From 551c1bf1fab7d85deb7c5fabe2897da63a425243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Mon, 4 May 2026 13:54:39 +0200 Subject: [PATCH 01/23] claude refactor --- android/settings.gradle | 2 +- lib/api/holidays/getHolidaysCache.dart | 2 +- .../marianumcloud/talk/chat/getChatCache.dart | 2 +- .../talk/chat/getChatResponse.dart | 2 +- .../getParticipants/getParticipantsCache.dart | 2 +- .../marianumcloud/talk/room/getRoomCache.dart | 2 +- .../queries/listFiles/listFilesCache.dart | 2 +- .../breaker/getBreakers/getBreakersCache.dart | 2 +- lib/api/requestCache.dart | 23 +- .../queries/getHolidays/getHolidaysCache.dart | 2 +- .../queries/getRooms/getRoomsCache.dart | 2 +- .../queries/getSubjects/getSubjectsCache.dart | 2 +- .../getTimetable/getTimetableCache.dart | 7 +- lib/app.dart | 151 +++--- lib/main.dart | 183 +++---- lib/model/accountData.dart | 79 ++- lib/model/accountModel.dart | 17 - lib/model/breakers/Breaker.dart | 36 -- lib/model/breakers/BreakerProps.dart | 52 -- lib/model/chatList/chatListProps.dart | 28 - lib/model/chatList/chatProps.dart | 63 --- lib/model/dataHolder.dart | 22 - lib/model/files/filesProps.dart | 52 -- lib/model/holidays/holidaysProps.dart | 24 - lib/model/timetable/timetableProps.dart | 130 ----- lib/notification/notificationTasks.dart | 16 +- lib/notification/notifyUpdater.dart | 41 +- .../view/loadable_state_consumer.dart | 9 +- .../utilityWidgets/bloc_module.dart | 4 +- .../loadable_hydrated_bloc.dart | 3 +- .../modules/account/bloc/account_bloc.dart | 12 + .../modules/account/bloc/account_event.dart | 10 + .../modules/account/bloc/account_state.dart | 8 + lib/state/app/modules/app_modules.dart | 23 +- .../modules/breaker/bloc/breaker_bloc.dart | 51 ++ .../modules/breaker/bloc/breaker_event.dart | 4 + .../modules/breaker/bloc/breaker_state.dart | 15 + .../breaker/bloc/breaker_state.freezed.dart | 277 ++++++++++ .../modules/breaker/bloc/breaker_state.g.dart | 19 + .../dataProvider/breaker_data_provider.dart | 14 + .../repository/breaker_repository.dart | 11 + .../app/modules/chat/bloc/chat_bloc.dart | 59 ++ .../app/modules/chat/bloc/chat_event.dart | 4 + .../app/modules/chat/bloc/chat_state.dart | 17 + .../modules/chat/bloc/chat_state.freezed.dart | 283 ++++++++++ .../app/modules/chat/bloc/chat_state.g.dart | 22 + .../chat/dataProvider/chat_data_provider.dart | 11 + .../chat/repository/chat_repository.dart | 11 + .../modules/chatList/bloc/chat_list_bloc.dart | 46 ++ .../chatList/bloc/chat_list_event.dart | 4 + .../chatList/bloc/chat_list_state.dart | 15 + .../bloc/chat_list_state.freezed.dart | 277 ++++++++++ .../chatList/bloc/chat_list_state.g.dart | 17 + .../dataProvider/chat_list_data_provider.dart | 22 + .../repository/chat_list_repository.dart | 11 + .../app/modules/files/bloc/files_bloc.dart | 52 ++ .../app/modules/files/bloc/files_event.dart | 4 + .../app/modules/files/bloc/files_state.dart | 16 + .../files/bloc/files_state.freezed.dart | 286 ++++++++++ .../app/modules/files/bloc/files_state.g.dart | 24 + .../dataProvider/files_data_provider.dart | 25 + .../files/repository/files_repository.dart | 11 + .../modules/settings/bloc/settings_cubit.dart | 65 +++ .../timetable/bloc/timetable_bloc.dart | 138 +++++ .../timetable/bloc/timetable_event.dart | 4 + .../timetable/bloc/timetable_state.dart | 33 ++ .../bloc/timetable_state.freezed.dart | 304 +++++++++++ .../timetable/bloc/timetable_state.g.dart | 52 ++ .../dataProvider/timetable_data_provider.dart | 87 +++ .../repository/timetable_repository.dart | 11 + lib/storage/base/settingsProvider.dart | 80 --- lib/storage/timetable/timetableSettings.dart | 2 +- .../timetable/timetable_name_mode.dart} | 15 +- lib/view/login/login.dart | 9 +- lib/view/pages/files/files.dart | 316 ++++++----- lib/view/pages/files/filesUploadDialog.dart | 65 ++- .../pages/more/feedback/feedbackDialog.dart | 9 +- lib/view/pages/more/roomplan/roomplan.dart | 2 +- .../more/share/appSharePlatformView.dart | 2 +- .../more/share/selectShareTypeDialog.dart | 16 +- lib/view/pages/overhang.dart | 25 +- lib/view/pages/talk/chatList.dart | 163 +++--- lib/view/pages/talk/chatView.dart | 118 ++-- .../talk/components/answerReference.dart | 4 +- .../pages/talk/components/chatBubble.dart | 16 +- .../talk/components/chatBubbleStyles.dart | 14 +- .../pages/talk/components/chatTextfield.dart | 340 ++++++------ lib/view/pages/talk/components/chatTile.dart | 289 +++++----- .../talk/components/pollOptionsList.dart | 2 +- lib/view/pages/talk/searchChat.dart | 2 +- .../timetable/appointmenetComponent.dart | 92 ---- .../pages/timetable/appointmentDetails.dart | 226 -------- .../pages/timetable/arbitraryAppointment.dart | 19 - .../customTimetableEventEditDialog.dart | 233 -------- .../custom_event_colors.dart} | 21 +- .../custom_event_edit_dialog.dart | 190 +++++++ .../custom_events/custom_events_view.dart | 78 +++ .../timetable/data/arbitrary_appointment.dart | 24 + .../pages/timetable/data/lesson_color.dart | 31 ++ .../pages/timetable/data/lesson_status.dart | 21 + .../data/timetable_appointment_factory.dart | 136 +++++ .../pages/timetable/data/webuntis_time.dart | 14 + .../timetable/details/_bottom_sheet.dart | 20 + .../appointment_details_dispatcher.dart | 19 + .../timetable/details/custom_event_sheet.dart | 88 +++ .../details/delete_custom_event.dart | 24 + .../details/webuntis_lesson_sheet.dart | 110 ++++ lib/view/pages/timetable/timetable.dart | 503 +++++------------- lib/view/pages/timetable/timetableEvents.dart | 8 - .../timetable/viewCustomTimetableEvents.dart | 95 ---- .../timetable/widgets/appointment_tile.dart | 68 +++ .../cross_painter.dart} | 0 .../widgets/lesson_appointment_source.dart | 7 + .../widgets/special_regions_builder.dart | 56 ++ .../time_region_tile.dart} | 29 +- lib/view/settings/defaultSettings.dart | 2 +- lib/view/settings/devToolsSettings.dart | 8 +- lib/view/settings/settings.dart | 24 +- lib/widget/breaker/breaker.dart | 27 + lib/widget/debug/debugTile.dart | 34 +- lib/widget/debug/jsonViewer.dart | 2 + lib/widget/fileViewer.dart | 7 +- lib/widget/infoDialog.dart | 2 +- lib/widget/placeholderView.dart | 2 +- pubspec.yaml | 2 + 125 files changed, 4484 insertions(+), 2544 deletions(-) delete mode 100644 lib/model/accountModel.dart delete mode 100644 lib/model/breakers/Breaker.dart delete mode 100644 lib/model/breakers/BreakerProps.dart delete mode 100644 lib/model/chatList/chatListProps.dart delete mode 100644 lib/model/chatList/chatProps.dart delete mode 100644 lib/model/dataHolder.dart delete mode 100644 lib/model/files/filesProps.dart delete mode 100644 lib/model/holidays/holidaysProps.dart delete mode 100644 lib/model/timetable/timetableProps.dart create mode 100644 lib/state/app/modules/account/bloc/account_bloc.dart create mode 100644 lib/state/app/modules/account/bloc/account_event.dart create mode 100644 lib/state/app/modules/account/bloc/account_state.dart create mode 100644 lib/state/app/modules/breaker/bloc/breaker_bloc.dart create mode 100644 lib/state/app/modules/breaker/bloc/breaker_event.dart create mode 100644 lib/state/app/modules/breaker/bloc/breaker_state.dart create mode 100644 lib/state/app/modules/breaker/bloc/breaker_state.freezed.dart create mode 100644 lib/state/app/modules/breaker/bloc/breaker_state.g.dart create mode 100644 lib/state/app/modules/breaker/dataProvider/breaker_data_provider.dart create mode 100644 lib/state/app/modules/breaker/repository/breaker_repository.dart create mode 100644 lib/state/app/modules/chat/bloc/chat_bloc.dart create mode 100644 lib/state/app/modules/chat/bloc/chat_event.dart create mode 100644 lib/state/app/modules/chat/bloc/chat_state.dart create mode 100644 lib/state/app/modules/chat/bloc/chat_state.freezed.dart create mode 100644 lib/state/app/modules/chat/bloc/chat_state.g.dart create mode 100644 lib/state/app/modules/chat/dataProvider/chat_data_provider.dart create mode 100644 lib/state/app/modules/chat/repository/chat_repository.dart create mode 100644 lib/state/app/modules/chatList/bloc/chat_list_bloc.dart create mode 100644 lib/state/app/modules/chatList/bloc/chat_list_event.dart create mode 100644 lib/state/app/modules/chatList/bloc/chat_list_state.dart create mode 100644 lib/state/app/modules/chatList/bloc/chat_list_state.freezed.dart create mode 100644 lib/state/app/modules/chatList/bloc/chat_list_state.g.dart create mode 100644 lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart create mode 100644 lib/state/app/modules/chatList/repository/chat_list_repository.dart create mode 100644 lib/state/app/modules/files/bloc/files_bloc.dart create mode 100644 lib/state/app/modules/files/bloc/files_event.dart create mode 100644 lib/state/app/modules/files/bloc/files_state.dart create mode 100644 lib/state/app/modules/files/bloc/files_state.freezed.dart create mode 100644 lib/state/app/modules/files/bloc/files_state.g.dart create mode 100644 lib/state/app/modules/files/dataProvider/files_data_provider.dart create mode 100644 lib/state/app/modules/files/repository/files_repository.dart create mode 100644 lib/state/app/modules/settings/bloc/settings_cubit.dart create mode 100644 lib/state/app/modules/timetable/bloc/timetable_bloc.dart create mode 100644 lib/state/app/modules/timetable/bloc/timetable_event.dart create mode 100644 lib/state/app/modules/timetable/bloc/timetable_state.dart create mode 100644 lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart create mode 100644 lib/state/app/modules/timetable/bloc/timetable_state.g.dart create mode 100644 lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart create mode 100644 lib/state/app/modules/timetable/repository/timetable_repository.dart delete mode 100644 lib/storage/base/settingsProvider.dart rename lib/{view/pages/timetable/timetableNameMode.dart => storage/timetable/timetable_name_mode.dart} (70%) delete mode 100644 lib/view/pages/timetable/appointmenetComponent.dart delete mode 100644 lib/view/pages/timetable/appointmentDetails.dart delete mode 100644 lib/view/pages/timetable/arbitraryAppointment.dart delete mode 100644 lib/view/pages/timetable/customTimetableEventEditDialog.dart rename lib/view/pages/timetable/{customTimetableColors.dart => custom_events/custom_event_colors.dart} (72%) create mode 100644 lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart create mode 100644 lib/view/pages/timetable/custom_events/custom_events_view.dart create mode 100644 lib/view/pages/timetable/data/arbitrary_appointment.dart create mode 100644 lib/view/pages/timetable/data/lesson_color.dart create mode 100644 lib/view/pages/timetable/data/lesson_status.dart create mode 100644 lib/view/pages/timetable/data/timetable_appointment_factory.dart create mode 100644 lib/view/pages/timetable/data/webuntis_time.dart create mode 100644 lib/view/pages/timetable/details/_bottom_sheet.dart create mode 100644 lib/view/pages/timetable/details/appointment_details_dispatcher.dart create mode 100644 lib/view/pages/timetable/details/custom_event_sheet.dart create mode 100644 lib/view/pages/timetable/details/delete_custom_event.dart create mode 100644 lib/view/pages/timetable/details/webuntis_lesson_sheet.dart delete mode 100644 lib/view/pages/timetable/timetableEvents.dart delete mode 100644 lib/view/pages/timetable/viewCustomTimetableEvents.dart create mode 100644 lib/view/pages/timetable/widgets/appointment_tile.dart rename lib/view/pages/timetable/{CrossPainter.dart => widgets/cross_painter.dart} (100%) create mode 100644 lib/view/pages/timetable/widgets/lesson_appointment_source.dart create mode 100644 lib/view/pages/timetable/widgets/special_regions_builder.dart rename lib/view/pages/timetable/{timeRegionComponent.dart => widgets/time_region_tile.dart} (62%) create mode 100644 lib/widget/breaker/breaker.dart diff --git a/android/settings.gradle b/android/settings.gradle index 82e2b5a..9ba153b 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -7,7 +7,7 @@ pluginManagement { return flutterSdkPath } settings.ext.flutterSdkPath = flutterSdkPath() - +0 includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") repositories { diff --git a/lib/api/holidays/getHolidaysCache.dart b/lib/api/holidays/getHolidaysCache.dart index 49e04e1..66e3147 100644 --- a/lib/api/holidays/getHolidaysCache.dart +++ b/lib/api/holidays/getHolidaysCache.dart @@ -5,7 +5,7 @@ import 'getHolidays.dart'; import 'getHolidaysResponse.dart'; class GetHolidaysCache extends RequestCache { - GetHolidaysCache({onUpdate, renew}) : super(RequestCache.cacheDay, onUpdate, renew: renew) { + GetHolidaysCache({void Function(GetHolidaysResponse)? onUpdate, bool? renew}) : super(RequestCache.cacheDay, onUpdate, renew: renew) { start('state-holidays'); } diff --git a/lib/api/marianumcloud/talk/chat/getChatCache.dart b/lib/api/marianumcloud/talk/chat/getChatCache.dart index 3792365..60de7c1 100644 --- a/lib/api/marianumcloud/talk/chat/getChatCache.dart +++ b/lib/api/marianumcloud/talk/chat/getChatCache.dart @@ -8,7 +8,7 @@ import 'getChatResponse.dart'; class GetChatCache extends RequestCache { String chatToken; - GetChatCache({required onUpdate, required this.chatToken}) : super(RequestCache.cacheNothing, onUpdate) { + GetChatCache({required void Function(GetChatResponse) onUpdate, required this.chatToken}) : super(RequestCache.cacheNothing, onUpdate) { start('nc-chat-$chatToken'); } diff --git a/lib/api/marianumcloud/talk/chat/getChatResponse.dart b/lib/api/marianumcloud/talk/chat/getChatResponse.dart index 840df36..2c1db07 100644 --- a/lib/api/marianumcloud/talk/chat/getChatResponse.dart +++ b/lib/api/marianumcloud/talk/chat/getChatResponse.dart @@ -86,7 +86,7 @@ class GetChatResponseObject { } -Map? _fromJson(json) { +Map? _fromJson(dynamic json) { if(json is Map) { var data = {}; for (var element in json.keys) { diff --git a/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart b/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart index 55df84d..a3fddc1 100644 --- a/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart +++ b/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart @@ -7,7 +7,7 @@ import 'getParticipantsResponse.dart'; class GetParticipantsCache extends RequestCache { String chatToken; - GetParticipantsCache({required onUpdate, required this.chatToken}) : super(RequestCache.cacheNothing, onUpdate) { + GetParticipantsCache({required void Function(GetParticipantsResponse) onUpdate, required this.chatToken}) : super(RequestCache.cacheNothing, onUpdate) { start('nc-chat-participants-$chatToken'); } diff --git a/lib/api/marianumcloud/talk/room/getRoomCache.dart b/lib/api/marianumcloud/talk/room/getRoomCache.dart index a4ea708..54f8578 100644 --- a/lib/api/marianumcloud/talk/room/getRoomCache.dart +++ b/lib/api/marianumcloud/talk/room/getRoomCache.dart @@ -7,7 +7,7 @@ import 'getRoomParams.dart'; import 'getRoomResponse.dart'; class GetRoomCache extends RequestCache { - GetRoomCache({onUpdate, renew}) : super(RequestCache.cacheMinute, onUpdate, renew: renew) { + GetRoomCache({void Function(GetRoomResponse)? onUpdate, bool? renew}) : super(RequestCache.cacheMinute, onUpdate, renew: renew) { start('nc-rooms'); } diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart b/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart index 845dc47..2a3e2fc 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart +++ b/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart @@ -9,7 +9,7 @@ import 'listFilesResponse.dart'; class ListFilesCache extends RequestCache { String path; - ListFilesCache({required onUpdate, required this.path}) : super(RequestCache.cacheNothing, onUpdate) { + ListFilesCache({required void Function(ListFilesResponse) onUpdate, required this.path}) : super(RequestCache.cacheNothing, onUpdate) { var bytes = utf8.encode('MarianumMobile-$path'); var cacheName = md5.convert(bytes).toString(); start('wd-folder-$cacheName'); diff --git a/lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart b/lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart index 9f6ef34..9f7e24d 100644 --- a/lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart +++ b/lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart @@ -5,7 +5,7 @@ import 'getBreakersResponse.dart'; class GetBreakersCache extends RequestCache { - GetBreakersCache({onUpdate, renew}) : super(RequestCache.cacheMinute, onUpdate, renew: renew) { + GetBreakersCache({void Function(GetBreakersResponse)? onUpdate, bool? renew}) : super(RequestCache.cacheMinute, onUpdate, renew: renew) { start('breakers'); } diff --git a/lib/api/requestCache.dart b/lib/api/requestCache.dart index 0420415..cbbf15b 100644 --- a/lib/api/requestCache.dart +++ b/lib/api/requestCache.dart @@ -13,8 +13,8 @@ abstract class RequestCache { static String collection = 'MarianumMobile'; int maxCacheTime; - Function(T) onUpdate; - Function(Exception) onError; + void Function(T)? onUpdate; + void Function(Exception) onError; bool? renew; RequestCache(this.maxCacheTime, this.onUpdate, {this.onError = ignore, this.renew = false}); @@ -22,24 +22,23 @@ abstract class RequestCache { static void ignore(Exception e) {} Future start(String document) async { - var tableData = await Localstore.instance.collection(collection).doc(document).get(); - if(tableData != null) { - onUpdate(onLocalData(tableData['json'])); + final tableData = await Localstore.instance.collection(collection).doc(document).get(); + if (tableData != null) { + onUpdate?.call(onLocalData(tableData['json'])); } - if(DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < (tableData?['lastupdate'] ?? 0)) { - if(renew == null || !renew!) return; + if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < (tableData?['lastupdate'] ?? 0)) { + if (renew == null || !renew!) return; } try { - var newValue = await onLoad(); - onUpdate(newValue); - + final newValue = await onLoad(); + onUpdate?.call(newValue); Localstore.instance.collection(collection).doc(document).set({ 'json': jsonEncode(newValue), - 'lastupdate': DateTime.now().millisecondsSinceEpoch + 'lastupdate': DateTime.now().millisecondsSinceEpoch, }); - } on Exception catch(e) { + } on Exception catch (e) { onError(e); } } diff --git a/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart b/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart index a91decd..c4e4627 100644 --- a/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart +++ b/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart @@ -5,7 +5,7 @@ import 'getHolidays.dart'; import 'getHolidaysResponse.dart'; class GetHolidaysCache extends RequestCache { - GetHolidaysCache({onUpdate}) : super(RequestCache.cacheDay, onUpdate) { + GetHolidaysCache({void Function(GetHolidaysResponse)? onUpdate}) : super(RequestCache.cacheDay, onUpdate) { start('wu-holidays'); } diff --git a/lib/api/webuntis/queries/getRooms/getRoomsCache.dart b/lib/api/webuntis/queries/getRooms/getRoomsCache.dart index e33589b..33d00ee 100644 --- a/lib/api/webuntis/queries/getRooms/getRoomsCache.dart +++ b/lib/api/webuntis/queries/getRooms/getRoomsCache.dart @@ -5,7 +5,7 @@ import 'getRooms.dart'; import 'getRoomsResponse.dart'; class GetRoomsCache extends RequestCache { - GetRoomsCache({onUpdate}) : super(RequestCache.cacheHour, onUpdate) { + GetRoomsCache({void Function(GetRoomsResponse)? onUpdate}) : super(RequestCache.cacheHour, onUpdate) { start('wu-rooms'); } diff --git a/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart b/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart index 07a5ede..bec137b 100644 --- a/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart +++ b/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart @@ -5,7 +5,7 @@ import 'getSubjects.dart'; import 'getSubjectsResponse.dart'; class GetSubjectsCache extends RequestCache { - GetSubjectsCache({onUpdate}) : super(RequestCache.cacheHour, onUpdate) { + GetSubjectsCache({void Function(GetSubjectsResponse)? onUpdate}) : super(RequestCache.cacheHour, onUpdate) { start('wu-subjects'); } diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart b/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart index 3b6d87a..0872b70 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart +++ b/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart @@ -10,7 +10,12 @@ class GetTimetableCache extends RequestCache { int startdate; int enddate; - GetTimetableCache({required onUpdate, onError, required this.startdate, required this.enddate}) : super(RequestCache.cacheMinute, onUpdate, onError: onError) { + GetTimetableCache({ + required void Function(GetTimetableResponse) onUpdate, + void Function(Exception)? onError, + required this.startdate, + required this.enddate, + }) : super(RequestCache.cacheMinute, onUpdate, onError: onError ?? RequestCache.ignore) { start('wu-timetable-$startdate-$enddate'); } diff --git a/lib/app.dart b/lib/app.dart index 0b4a03b..86b2650 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,26 +1,24 @@ - import 'dart:async'; import 'dart:developer'; import 'package:easy_debounce/easy_throttle.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; -import 'state/app/modules/app_modules.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:provider/provider.dart'; import 'api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; import 'api/mhsl/server/userIndex/update/updateUserindex.dart'; import 'main.dart'; -import 'model/breakers/Breaker.dart'; -import 'model/breakers/BreakerProps.dart'; -import 'model/chatList/chatListProps.dart'; +import 'widget/breaker/breaker.dart'; import 'model/dataCleaner.dart'; -import 'model/timetable/timetableProps.dart'; import 'notification/notificationController.dart'; import 'notification/notificationTasks.dart'; import 'notification/notifyUpdater.dart'; -import 'storage/base/settingsProvider.dart'; +import 'state/app/modules/app_modules.dart'; +import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; +import 'state/app/modules/chatList/bloc/chat_list_bloc.dart'; +import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'view/pages/overhang.dart'; class App extends StatefulWidget { @@ -31,101 +29,106 @@ class App extends StatefulWidget { } class _AppState extends State with WidgetsBindingObserver { - - late Timer refetchChats; - late Timer updateTimings; + late Timer _refetchChats; + late Timer _updateTimings; @override void didChangeAppLifecycleState(AppLifecycleState state) { - log('AppLifecycle: ${state.toString()}'); - - if(state == AppLifecycleState.resumed) { - EasyThrottle.throttle( - 'appLifecycleState', - const Duration(seconds: 10), - () { - log('Refreshing due to LifecycleChange'); - NotificationTasks.updateProviders(context); - Provider.of(context, listen: false).run(); - } - ); + log('AppLifecycle: $state'); + if (state == AppLifecycleState.resumed) { + EasyThrottle.throttle('appLifecycleState', const Duration(seconds: 10), () { + if (!mounted) return; + log('Refreshing due to LifecycleChange'); + NotificationTasks.updateProviders(context); + }); } } - @override void initState() { + super.initState(); Main.bottomNavigator = PersistentTabController(initialIndex: 0); WidgetsBinding.instance.addObserver(this); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - Provider.of(context, listen: false).run(); - Provider.of(context, listen: false).run(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().refresh(); + context.read().refresh(); }); - updateTimings = Timer.periodic(const Duration(seconds: 30), (Timer t) => setState((){})); + _updateTimings = Timer.periodic(const Duration(seconds: 30), (_) { + if (mounted) setState(() {}); + }); - refetchChats = Timer.periodic(const Duration(seconds: 60), (timer) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - Provider.of(context, listen: false).run(); + _refetchChats = Timer.periodic(const Duration(seconds: 60), (_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().refresh(); }); }); - // User index UpdateUserIndex.index(); - // User Notifications - if(Provider.of(context, listen: false).val().notificationSettings.enabled) { - update() => NotifyUpdater.registerToServer(); - FirebaseMessaging.instance.onTokenRefresh.listen((event) => update()); + if (context.read().val().notificationSettings.enabled) { + void update() => NotifyUpdater.registerToServer(); + FirebaseMessaging.instance.onTokenRefresh.listen((_) => update()); update(); } - - FirebaseMessaging.onMessage.listen((message) => NotificationController.onForegroundMessageHandler(message, context)); + + FirebaseMessaging.onMessage.listen((message) { + if (!mounted) return; + NotificationController.onForegroundMessageHandler(message, context); + }); FirebaseMessaging.onBackgroundMessage(NotificationController.onBackgroundMessageHandler); - FirebaseMessaging.onMessageOpenedApp.listen((message) => NotificationController.onAppOpenedByNotification(message, context)); - FirebaseMessaging.instance.getInitialMessage().then((message) => message == null ? null : NotificationController.onAppOpenedByNotification(message, context)); + FirebaseMessaging.onMessageOpenedApp.listen((message) { + if (!mounted) return; + NotificationController.onAppOpenedByNotification(message, context); + }); + FirebaseMessaging.instance.getInitialMessage().then((message) { + if (message == null || !mounted) return; + NotificationController.onAppOpenedByNotification(message, context); + }); DataCleaner.cleanOldCache(); - - super.initState(); } - @override - Widget build(BuildContext context) => Consumer(builder: (context, settings, child) => PersistentTabView( - controller: Main.bottomNavigator, - navBarOverlap: const NavBarOverlap.none(), - backgroundColor: Theme.of(context).colorScheme.primary, - handleAndroidBackButtonPress: false, - - screenTransitionAnimation: const ScreenTransitionAnimation(curve: Curves.easeOutQuad, duration: Duration(milliseconds: 200)), - tabs: [ - ...AppModule.getBottomBarModules(context).map((e) => e.toBottomTab(context)), - - PersistentTabConfig( - screen: const Breaker(breaker: BreakerArea.more, child: Overhang()), - item: ItemConfig( - activeForegroundColor: Theme.of(context).primaryColor, - inactiveForegroundColor: Theme.of(context).colorScheme.secondary, - icon: const Icon(Icons.apps), - title: 'Mehr' - ), - ), - ], - navBarBuilder: (config) => Style6BottomNavBar( - navBarConfig: config, - navBarDecoration: NavBarDecoration( - border: const Border(top: BorderSide(width: 1, color: Colors.grey)), - color: Theme.of(context).colorScheme.surface, - ), - ), - )); - @override void dispose() { - refetchChats.cancel(); - updateTimings.cancel(); + _refetchChats.cancel(); + _updateTimings.cancel(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } + + @override + Widget build(BuildContext context) => PersistentTabView( + controller: Main.bottomNavigator, + navBarOverlap: const NavBarOverlap.none(), + backgroundColor: Theme.of(context).colorScheme.primary, + handleAndroidBackButtonPress: false, + screenTransitionAnimation: const ScreenTransitionAnimation( + curve: Curves.easeOutQuad, + duration: Duration(milliseconds: 200), + ), + tabs: [ + ...AppModule.getBottomBarModules(context).map((e) => e.toBottomTab(context)), + PersistentTabConfig( + screen: const Breaker(breaker: BreakerArea.more, child: Overhang()), + item: ItemConfig( + activeForegroundColor: Theme.of(context).primaryColor, + inactiveForegroundColor: Theme.of(context).colorScheme.secondary, + icon: const Icon(Icons.apps), + title: 'Mehr', + ), + ), + ], + navBarBuilder: (config) => Style6BottomNavBar( + navBarConfig: config, + navBarDecoration: NavBarDecoration( + border: const Border(top: BorderSide(width: 1, color: Colors.grey)), + color: Theme.of(context).colorScheme.surface, + ), + ), + ); } diff --git a/lib/main.dart b/lib/main.dart index fb1415e..5c3d278 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,33 +1,34 @@ import 'dart:async'; import 'dart:developer'; import 'dart:io'; +import 'dart:ui'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:jiffy/jiffy.dart'; import 'package:loader_overlay/loader_overlay.dart'; import 'package:path_provider/path_provider.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:provider/provider.dart'; import 'api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; import 'app.dart'; import 'firebase_options.dart'; import 'model/accountData.dart'; -import 'model/accountModel.dart'; -import 'model/breakers/Breaker.dart'; -import 'model/breakers/BreakerProps.dart'; -import 'model/chatList/chatListProps.dart'; -import 'model/chatList/chatProps.dart'; -import 'model/files/filesProps.dart'; -import 'model/holidays/holidaysProps.dart'; -import 'model/timetable/timetableProps.dart'; -import 'storage/base/settingsProvider.dart'; +import 'widget/breaker/breaker.dart'; +import 'state/app/modules/account/bloc/account_bloc.dart'; +import 'state/app/modules/account/bloc/account_state.dart'; +import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; +import 'state/app/modules/chat/bloc/chat_bloc.dart'; +import 'state/app/modules/chatList/bloc/chat_list_bloc.dart'; +import 'state/app/modules/settings/bloc/settings_cubit.dart'; +import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; +import 'storage/base/settings.dart'; import 'theming/darkAppTheme.dart'; import 'theming/lightAppTheme.dart'; import 'view/login/login.dart'; @@ -37,133 +38,123 @@ Future main() async { log('MarianumMobile started'); WidgetsFlutterBinding.ensureInitialized(); - addCertificateAsTrusted(ByteData certificate) => SecurityContext.defaultContext.setTrustedCertificatesBytes(certificate.buffer.asUint8List()); + void addCertificateAsTrusted(ByteData certificate) => + SecurityContext.defaultContext.setTrustedCertificatesBytes(certificate.buffer.asUint8List()); - var initialisationTasks = [ + final initialisationTasks = [ Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform) - .then((value) async => log("Firebase token: ${await FirebaseMessaging.instance.getToken() ?? "Error: no Firebase token!"}")) - .onError((error, stackTrace) => log('Error initializing Firebase: $error')), - + .then((_) async => log('Firebase token: ${await FirebaseMessaging.instance.getToken() ?? "Error: no Firebase token!"}')) + .onError((error, _) => log('Error initializing Firebase: $error')), PlatformAssetBundle().load('assets/ca/lets-encrypt-r3.pem').then(addCertificateAsTrusted), PlatformAssetBundle().load('assets/ca/lets-encrypt-r10.pem').then(addCertificateAsTrusted), PlatformAssetBundle().load('assets/ca/lets-encrypt-r13.pem').then(addCertificateAsTrusted), - Future(() async { - await HydratedStorage.build( - storageDirectory: HydratedStorageDirectory((await getTemporaryDirectory()).path) - ).then((storage) => HydratedBloc.storage = storage); - }) + final storage = await HydratedStorage.build( + storageDirectory: HydratedStorageDirectory((await getTemporaryDirectory()).path), + ); + HydratedBloc.storage = storage; + }), ]; log('starting app initialisation...'); await Future.wait(initialisationTasks); log('app initialisation done!'); - if(kReleaseMode) { + if (kReleaseMode) { ErrorWidget.builder = (error) => PlaceholderView( - icon: Icons.phonelink_erase_rounded, - text: error.toStringShort(), - ); + icon: Icons.phonelink_erase_rounded, + text: error.toStringShort(), + ); } + // Capture uncaught Flutter and platform errors so they show up in logs + // instead of being silently swallowed. + FlutterError.onError = (details) { + log('Uncaught Flutter error: ${details.exception}', stackTrace: details.stack); + FlutterError.presentError(details); + }; + PlatformDispatcher.instance.onError = (error, stack) { + log('Uncaught platform error: $error', stackTrace: stack); + return true; + }; + log('running app...'); runApp( - MultiProvider( - providers: [ - ChangeNotifierProvider(create: (context) => BreakerProps()), - - ChangeNotifierProvider(create: (context) => SettingsProvider()), - ChangeNotifierProvider(create: (context) => AccountModel()), - - ChangeNotifierProvider(create: (context) => TimetableProps()), - ChangeNotifierProvider(create: (context) => ChatListProps()), - ChangeNotifierProvider(create: (context) => ChatProps()), - ChangeNotifierProvider(create: (context) => FilesProps()), - - ChangeNotifierProvider(create: (context) => HolidaysProps()), - ], - child: const Main(), - ) + MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => SettingsCubit()), + BlocProvider(create: (_) => AccountBloc()), + BlocProvider(create: (_) => BreakerBloc()), + BlocProvider(create: (_) => ChatListBloc()), + BlocProvider(create: (_) => ChatBloc()), + BlocProvider(create: (_) => TimetableBloc()), + ], + child: const Main(), + ), ); } class Main extends StatefulWidget { const Main({super.key}); - static PersistentTabController bottomNavigator = PersistentTabController(initialIndex: 0); + static PersistentTabController bottomNavigator = PersistentTabController(initialIndex: 0); @override State
createState() => _MainState(); } class _MainState extends State
{ - late Timer refetchProps; - @override void initState() { + super.initState(); Jiffy.setLocale('de'); AccountData().waitForPopulation().then((value) { - Provider.of(context, listen: false) - .setState(value ? AccountModelState.loggedIn : AccountModelState.loggedOut); + if (!mounted) return; + context.read().setStatus(value ? AccountStatus.loggedIn : AccountStatus.loggedOut); }); - - refetchProps = Timer.periodic(const Duration(seconds: 60), (timer) { - Provider.of(context, listen: false).run(); - }); - - super.initState(); } @override Widget build(BuildContext context) => Directionality( - textDirection: TextDirection.ltr, - child: Consumer( - builder: (context, settings, child) { - var devToolsSettings = settings.val().devToolsSettings; - return MaterialApp( - showPerformanceOverlay: devToolsSettings.showPerformanceOverlay, - checkerboardOffscreenLayers: devToolsSettings.checkerboardOffscreenLayers, - checkerboardRasterCacheImages: devToolsSettings.checkerboardRasterCacheImages, - - debugShowCheckedModeBanner: false, - localizationsDelegates: const [ - ...GlobalMaterialLocalizations.delegates, - GlobalWidgetsLocalizations.delegate, - ], - supportedLocales: const [ - Locale('de'), - Locale('en'), - ], - locale: const Locale('de'), - - title: 'Marianum Fulda', - - themeMode: settings.val().appTheme, - theme: LightAppTheme.theme, - darkTheme: DarkAppTheme.theme, - home: LoaderOverlay( - child: Breaker( + textDirection: TextDirection.ltr, + child: BlocBuilder( + builder: (context, settings) { + final devToolsSettings = settings.devToolsSettings; + return MaterialApp( + showPerformanceOverlay: devToolsSettings.showPerformanceOverlay, + checkerboardOffscreenLayers: devToolsSettings.checkerboardOffscreenLayers, + checkerboardRasterCacheImages: devToolsSettings.checkerboardRasterCacheImages, + debugShowCheckedModeBanner: false, + localizationsDelegates: const [ + ...GlobalMaterialLocalizations.delegates, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: const [Locale('de'), Locale('en')], + locale: const Locale('de'), + title: 'Marianum Fulda', + themeMode: settings.appTheme, + theme: LightAppTheme.theme, + darkTheme: DarkAppTheme.theme, + home: LoaderOverlay( + child: Breaker( breaker: BreakerArea.global, - child: Consumer( - builder: (context, accountModel, child) { - switch(accountModel.state) { - case AccountModelState.loggedIn: return const App(); - case AccountModelState.loggedOut: return const Login(); - case AccountModelState.undefined: return const PlaceholderView(icon: Icons.timer, text: 'Daten werden geladen'); + child: BlocBuilder( + builder: (context, accountState) { + switch (accountState.status) { + case AccountStatus.loggedIn: + return const App(); + case AccountStatus.loggedOut: + return const Login(); + case AccountStatus.undefined: + return const PlaceholderView(icon: Icons.timer, text: 'Daten werden geladen'); } }, - ) + ), + ), ), - ), - ); - }, - ), - ); - - @override - void dispose() { - refetchProps.cancel(); - super.dispose(); - } + ); + }, + ), + ); } diff --git a/lib/model/accountData.dart b/lib/model/accountData.dart index 419146a..03e2f53 100644 --- a/lib/model/accountData.dart +++ b/lib/model/accountData.dart @@ -4,68 +4,91 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/cupertino.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'accountModel.dart'; +import '../state/app/modules/account/bloc/account_bloc.dart'; +import '../state/app/modules/account/bloc/account_state.dart'; class AccountData { static const _usernameField = 'username'; static const _passwordField = 'password'; - + + static const FlutterSecureStorage _secureStorage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + ); + static final AccountData _instance = AccountData._construct(); - final Future _storage = SharedPreferences.getInstance(); Completer _populated = Completer(); factory AccountData() => _instance; AccountData._construct() { - _updateFromStorage(); + _migrateAndLoad(); } String? _username; String? _password; String getUsername() { - if(_username == null) throw Exception('Username not initialized'); + if (_username == null) throw Exception('Username not initialized'); return _username!; } String getPassword() { - if(_password == null) throw Exception('Password not initialized'); + if (_password == null) throw Exception('Password not initialized'); return _password!; } - String getUserSecret() => sha512.convert(utf8.encode('${AccountData().getUsername()}:${AccountData().getPassword()}')).toString(); + String getUserSecret() => + sha512.convert(utf8.encode('${getUsername()}:${getPassword()}')).toString(); - Future getDeviceId() async => sha512.convert(utf8.encode('${getUserSecret()}@${await FirebaseMessaging.instance.getToken()}')).toString(); + Future getDeviceId() async => sha512 + .convert(utf8.encode('${getUserSecret()}@${await FirebaseMessaging.instance.getToken()}')) + .toString(); Future setData(String username, String password) async { - var storage = await _storage; - - storage.setString(_usernameField, username); - storage.setString(_passwordField, password); - await _updateFromStorage(); + await _secureStorage.write(key: _usernameField, value: username); + await _secureStorage.write(key: _passwordField, value: password); + _username = username; + _password = password; + if (!_populated.isCompleted) _populated.complete(); } Future removeData({BuildContext? context}) async { _populated = Completer(); - - if(context != null) Provider.of(context, listen: false).setState(AccountModelState.loggedOut); - - var storage = await _storage; - await storage.remove(_usernameField); - await storage.remove(_passwordField); + if (context != null) { + context.read().setStatus(AccountStatus.loggedOut); + } + _username = null; + _password = null; + await _secureStorage.delete(key: _usernameField); + await _secureStorage.delete(key: _passwordField); } - Future _updateFromStorage() async { - var storage = await _storage; - //await storage.reload(); // This line was the cause of the first rejected google play upload :( - if(storage.containsKey(_usernameField) && storage.containsKey(_passwordField)) { - _username = storage.getString(_usernameField); - _password = storage.getString(_passwordField); + Future _migrateAndLoad() async { + await _migrateFromLegacyStorage(); + _username = await _secureStorage.read(key: _usernameField); + _password = await _secureStorage.read(key: _passwordField); + if (!_populated.isCompleted) _populated.complete(); + } + + // Move credentials from the old SharedPreferences plain-text storage into the + // platform's secure keystore. Run once per install and clear the legacy keys. + Future _migrateFromLegacyStorage() async { + final prefs = await SharedPreferences.getInstance(); + final legacyUsername = prefs.getString(_usernameField); + final legacyPassword = prefs.getString(_passwordField); + if (legacyUsername == null || legacyPassword == null) return; + + final hasSecure = (await _secureStorage.read(key: _usernameField)) != null; + if (!hasSecure) { + await _secureStorage.write(key: _usernameField, value: legacyUsername); + await _secureStorage.write(key: _passwordField, value: legacyPassword); } - if(!_populated.isCompleted) _populated.complete(); + await prefs.remove(_usernameField); + await prefs.remove(_passwordField); } Future waitForPopulation() async { @@ -76,7 +99,7 @@ class AccountData { bool isPopulated() => _username != null && _password != null; String buildHttpAuthString() { - if(!isPopulated()) throw Exception('AccountData (e.g. username or password) is not initialized!'); + if (!isPopulated()) throw Exception('AccountData (e.g. username or password) is not initialized!'); return '$_username:$_password'; } } diff --git a/lib/model/accountModel.dart b/lib/model/accountModel.dart deleted file mode 100644 index 26abcfd..0000000 --- a/lib/model/accountModel.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/cupertino.dart'; - -class AccountModel extends ChangeNotifier { - AccountModelState _accountState = AccountModelState.undefined; - AccountModelState get state => _accountState; - - void setState(AccountModelState state) { - _accountState = state; - notifyListeners(); - } -} - -enum AccountModelState { - undefined, - loggedIn, - loggedOut, -} diff --git a/lib/model/breakers/Breaker.dart b/lib/model/breakers/Breaker.dart deleted file mode 100644 index d9ad93e..0000000 --- a/lib/model/breakers/Breaker.dart +++ /dev/null @@ -1,36 +0,0 @@ - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; -import '../../widget/placeholderView.dart'; -import 'BreakerProps.dart'; - - -class Breaker extends StatefulWidget { - final BreakerArea breaker; - final Widget child; - - const Breaker({required this.breaker, required this.child, super.key}); - - @override - State createState() => _BreakerState(); -} - -class _BreakerState extends State { - @override - Widget build(BuildContext context) => Consumer( - builder: (context, value, child) { - var blocked = value.isBlocked(widget.breaker); - if(blocked != null) { - return PlaceholderView( - icon: Icons.app_blocking_outlined, - text: 'Die App / Dieser Bereich ist zurzeit nicht verfügbar!\n\n' - "${blocked.isEmpty ? "Es wurde vom Server kein Grund übermittelt.\nAktualisiere die App und versuche es später erneut" : blocked}" - ); - } - - return widget.child; - }, - ); -} diff --git a/lib/model/breakers/BreakerProps.dart b/lib/model/breakers/BreakerProps.dart deleted file mode 100644 index 4386c2f..0000000 --- a/lib/model/breakers/BreakerProps.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:package_info_plus/package_info_plus.dart'; - -import '../../api/apiResponse.dart'; -import '../../api/mhsl/breaker/getBreakers/getBreakersCache.dart'; -import '../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; -import '../dataHolder.dart'; - -class BreakerProps extends DataHolder { - GetBreakersResponse? _getBreakersResponse; - GetBreakersResponse get getBreakersResponse => _getBreakersResponse!; - - PackageInfo? packageInfo; - - String? isBlocked(BreakerArea? type) { - if(kDebugMode) return null; - - if(packageInfo == null) { - PackageInfo.fromPlatform().then((value) => packageInfo = value); - return null; - } - - if(primaryLoading()) return null; - var breakers = _getBreakersResponse!; - - if(breakers.global.areas.contains(type)) return breakers.global.message; - - var selfVersion = int.parse(packageInfo!.buildNumber); - for(var key in breakers.regional.keys) { - var value = breakers.regional[key]!; - - if(int.parse(key.split('b')[1]) >= selfVersion) { - if(value.areas.contains(type)) return value.message; - } - } - - return null; - } - - @override - List properties() => [_getBreakersResponse]; - - @override - void run() { - GetBreakersCache( - onUpdate: (GetBreakersResponse getBreakersResponse) { - _getBreakersResponse = getBreakersResponse; - notifyListeners(); - } - ); - } -} diff --git a/lib/model/chatList/chatListProps.dart b/lib/model/chatList/chatListProps.dart deleted file mode 100644 index f1b7005..0000000 --- a/lib/model/chatList/chatListProps.dart +++ /dev/null @@ -1,28 +0,0 @@ - -import 'package:flutter_app_badge/flutter_app_badge.dart'; - -import '../../api/apiResponse.dart'; -import '../../api/marianumcloud/talk/room/getRoomCache.dart'; -import '../../api/marianumcloud/talk/room/getRoomResponse.dart'; -import '../dataHolder.dart'; - -class ChatListProps extends DataHolder { - GetRoomResponse? _getRoomResponse; - GetRoomResponse get getRoomsResponse => _getRoomResponse!; - - @override - List properties() => [_getRoomResponse]; - - @override - void run({renew}) { - GetRoomCache( - renew: renew, - onUpdate: (GetRoomResponse data) => { - _getRoomResponse = data, - notifyListeners(), - FlutterAppBadge.count(data.data.map((e) => e.unreadMessages).reduce((a, b) => a+b)) - } - ); - } - -} diff --git a/lib/model/chatList/chatProps.dart b/lib/model/chatList/chatProps.dart deleted file mode 100644 index 80b1038..0000000 --- a/lib/model/chatList/chatProps.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:provider/provider.dart'; - -import '../../api/apiResponse.dart'; -import '../../api/marianumcloud/talk/chat/getChatCache.dart'; -import '../../api/marianumcloud/talk/chat/getChatResponse.dart'; -import '../../storage/base/settingsProvider.dart'; -import '../dataHolder.dart'; - -class ChatProps extends DataHolder { - String _queryToken = ''; - DateTime _lastTokenSet = DateTime.now(); - int? _referenceMessageId; - - GetChatResponse? _getChatResponse; - GetChatResponse get getChatResponse => _getChatResponse!; - - int? get getReferenceMessageId => _referenceMessageId; - set unsafeInternalSetReferenceMessageId(int? reference) => _referenceMessageId = reference; - - @override - List properties() => [_getChatResponse]; - - @override - void run() { - notifyListeners(); - if(_queryToken.isEmpty) return; - var requestStart = DateTime.now(); - - GetChatCache( - chatToken: _queryToken, - onUpdate: (GetChatResponse data) { - if(_lastTokenSet.isAfter(requestStart)) return; // Another request was faster - - _getChatResponse = data; - notifyListeners(); - } - ); - } - - void setReferenceMessageId(int? messageId, BuildContext context, String sendToToken) { - Future.microtask(() { - _referenceMessageId = messageId; - notifyListeners(); - - var settings = Provider.of(context, listen: false); - if(messageId != null) { - settings.val(write: true).talkSettings.draftReplies[sendToToken] = messageId; - } else { - settings.val(write: true).talkSettings.draftReplies.removeWhere((key, value) => key == sendToToken); - } - }); - } - - void setQueryToken(String token) { - _queryToken = token; - _getChatResponse = null; - _lastTokenSet = DateTime.now(); - run(); - } - - String currentToken() => _queryToken; -} diff --git a/lib/model/dataHolder.dart b/lib/model/dataHolder.dart deleted file mode 100644 index 84b3dbd..0000000 --- a/lib/model/dataHolder.dart +++ /dev/null @@ -1,22 +0,0 @@ - -import 'package:flutter/cupertino.dart'; -import 'package:localstore/localstore.dart'; - -import '../api/apiResponse.dart'; - -abstract class DataHolder extends ChangeNotifier { - - CollectionRef storage(String path) => Localstore.instance.collection(path); - - void run(); - List properties(); - - bool primaryLoading() { - // log("${toString()} ${properties().map((e) => e != null ? "1" : "0").join(", ")}"); - for(var element in properties()) { - if(element == null) return true; - } - return false; - //return properties().where((element) => element != null).isEmpty; - } -} diff --git a/lib/model/files/filesProps.dart b/lib/model/files/filesProps.dart deleted file mode 100644 index 979c708..0000000 --- a/lib/model/files/filesProps.dart +++ /dev/null @@ -1,52 +0,0 @@ - -import '../../api/apiResponse.dart'; -import '../../api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart'; -import '../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart'; -import '../dataHolder.dart'; - -extension ExtendedList on List { - T indexOrNull(int index) => index +1 <= length ? this[index] : null; - T firstOrNull() => isEmpty ? null : first; - T lastOrNull() => isEmpty ? null : last; -} - -class FilesProps extends DataHolder { - List folderPath = List.empty(growable: true); - String currentFolderName = 'Home'; - - ListFilesResponse? _listFilesResponse; - ListFilesResponse get listFilesResponse => _listFilesResponse!; - - void runPath(List path) { - folderPath = path; - run(); - } - - @override - List properties() => [_listFilesResponse]; - - @override - void run() { - _listFilesResponse = null; - notifyListeners(); - ListFilesCache( - path: folderPath.isEmpty ? '/' : folderPath.join('/'), - onUpdate: (ListFilesResponse data) => { - _listFilesResponse = data, - notifyListeners(), - } - ); - } - - void enterFolder(String name) { - folderPath.add(name); - currentFolderName = name; - run(); - } - - void popFolder() { - folderPath.removeLast(); - if(folderPath.isEmpty) currentFolderName = 'Home'; - run(); - } -} diff --git a/lib/model/holidays/holidaysProps.dart b/lib/model/holidays/holidaysProps.dart deleted file mode 100644 index a3e153f..0000000 --- a/lib/model/holidays/holidaysProps.dart +++ /dev/null @@ -1,24 +0,0 @@ - -import '../../api/apiResponse.dart'; -import '../../api/holidays/getHolidaysCache.dart'; -import '../../api/holidays/getHolidaysResponse.dart'; -import '../dataHolder.dart'; - - -class HolidaysProps extends DataHolder { - GetHolidaysResponse? _getHolidaysResponse; - GetHolidaysResponse get getHolidaysResponse => _getHolidaysResponse!; - - @override - List properties() => [_getHolidaysResponse]; - - @override - void run() { - GetHolidaysCache( - onUpdate: (GetHolidaysResponse data) => { - _getHolidaysResponse = data, - notifyListeners(), - }, - ); - } -} diff --git a/lib/model/timetable/timetableProps.dart b/lib/model/timetable/timetableProps.dart deleted file mode 100644 index 2d7be91..0000000 --- a/lib/model/timetable/timetableProps.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:intl/intl.dart'; - -import '../../api/apiResponse.dart'; -import '../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart'; -import '../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.dart'; -import '../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart'; -import '../../api/webuntis/queries/getHolidays/getHolidaysCache.dart'; -import '../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; -import '../../api/webuntis/queries/getRooms/getRoomsCache.dart'; -import '../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; -import '../../api/webuntis/queries/getSubjects/getSubjectsCache.dart'; -import '../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; -import '../../api/webuntis/queries/getTimetable/getTimetableCache.dart'; -import '../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; -import '../../api/webuntis/webuntisError.dart'; -import '../accountData.dart'; -import '../dataHolder.dart'; - -class TimetableProps extends DataHolder { - final _queryWeek = DateTime.now().add(const Duration(days: 2)); - - late DateTime startDate = getDate(_queryWeek.subtract(Duration(days: _queryWeek.weekday - 1))); - late DateTime endDate = getDate(_queryWeek.add(Duration(days: DateTime.daysPerWeek - _queryWeek.weekday - 2))); - - GetTimetableResponse? _getTimetableResponse; - GetTimetableResponse get getTimetableResponse => _getTimetableResponse!; - - GetRoomsResponse? _getRoomsResponse; - GetRoomsResponse get getRoomsResponse => _getRoomsResponse!; - - GetSubjectsResponse? _getSubjectsResponse; - GetSubjectsResponse get getSubjectsResponse => _getSubjectsResponse!; - - GetHolidaysResponse? _getHolidaysResponse; - GetHolidaysResponse get getHolidaysResponse => _getHolidaysResponse!; - - GetCustomTimetableEventResponse? _getCustomTimetableEventResponse; - GetCustomTimetableEventResponse get getCustomTimetableEventResponse => _getCustomTimetableEventResponse!; - - WebuntisError? error; - WebuntisError? get getError => error; - bool get hasError => error != null; - - @override - List properties() => [_getTimetableResponse, _getRoomsResponse, _getSubjectsResponse, _getHolidaysResponse, _getCustomTimetableEventResponse]; - - @override - void run({renew}) { - GetTimetableCache( - startdate: int.parse(DateFormat('yyyyMMdd').format(startDate)), - enddate: int.parse(DateFormat('yyyyMMdd').format(endDate)), - onUpdate: (GetTimetableResponse data) => { - _getTimetableResponse = data, - notifyListeners(), - }, - onError: (Exception e) => { - error = e as WebuntisError?, - notifyListeners(), - } - ); - - GetRoomsCache( - onUpdate: (GetRoomsResponse data) => { - _getRoomsResponse = data, - notifyListeners(), - } - ); - - GetSubjectsCache( - onUpdate: (GetSubjectsResponse data) => { - _getSubjectsResponse = data, - notifyListeners(), - } - ); - - GetHolidaysCache( // This broke in the past. Below here is backup simulation for an empty holiday block - onUpdate: (GetHolidaysResponse data) => { - _getHolidaysResponse = data, - notifyListeners(), - } - ); - // _getHolidaysResponse = GetHolidaysResponse.fromJson(jsonDecode(""" - // {"jsonrpc":"2.0","id":"ID","result":[]} - // """)); - - GetCustomTimetableEventCache( - renew: renew, - GetCustomTimetableEventParams( - AccountData().getUserSecret() - ), - onUpdate: (GetCustomTimetableEventResponse data) => { - _getCustomTimetableEventResponse = data, - notifyListeners(), - } - ); - - notifyListeners(); - } - - DateTime getDate(DateTime d) => DateTime(d.year, d.month, d.day); - - bool isWeekend(DateTime queryDate) => queryDate.weekday == DateTime.saturday || queryDate.weekday == DateTime.sunday; - - void updateWeek(DateTime start, DateTime end) { - properties().forEach((element) => element = null); - error = null; - notifyListeners(); - startDate = start; - endDate = end; - try { - run(); - } on WebuntisError catch(e) { - error = e; - notifyListeners(); - } - } - - void resetWeek() { - error = null; - notifyListeners(); - - var queryWeek = DateTime.now().add(const Duration(days: 2)); - - startDate = getDate(queryWeek.subtract(Duration(days: queryWeek.weekday - 1))); - endDate = getDate(queryWeek.add(Duration(days: DateTime.daysPerWeek - queryWeek.weekday))); - - run(); - notifyListeners(); - } -} diff --git a/lib/notification/notificationTasks.dart b/lib/notification/notificationTasks.dart index 7fb59df..e858f08 100644 --- a/lib/notification/notificationTasks.dart +++ b/lib/notification/notificationTasks.dart @@ -1,26 +1,26 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_app_badge/flutter_app_badge.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../main.dart'; -import '../model/chatList/chatListProps.dart'; -import '../model/chatList/chatProps.dart'; import '../state/app/modules/app_modules.dart'; +import '../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../state/app/modules/chatList/bloc/chat_list_bloc.dart'; class NotificationTasks { static void updateBadgeCount(RemoteMessage notification) { - FlutterAppBadge.count(int.parse(notification.data['unreadCount'] ?? 0)); + FlutterAppBadge.count(int.parse(notification.data['unreadCount'] ?? '0')); } static void updateProviders(BuildContext context) { - Provider.of(context, listen: false).run(renew: true); - Provider.of(context, listen: false).run(); + context.read().refresh(); + context.read().refresh(); } static void navigateToTalk(BuildContext context) { - var talkTab = AppModule.getBottomBarModules(context).map((e) => e.module).toList().indexOf(Modules.talk); - if(talkTab == -1) return; + final talkTab = AppModule.getBottomBarModules(context).map((e) => e.module).toList().indexOf(Modules.talk); + if (talkTab == -1) return; Main.bottomNavigator.jumpToTab(talkTab); } } diff --git a/lib/notification/notifyUpdater.dart b/lib/notification/notifyUpdater.dart index dc3dff0..dd9527a 100644 --- a/lib/notification/notifyUpdater.dart +++ b/lib/notification/notifyUpdater.dart @@ -1,34 +1,33 @@ -import 'package:flutter/material.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/material.dart'; import '../api/mhsl/notify/register/notifyRegister.dart'; import '../api/mhsl/notify/register/notifyRegisterParams.dart'; import '../model/accountData.dart'; -import '../storage/base/settingsProvider.dart'; +import '../state/app/modules/settings/bloc/settings_cubit.dart'; import '../widget/confirmDialog.dart'; class NotifyUpdater { - static ConfirmDialog enableAfterDisclaimer(SettingsProvider settings) => ConfirmDialog( - title: 'Warnung', - icon: Icons.warning_amber, - content: '' - 'Die Push-Benachrichtigungen werden durch mhsl.eu versendet.\n\n' - 'Durch das aktivieren dieser Funktion wird dein Nutzername, dein Password und eine Geräte-ID von mhsl dauerhaft gespeichert und verarbeitet.\n\n' - 'Für mehr Informationen drücke lange auf die Einstellungsoption!', - confirmButton: 'Aktivieren', - onConfirm: () { - FirebaseMessaging.instance.requestPermission( - provisional: false - ); - settings.val(write: true).notificationSettings.enabled = true; - NotifyUpdater.registerToServer(); - }, - ); + static ConfirmDialog enableAfterDisclaimer(SettingsCubit settings) => ConfirmDialog( + title: 'Warnung', + icon: Icons.warning_amber, + content: '' + 'Die Push-Benachrichtigungen werden durch mhsl.eu versendet.\n\n' + 'Durch das aktivieren dieser Funktion wird dein Nutzername, dein Password und eine Geräte-ID von mhsl dauerhaft gespeichert und verarbeitet.\n\n' + 'Für mehr Informationen drücke lange auf die Einstellungsoption!', + confirmButton: 'Aktivieren', + onConfirm: () { + FirebaseMessaging.instance.requestPermission(provisional: false); + settings.val(write: true).notificationSettings.enabled = true; + NotifyUpdater.registerToServer(); + }, + ); static Future registerToServer() async { - var fcmToken = await FirebaseMessaging.instance.getToken(); - - if(fcmToken == null) throw Exception('Failed to register push notification because there is no FBC token!'); + final fcmToken = await FirebaseMessaging.instance.getToken(); + if (fcmToken == null) { + throw Exception('Failed to register push notification because there is no FBC token!'); + } NotifyRegister( NotifyRegisterParams( diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart b/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart index e1613ba..4b5b557 100644 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart +++ b/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart @@ -25,8 +25,9 @@ class LoadableStateConsumer().state; - if(!loadableState.isLoading && onLoad != null) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) => onLoad!(loadableState.data!)); + final loadedData = loadableState.data; + if(!loadableState.isLoading && onLoad != null && loadedData is TState) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) => onLoad!(loadedData)); } var childWidget = ConditionalWrapper( @@ -47,8 +48,8 @@ class LoadableStateConsumer, TState> extends St @override Widget build(BuildContext context) => BlocProvider( create: (context) { - var bloc = create(context); - this.onInitialisation != null ? this.onInitialisation!(context, bloc) : null; + final bloc = create(context); + onInitialisation?.call(context, bloc); return bloc; }, child: Builder( diff --git a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart index f384924..67cf540 100644 --- a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart +++ b/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart @@ -103,7 +103,8 @@ abstract class LoadableHydratedBloc< Map? toJson(LoadableState state) { Map? data; try { - data = state.data == null ? null : toStorage(state.data!); + final stateData = state.data; + data = stateData is TState ? toStorage(stateData) : null; } catch(e) { log('Failed to save state ${TState.toString()}: ${e.toString()}'); } diff --git a/lib/state/app/modules/account/bloc/account_bloc.dart b/lib/state/app/modules/account/bloc/account_bloc.dart new file mode 100644 index 0000000..59514dd --- /dev/null +++ b/lib/state/app/modules/account/bloc/account_bloc.dart @@ -0,0 +1,12 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'account_event.dart'; +import 'account_state.dart'; + +class AccountBloc extends Bloc { + AccountBloc() : super(const AccountState()) { + on((event, emit) => emit(state.copyWith(status: event.status))); + } + + void setStatus(AccountStatus status) => add(AccountStatusChanged(status)); +} diff --git a/lib/state/app/modules/account/bloc/account_event.dart b/lib/state/app/modules/account/bloc/account_event.dart new file mode 100644 index 0000000..a3a0f8a --- /dev/null +++ b/lib/state/app/modules/account/bloc/account_event.dart @@ -0,0 +1,10 @@ +import 'account_state.dart'; + +sealed class AccountEvent { + const AccountEvent(); +} + +class AccountStatusChanged extends AccountEvent { + final AccountStatus status; + const AccountStatusChanged(this.status); +} diff --git a/lib/state/app/modules/account/bloc/account_state.dart b/lib/state/app/modules/account/bloc/account_state.dart new file mode 100644 index 0000000..d0ab496 --- /dev/null +++ b/lib/state/app/modules/account/bloc/account_state.dart @@ -0,0 +1,8 @@ +enum AccountStatus { undefined, loggedIn, loggedOut } + +class AccountState { + final AccountStatus status; + const AccountState({this.status = AccountStatus.undefined}); + + AccountState copyWith({AccountStatus? status}) => AccountState(status: status ?? this.status); +} diff --git a/lib/state/app/modules/app_modules.dart b/lib/state/app/modules/app_modules.dart index 744feef..10b6b36 100644 --- a/lib/state/app/modules/app_modules.dart +++ b/lib/state/app/modules/app_modules.dart @@ -1,16 +1,18 @@ import 'package:flutter/material.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; -import '../../../model/breakers/Breaker.dart'; -import '../../../model/chatList/chatListProps.dart'; -import '../../../storage/base/settingsProvider.dart'; +import '../../../widget/breaker/breaker.dart'; import '../../../view/pages/files/files.dart'; import '../../../view/pages/more/roomplan/roomplan.dart'; import '../../../view/pages/talk/chatList.dart'; import '../../../view/pages/timetable/timetable.dart'; import '../../../widget/centeredLeading.dart'; +import 'chatList/bloc/chat_list_bloc.dart'; +import 'chatList/bloc/chat_list_state.dart'; +import 'settings/bloc/settings_cubit.dart'; +import '../infrastructure/loadableState/loadable_state.dart'; import 'gradeAverages/view/grade_averages_view.dart'; import 'holidays/view/holidays_view.dart'; import 'marianumMessage/view/marianum_message_list_view.dart'; @@ -27,7 +29,7 @@ class AppModule { AppModule(this.module, {required this.name, required this.icon, this.breakerArea = BreakerArea.global, required this.create}); static Map modules(BuildContext context, { showFiltered = false }) { - var settings = Provider.of(context, listen: false); + var settings = context.read(); var available = { Modules.timetable: AppModule( Modules.timetable, @@ -39,10 +41,11 @@ class AppModule { Modules.talk: AppModule( Modules.talk, name: 'Talk', - icon: () => Consumer( - builder: (context, value, child) { - if(value.primaryLoading()) return Icon(Icons.chat); - var messages = value.getRoomsResponse.data.map((e) => e.unreadMessages).reduce((a, b) => a+b); + icon: () => BlocBuilder>( + builder: (context, state) { + final rooms = state.data?.rooms; + if (rooms == null || rooms.data.isEmpty) return const Icon(Icons.chat); + final messages = rooms.data.map((e) => e.unreadMessages).reduce((a, b) => a + b); return badges.Badge( showBadge: messages > 0, position: badges.BadgePosition.topEnd(top: -3, end: -3), @@ -53,7 +56,7 @@ class AppModule { elevation: 1, ), badgeContent: Text('$messages', style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)), - child: Icon(Icons.chat), + child: const Icon(Icons.chat), ); }, ), diff --git a/lib/state/app/modules/breaker/bloc/breaker_bloc.dart b/lib/state/app/modules/breaker/bloc/breaker_bloc.dart new file mode 100644 index 0000000..0381513 --- /dev/null +++ b/lib/state/app/modules/breaker/bloc/breaker_bloc.dart @@ -0,0 +1,51 @@ +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../../../../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../repository/breaker_repository.dart'; +import 'breaker_event.dart'; +import 'breaker_state.dart'; + +class BreakerBloc extends LoadableHydratedBloc { + PackageInfo? _packageInfo; + + @override + BreakerRepository repository() => BreakerRepository(); + + @override + BreakerState fromNothing() => const BreakerState(); + + @override + BreakerState fromStorage(Map json) => BreakerState.fromJson(json); + + @override + Map? toStorage(BreakerState state) => state.toJson(); + + @override + Future gatherData() async { + _packageInfo ??= await PackageInfo.fromPlatform(); + final response = await repo.data.getBreakers(); + add(DataGathered((s) => s.copyWith(response: response))); + } + + void refresh() => fetch(); + + String? isBlocked(BreakerArea? type) { + if (kDebugMode) return null; + final response = innerState?.response; + if (response == null || _packageInfo == null) return null; + + if (response.global.areas.contains(type)) return response.global.message; + + final selfBuild = int.parse(_packageInfo!.buildNumber); + for (final entry in response.regional.entries) { + final affectedBuild = int.parse(entry.key.split('b')[1]); + if (affectedBuild >= selfBuild && entry.value.areas.contains(type)) { + return entry.value.message; + } + } + return null; + } +} diff --git a/lib/state/app/modules/breaker/bloc/breaker_event.dart b/lib/state/app/modules/breaker/bloc/breaker_event.dart new file mode 100644 index 0000000..5c9ed7e --- /dev/null +++ b/lib/state/app/modules/breaker/bloc/breaker_event.dart @@ -0,0 +1,4 @@ +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import 'breaker_state.dart'; + +sealed class BreakerEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/breaker/bloc/breaker_state.dart b/lib/state/app/modules/breaker/bloc/breaker_state.dart new file mode 100644 index 0000000..140cc45 --- /dev/null +++ b/lib/state/app/modules/breaker/bloc/breaker_state.dart @@ -0,0 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; + +part 'breaker_state.freezed.dart'; +part 'breaker_state.g.dart'; + +@freezed +abstract class BreakerState with _$BreakerState { + const factory BreakerState({ + GetBreakersResponse? response, + }) = _BreakerState; + + factory BreakerState.fromJson(Map json) => _$BreakerStateFromJson(json); +} diff --git a/lib/state/app/modules/breaker/bloc/breaker_state.freezed.dart b/lib/state/app/modules/breaker/bloc/breaker_state.freezed.dart new file mode 100644 index 0000000..7af9941 --- /dev/null +++ b/lib/state/app/modules/breaker/bloc/breaker_state.freezed.dart @@ -0,0 +1,277 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'breaker_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$BreakerState { + + GetBreakersResponse? get response; +/// Create a copy of BreakerState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BreakerStateCopyWith get copyWith => _$BreakerStateCopyWithImpl(this as BreakerState, _$identity); + + /// Serializes this BreakerState to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BreakerState&&(identical(other.response, response) || other.response == response)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,response); + +@override +String toString() { + return 'BreakerState(response: $response)'; +} + + +} + +/// @nodoc +abstract mixin class $BreakerStateCopyWith<$Res> { + factory $BreakerStateCopyWith(BreakerState value, $Res Function(BreakerState) _then) = _$BreakerStateCopyWithImpl; +@useResult +$Res call({ + GetBreakersResponse? response +}); + + + + +} +/// @nodoc +class _$BreakerStateCopyWithImpl<$Res> + implements $BreakerStateCopyWith<$Res> { + _$BreakerStateCopyWithImpl(this._self, this._then); + + final BreakerState _self; + final $Res Function(BreakerState) _then; + +/// Create a copy of BreakerState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? response = freezed,}) { + return _then(_self.copyWith( +response: freezed == response ? _self.response : response // ignore: cast_nullable_to_non_nullable +as GetBreakersResponse?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [BreakerState]. +extension BreakerStatePatterns on BreakerState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _BreakerState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _BreakerState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _BreakerState value) $default,){ +final _that = this; +switch (_that) { +case _BreakerState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _BreakerState value)? $default,){ +final _that = this; +switch (_that) { +case _BreakerState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( GetBreakersResponse? response)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _BreakerState() when $default != null: +return $default(_that.response);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( GetBreakersResponse? response) $default,) {final _that = this; +switch (_that) { +case _BreakerState(): +return $default(_that.response);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( GetBreakersResponse? response)? $default,) {final _that = this; +switch (_that) { +case _BreakerState() when $default != null: +return $default(_that.response);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _BreakerState implements BreakerState { + const _BreakerState({this.response}); + factory _BreakerState.fromJson(Map json) => _$BreakerStateFromJson(json); + +@override final GetBreakersResponse? response; + +/// Create a copy of BreakerState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$BreakerStateCopyWith<_BreakerState> get copyWith => __$BreakerStateCopyWithImpl<_BreakerState>(this, _$identity); + +@override +Map toJson() { + return _$BreakerStateToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _BreakerState&&(identical(other.response, response) || other.response == response)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,response); + +@override +String toString() { + return 'BreakerState(response: $response)'; +} + + +} + +/// @nodoc +abstract mixin class _$BreakerStateCopyWith<$Res> implements $BreakerStateCopyWith<$Res> { + factory _$BreakerStateCopyWith(_BreakerState value, $Res Function(_BreakerState) _then) = __$BreakerStateCopyWithImpl; +@override @useResult +$Res call({ + GetBreakersResponse? response +}); + + + + +} +/// @nodoc +class __$BreakerStateCopyWithImpl<$Res> + implements _$BreakerStateCopyWith<$Res> { + __$BreakerStateCopyWithImpl(this._self, this._then); + + final _BreakerState _self; + final $Res Function(_BreakerState) _then; + +/// Create a copy of BreakerState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? response = freezed,}) { + return _then(_BreakerState( +response: freezed == response ? _self.response : response // ignore: cast_nullable_to_non_nullable +as GetBreakersResponse?, + )); +} + + +} + +// dart format on diff --git a/lib/state/app/modules/breaker/bloc/breaker_state.g.dart b/lib/state/app/modules/breaker/bloc/breaker_state.g.dart new file mode 100644 index 0000000..d47f3e3 --- /dev/null +++ b/lib/state/app/modules/breaker/bloc/breaker_state.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'breaker_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_BreakerState _$BreakerStateFromJson(Map json) => + _BreakerState( + response: json['response'] == null + ? null + : GetBreakersResponse.fromJson( + json['response'] as Map, + ), + ); + +Map _$BreakerStateToJson(_BreakerState instance) => + {'response': instance.response}; diff --git a/lib/state/app/modules/breaker/dataProvider/breaker_data_provider.dart b/lib/state/app/modules/breaker/dataProvider/breaker_data_provider.dart new file mode 100644 index 0000000..5623e71 --- /dev/null +++ b/lib/state/app/modules/breaker/dataProvider/breaker_data_provider.dart @@ -0,0 +1,14 @@ +import 'dart:async'; + +import '../../../../../api/mhsl/breaker/getBreakers/getBreakersCache.dart'; +import '../../../../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; + +class BreakerDataProvider { + Future getBreakers() { + final completer = Completer(); + GetBreakersCache(onUpdate: (data) { + if (!completer.isCompleted) completer.complete(data); + }); + return completer.future; + } +} diff --git a/lib/state/app/modules/breaker/repository/breaker_repository.dart b/lib/state/app/modules/breaker/repository/breaker_repository.dart new file mode 100644 index 0000000..42bb070 --- /dev/null +++ b/lib/state/app/modules/breaker/repository/breaker_repository.dart @@ -0,0 +1,11 @@ +import '../../../infrastructure/repository/repository.dart'; +import '../bloc/breaker_state.dart'; +import '../dataProvider/breaker_data_provider.dart'; + +class BreakerRepository extends Repository { + final BreakerDataProvider _provider; + + BreakerRepository([BreakerDataProvider? provider]) : _provider = provider ?? BreakerDataProvider(); + + BreakerDataProvider get data => _provider; +} diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart new file mode 100644 index 0000000..e89d7a9 --- /dev/null +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -0,0 +1,59 @@ +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../repository/chat_repository.dart'; +import 'chat_event.dart'; +import 'chat_state.dart'; + +class ChatBloc extends LoadableHydratedBloc { + DateTime _lastTokenSet = DateTime.fromMillisecondsSinceEpoch(0); + + @override + ChatRepository repository() => ChatRepository(); + + @override + ChatState fromNothing() => const ChatState(); + + @override + ChatState fromStorage(Map json) => ChatState.fromJson(json); + + @override + Map? toStorage(ChatState state) => state.toJson(); + + @override + Future gatherData() async { + final token = innerState?.currentToken ?? ''; + if (token.isEmpty) return; + _loadChat(token); + } + + void setToken(String token) { + if (token == (innerState?.currentToken ?? '')) { + refresh(); + return; + } + add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null))); + _loadChat(token); + } + + void setReferenceMessageId(int? messageId) { + add(Emit((s) => s.copyWith(referenceMessageId: messageId))); + } + + void refresh() { + final token = innerState?.currentToken ?? ''; + if (token.isNotEmpty) _loadChat(token); + } + + void _loadChat(String token) { + final requestStart = DateTime.now(); + _lastTokenSet = requestStart; + repo.data.getChat( + token: token, + onUpdate: (data) { + if (_lastTokenSet.isAfter(requestStart)) return; + if ((innerState?.currentToken ?? '') != token) return; + add(DataGathered((s) => s.copyWith(chatResponse: data))); + }, + ); + } +} diff --git a/lib/state/app/modules/chat/bloc/chat_event.dart b/lib/state/app/modules/chat/bloc/chat_event.dart new file mode 100644 index 0000000..460817d --- /dev/null +++ b/lib/state/app/modules/chat/bloc/chat_event.dart @@ -0,0 +1,4 @@ +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import 'chat_state.dart'; + +sealed class ChatEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/chat/bloc/chat_state.dart b/lib/state/app/modules/chat/bloc/chat_state.dart new file mode 100644 index 0000000..221b84d --- /dev/null +++ b/lib/state/app/modules/chat/bloc/chat_state.dart @@ -0,0 +1,17 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; + +part 'chat_state.freezed.dart'; +part 'chat_state.g.dart'; + +@freezed +abstract class ChatState with _$ChatState { + const factory ChatState({ + @Default('') String currentToken, + GetChatResponse? chatResponse, + int? referenceMessageId, + }) = _ChatState; + + factory ChatState.fromJson(Map json) => _$ChatStateFromJson(json); +} diff --git a/lib/state/app/modules/chat/bloc/chat_state.freezed.dart b/lib/state/app/modules/chat/bloc/chat_state.freezed.dart new file mode 100644 index 0000000..0467823 --- /dev/null +++ b/lib/state/app/modules/chat/bloc/chat_state.freezed.dart @@ -0,0 +1,283 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'chat_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$ChatState { + + String get currentToken; GetChatResponse? get chatResponse; int? get referenceMessageId; +/// Create a copy of ChatState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ChatStateCopyWith get copyWith => _$ChatStateCopyWithImpl(this as ChatState, _$identity); + + /// Serializes this ChatState to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId); + +@override +String toString() { + return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId)'; +} + + +} + +/// @nodoc +abstract mixin class $ChatStateCopyWith<$Res> { + factory $ChatStateCopyWith(ChatState value, $Res Function(ChatState) _then) = _$ChatStateCopyWithImpl; +@useResult +$Res call({ + String currentToken, GetChatResponse? chatResponse, int? referenceMessageId +}); + + + + +} +/// @nodoc +class _$ChatStateCopyWithImpl<$Res> + implements $ChatStateCopyWith<$Res> { + _$ChatStateCopyWithImpl(this._self, this._then); + + final ChatState _self; + final $Res Function(ChatState) _then; + +/// Create a copy of ChatState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,}) { + return _then(_self.copyWith( +currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable +as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable +as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ChatState]. +extension ChatStatePatterns on ChatState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ChatState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ChatState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ChatState value) $default,){ +final _that = this; +switch (_that) { +case _ChatState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ChatState value)? $default,){ +final _that = this; +switch (_that) { +case _ChatState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ChatState() when $default != null: +return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId) $default,) {final _that = this; +switch (_that) { +case _ChatState(): +return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId)? $default,) {final _that = this; +switch (_that) { +case _ChatState() when $default != null: +return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ChatState implements ChatState { + const _ChatState({this.currentToken = '', this.chatResponse, this.referenceMessageId}); + factory _ChatState.fromJson(Map json) => _$ChatStateFromJson(json); + +@override@JsonKey() final String currentToken; +@override final GetChatResponse? chatResponse; +@override final int? referenceMessageId; + +/// Create a copy of ChatState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ChatStateCopyWith<_ChatState> get copyWith => __$ChatStateCopyWithImpl<_ChatState>(this, _$identity); + +@override +Map toJson() { + return _$ChatStateToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId); + +@override +String toString() { + return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId)'; +} + + +} + +/// @nodoc +abstract mixin class _$ChatStateCopyWith<$Res> implements $ChatStateCopyWith<$Res> { + factory _$ChatStateCopyWith(_ChatState value, $Res Function(_ChatState) _then) = __$ChatStateCopyWithImpl; +@override @useResult +$Res call({ + String currentToken, GetChatResponse? chatResponse, int? referenceMessageId +}); + + + + +} +/// @nodoc +class __$ChatStateCopyWithImpl<$Res> + implements _$ChatStateCopyWith<$Res> { + __$ChatStateCopyWithImpl(this._self, this._then); + + final _ChatState _self; + final $Res Function(_ChatState) _then; + +/// Create a copy of ChatState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,}) { + return _then(_ChatState( +currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable +as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable +as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + + +} + +// dart format on diff --git a/lib/state/app/modules/chat/bloc/chat_state.g.dart b/lib/state/app/modules/chat/bloc/chat_state.g.dart new file mode 100644 index 0000000..b685b00 --- /dev/null +++ b/lib/state/app/modules/chat/bloc/chat_state.g.dart @@ -0,0 +1,22 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_ChatState _$ChatStateFromJson(Map json) => _ChatState( + currentToken: json['currentToken'] as String? ?? '', + chatResponse: json['chatResponse'] == null + ? null + : GetChatResponse.fromJson(json['chatResponse'] as Map), + referenceMessageId: (json['referenceMessageId'] as num?)?.toInt(), +); + +Map _$ChatStateToJson(_ChatState instance) => + { + 'currentToken': instance.currentToken, + 'chatResponse': instance.chatResponse, + 'referenceMessageId': instance.referenceMessageId, + }; diff --git a/lib/state/app/modules/chat/dataProvider/chat_data_provider.dart b/lib/state/app/modules/chat/dataProvider/chat_data_provider.dart new file mode 100644 index 0000000..dab8899 --- /dev/null +++ b/lib/state/app/modules/chat/dataProvider/chat_data_provider.dart @@ -0,0 +1,11 @@ +import '../../../../../api/marianumcloud/talk/chat/getChatCache.dart'; +import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; + +class ChatDataProvider { + void getChat({ + required String token, + required void Function(GetChatResponse data) onUpdate, + }) { + GetChatCache(chatToken: token, onUpdate: onUpdate); + } +} diff --git a/lib/state/app/modules/chat/repository/chat_repository.dart b/lib/state/app/modules/chat/repository/chat_repository.dart new file mode 100644 index 0000000..54e4356 --- /dev/null +++ b/lib/state/app/modules/chat/repository/chat_repository.dart @@ -0,0 +1,11 @@ +import '../../../infrastructure/repository/repository.dart'; +import '../bloc/chat_state.dart'; +import '../dataProvider/chat_data_provider.dart'; + +class ChatRepository extends Repository { + final ChatDataProvider _provider; + + ChatRepository([ChatDataProvider? provider]) : _provider = provider ?? ChatDataProvider(); + + ChatDataProvider get data => _provider; +} diff --git a/lib/state/app/modules/chatList/bloc/chat_list_bloc.dart b/lib/state/app/modules/chatList/bloc/chat_list_bloc.dart new file mode 100644 index 0000000..2080fae --- /dev/null +++ b/lib/state/app/modules/chatList/bloc/chat_list_bloc.dart @@ -0,0 +1,46 @@ +import 'package:flutter_app_badge/flutter_app_badge.dart'; + +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../repository/chat_list_repository.dart'; +import 'chat_list_event.dart'; +import 'chat_list_state.dart'; + +class ChatListBloc extends LoadableHydratedBloc { + @override + ChatListRepository repository() => ChatListRepository(); + + @override + ChatListState fromNothing() => const ChatListState(); + + @override + ChatListState fromStorage(Map json) => ChatListState.fromJson(json); + + @override + Map? toStorage(ChatListState state) => state.toJson(); + + @override + Future gatherData() async { + final rooms = await repo.data.getRooms(); + add(DataGathered((s) => s.copyWith(rooms: rooms))); + _updateAppBadge(rooms); + } + + Future refresh({bool renew = true}) async { + final rooms = await repo.data.getRooms(renew: renew); + add(DataGathered((s) => s.copyWith(rooms: rooms))); + _updateAppBadge(rooms); + } + + Future createDirectChat(String invite) async { + await repo.data.createDirectRoom(invite); + await refresh(); + } + + void _updateAppBadge(dynamic rooms) { + try { + final unread = rooms.data.map((e) => e.unreadMessages).fold(0, (a, b) => a + b as int); + FlutterAppBadge.count(unread); + } catch (_) {} + } +} diff --git a/lib/state/app/modules/chatList/bloc/chat_list_event.dart b/lib/state/app/modules/chatList/bloc/chat_list_event.dart new file mode 100644 index 0000000..614898d --- /dev/null +++ b/lib/state/app/modules/chatList/bloc/chat_list_event.dart @@ -0,0 +1,4 @@ +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import 'chat_list_state.dart'; + +sealed class ChatListEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/chatList/bloc/chat_list_state.dart b/lib/state/app/modules/chatList/bloc/chat_list_state.dart new file mode 100644 index 0000000..12ad303 --- /dev/null +++ b/lib/state/app/modules/chatList/bloc/chat_list_state.dart @@ -0,0 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; + +part 'chat_list_state.freezed.dart'; +part 'chat_list_state.g.dart'; + +@freezed +abstract class ChatListState with _$ChatListState { + const factory ChatListState({ + GetRoomResponse? rooms, + }) = _ChatListState; + + factory ChatListState.fromJson(Map json) => _$ChatListStateFromJson(json); +} diff --git a/lib/state/app/modules/chatList/bloc/chat_list_state.freezed.dart b/lib/state/app/modules/chatList/bloc/chat_list_state.freezed.dart new file mode 100644 index 0000000..ff2714b --- /dev/null +++ b/lib/state/app/modules/chatList/bloc/chat_list_state.freezed.dart @@ -0,0 +1,277 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'chat_list_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$ChatListState { + + GetRoomResponse? get rooms; +/// Create a copy of ChatListState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ChatListStateCopyWith get copyWith => _$ChatListStateCopyWithImpl(this as ChatListState, _$identity); + + /// Serializes this ChatListState to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatListState&&(identical(other.rooms, rooms) || other.rooms == rooms)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,rooms); + +@override +String toString() { + return 'ChatListState(rooms: $rooms)'; +} + + +} + +/// @nodoc +abstract mixin class $ChatListStateCopyWith<$Res> { + factory $ChatListStateCopyWith(ChatListState value, $Res Function(ChatListState) _then) = _$ChatListStateCopyWithImpl; +@useResult +$Res call({ + GetRoomResponse? rooms +}); + + + + +} +/// @nodoc +class _$ChatListStateCopyWithImpl<$Res> + implements $ChatListStateCopyWith<$Res> { + _$ChatListStateCopyWithImpl(this._self, this._then); + + final ChatListState _self; + final $Res Function(ChatListState) _then; + +/// Create a copy of ChatListState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? rooms = freezed,}) { + return _then(_self.copyWith( +rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable +as GetRoomResponse?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ChatListState]. +extension ChatListStatePatterns on ChatListState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ChatListState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ChatListState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ChatListState value) $default,){ +final _that = this; +switch (_that) { +case _ChatListState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ChatListState value)? $default,){ +final _that = this; +switch (_that) { +case _ChatListState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( GetRoomResponse? rooms)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ChatListState() when $default != null: +return $default(_that.rooms);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( GetRoomResponse? rooms) $default,) {final _that = this; +switch (_that) { +case _ChatListState(): +return $default(_that.rooms);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( GetRoomResponse? rooms)? $default,) {final _that = this; +switch (_that) { +case _ChatListState() when $default != null: +return $default(_that.rooms);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ChatListState implements ChatListState { + const _ChatListState({this.rooms}); + factory _ChatListState.fromJson(Map json) => _$ChatListStateFromJson(json); + +@override final GetRoomResponse? rooms; + +/// Create a copy of ChatListState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ChatListStateCopyWith<_ChatListState> get copyWith => __$ChatListStateCopyWithImpl<_ChatListState>(this, _$identity); + +@override +Map toJson() { + return _$ChatListStateToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatListState&&(identical(other.rooms, rooms) || other.rooms == rooms)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,rooms); + +@override +String toString() { + return 'ChatListState(rooms: $rooms)'; +} + + +} + +/// @nodoc +abstract mixin class _$ChatListStateCopyWith<$Res> implements $ChatListStateCopyWith<$Res> { + factory _$ChatListStateCopyWith(_ChatListState value, $Res Function(_ChatListState) _then) = __$ChatListStateCopyWithImpl; +@override @useResult +$Res call({ + GetRoomResponse? rooms +}); + + + + +} +/// @nodoc +class __$ChatListStateCopyWithImpl<$Res> + implements _$ChatListStateCopyWith<$Res> { + __$ChatListStateCopyWithImpl(this._self, this._then); + + final _ChatListState _self; + final $Res Function(_ChatListState) _then; + +/// Create a copy of ChatListState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? rooms = freezed,}) { + return _then(_ChatListState( +rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable +as GetRoomResponse?, + )); +} + + +} + +// dart format on diff --git a/lib/state/app/modules/chatList/bloc/chat_list_state.g.dart b/lib/state/app/modules/chatList/bloc/chat_list_state.g.dart new file mode 100644 index 0000000..1a28f8c --- /dev/null +++ b/lib/state/app/modules/chatList/bloc/chat_list_state.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_list_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_ChatListState _$ChatListStateFromJson(Map json) => + _ChatListState( + rooms: json['rooms'] == null + ? null + : GetRoomResponse.fromJson(json['rooms'] as Map), + ); + +Map _$ChatListStateToJson(_ChatListState instance) => + {'rooms': instance.rooms}; diff --git a/lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart b/lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart new file mode 100644 index 0000000..d7bf80e --- /dev/null +++ b/lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart @@ -0,0 +1,22 @@ +import 'dart:async'; + +import '../../../../../api/marianumcloud/talk/room/getRoomCache.dart'; +import '../../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../../../../../api/marianumcloud/talk/createRoom/createRoom.dart'; +import '../../../../../api/marianumcloud/talk/createRoom/createRoomParams.dart'; + +class ChatListDataProvider { + Future getRooms({bool renew = false}) { + final completer = Completer(); + GetRoomCache( + renew: renew, + onUpdate: (data) { + if (!completer.isCompleted) completer.complete(data); + }, + ); + return completer.future; + } + + Future createDirectRoom(String invite) => + CreateRoom(CreateRoomParams(roomType: 1, invite: invite)).run(); +} diff --git a/lib/state/app/modules/chatList/repository/chat_list_repository.dart b/lib/state/app/modules/chatList/repository/chat_list_repository.dart new file mode 100644 index 0000000..5a10ce6 --- /dev/null +++ b/lib/state/app/modules/chatList/repository/chat_list_repository.dart @@ -0,0 +1,11 @@ +import '../../../infrastructure/repository/repository.dart'; +import '../bloc/chat_list_state.dart'; +import '../dataProvider/chat_list_data_provider.dart'; + +class ChatListRepository extends Repository { + final ChatListDataProvider _provider; + + ChatListRepository([ChatListDataProvider? provider]) : _provider = provider ?? ChatListDataProvider(); + + ChatListDataProvider get data => _provider; +} diff --git a/lib/state/app/modules/files/bloc/files_bloc.dart b/lib/state/app/modules/files/bloc/files_bloc.dart new file mode 100644 index 0000000..98eff6e --- /dev/null +++ b/lib/state/app/modules/files/bloc/files_bloc.dart @@ -0,0 +1,52 @@ +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../repository/files_repository.dart'; +import 'files_event.dart'; +import 'files_state.dart'; + +class FilesBloc extends LoadableHydratedBloc { + final List initialPath; + + FilesBloc({this.initialPath = const []}); + + @override + FilesRepository repository() => FilesRepository(); + + @override + FilesState fromNothing() => FilesState(currentPath: initialPath); + + @override + FilesState fromStorage(Map json) => FilesState.fromJson(json); + + @override + Map? toStorage(FilesState state) => null; + + @override + Future gatherData() async { + final path = innerState?.currentPath ?? initialPath; + await _query(path); + } + + Future refresh() async { + final path = innerState?.currentPath ?? initialPath; + await _query(path); + } + + Future setPath(List path) async { + add(Emit((s) => s.copyWith(currentPath: path, listing: null))); + await _query(path); + } + + Future createFolder(String name) async { + final path = innerState?.currentPath ?? initialPath; + await repo.data.createFolder('${path.join('/')}/$name'); + await refresh(); + } + + Future _query(List path) async { + final pathString = path.isEmpty ? '/' : path.join('/'); + final listing = await repo.data.listFiles(pathString); + listing.files.removeWhere((file) => file.name.isEmpty || file.name == path.lastOrNull); + add(DataGathered((s) => s.copyWith(listing: listing))); + } +} diff --git a/lib/state/app/modules/files/bloc/files_event.dart b/lib/state/app/modules/files/bloc/files_event.dart new file mode 100644 index 0000000..5b6a3a1 --- /dev/null +++ b/lib/state/app/modules/files/bloc/files_event.dart @@ -0,0 +1,4 @@ +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import 'files_state.dart'; + +sealed class FilesEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/files/bloc/files_state.dart b/lib/state/app/modules/files/bloc/files_state.dart new file mode 100644 index 0000000..d13b079 --- /dev/null +++ b/lib/state/app/modules/files/bloc/files_state.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart'; + +part 'files_state.freezed.dart'; +part 'files_state.g.dart'; + +@freezed +abstract class FilesState with _$FilesState { + const factory FilesState({ + @Default([]) List currentPath, + ListFilesResponse? listing, + }) = _FilesState; + + factory FilesState.fromJson(Map json) => _$FilesStateFromJson(json); +} diff --git a/lib/state/app/modules/files/bloc/files_state.freezed.dart b/lib/state/app/modules/files/bloc/files_state.freezed.dart new file mode 100644 index 0000000..03e46f9 --- /dev/null +++ b/lib/state/app/modules/files/bloc/files_state.freezed.dart @@ -0,0 +1,286 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'files_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$FilesState { + + List get currentPath; ListFilesResponse? get listing; +/// Create a copy of FilesState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$FilesStateCopyWith get copyWith => _$FilesStateCopyWithImpl(this as FilesState, _$identity); + + /// Serializes this FilesState to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is FilesState&&const DeepCollectionEquality().equals(other.currentPath, currentPath)&&(identical(other.listing, listing) || other.listing == listing)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(currentPath),listing); + +@override +String toString() { + return 'FilesState(currentPath: $currentPath, listing: $listing)'; +} + + +} + +/// @nodoc +abstract mixin class $FilesStateCopyWith<$Res> { + factory $FilesStateCopyWith(FilesState value, $Res Function(FilesState) _then) = _$FilesStateCopyWithImpl; +@useResult +$Res call({ + List currentPath, ListFilesResponse? listing +}); + + + + +} +/// @nodoc +class _$FilesStateCopyWithImpl<$Res> + implements $FilesStateCopyWith<$Res> { + _$FilesStateCopyWithImpl(this._self, this._then); + + final FilesState _self; + final $Res Function(FilesState) _then; + +/// Create a copy of FilesState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? currentPath = null,Object? listing = freezed,}) { + return _then(_self.copyWith( +currentPath: null == currentPath ? _self.currentPath : currentPath // ignore: cast_nullable_to_non_nullable +as List,listing: freezed == listing ? _self.listing : listing // ignore: cast_nullable_to_non_nullable +as ListFilesResponse?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [FilesState]. +extension FilesStatePatterns on FilesState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _FilesState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _FilesState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _FilesState value) $default,){ +final _that = this; +switch (_that) { +case _FilesState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _FilesState value)? $default,){ +final _that = this; +switch (_that) { +case _FilesState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List currentPath, ListFilesResponse? listing)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _FilesState() when $default != null: +return $default(_that.currentPath,_that.listing);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List currentPath, ListFilesResponse? listing) $default,) {final _that = this; +switch (_that) { +case _FilesState(): +return $default(_that.currentPath,_that.listing);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List currentPath, ListFilesResponse? listing)? $default,) {final _that = this; +switch (_that) { +case _FilesState() when $default != null: +return $default(_that.currentPath,_that.listing);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _FilesState implements FilesState { + const _FilesState({final List currentPath = const [], this.listing}): _currentPath = currentPath; + factory _FilesState.fromJson(Map json) => _$FilesStateFromJson(json); + + final List _currentPath; +@override@JsonKey() List get currentPath { + if (_currentPath is EqualUnmodifiableListView) return _currentPath; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_currentPath); +} + +@override final ListFilesResponse? listing; + +/// Create a copy of FilesState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$FilesStateCopyWith<_FilesState> get copyWith => __$FilesStateCopyWithImpl<_FilesState>(this, _$identity); + +@override +Map toJson() { + return _$FilesStateToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _FilesState&&const DeepCollectionEquality().equals(other._currentPath, _currentPath)&&(identical(other.listing, listing) || other.listing == listing)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_currentPath),listing); + +@override +String toString() { + return 'FilesState(currentPath: $currentPath, listing: $listing)'; +} + + +} + +/// @nodoc +abstract mixin class _$FilesStateCopyWith<$Res> implements $FilesStateCopyWith<$Res> { + factory _$FilesStateCopyWith(_FilesState value, $Res Function(_FilesState) _then) = __$FilesStateCopyWithImpl; +@override @useResult +$Res call({ + List currentPath, ListFilesResponse? listing +}); + + + + +} +/// @nodoc +class __$FilesStateCopyWithImpl<$Res> + implements _$FilesStateCopyWith<$Res> { + __$FilesStateCopyWithImpl(this._self, this._then); + + final _FilesState _self; + final $Res Function(_FilesState) _then; + +/// Create a copy of FilesState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? currentPath = null,Object? listing = freezed,}) { + return _then(_FilesState( +currentPath: null == currentPath ? _self._currentPath : currentPath // ignore: cast_nullable_to_non_nullable +as List,listing: freezed == listing ? _self.listing : listing // ignore: cast_nullable_to_non_nullable +as ListFilesResponse?, + )); +} + + +} + +// dart format on diff --git a/lib/state/app/modules/files/bloc/files_state.g.dart b/lib/state/app/modules/files/bloc/files_state.g.dart new file mode 100644 index 0000000..7d5d345 --- /dev/null +++ b/lib/state/app/modules/files/bloc/files_state.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'files_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_FilesState _$FilesStateFromJson(Map json) => _FilesState( + currentPath: + (json['currentPath'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + listing: json['listing'] == null + ? null + : ListFilesResponse.fromJson(json['listing'] as Map), +); + +Map _$FilesStateToJson(_FilesState instance) => + { + 'currentPath': instance.currentPath, + 'listing': instance.listing, + }; diff --git a/lib/state/app/modules/files/dataProvider/files_data_provider.dart b/lib/state/app/modules/files/dataProvider/files_data_provider.dart new file mode 100644 index 0000000..dda4716 --- /dev/null +++ b/lib/state/app/modules/files/dataProvider/files_data_provider.dart @@ -0,0 +1,25 @@ +import 'dart:async'; + +import 'package:nextcloud/nextcloud.dart'; + +import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart'; +import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart'; +import '../../../../../api/marianumcloud/webdav/webdavApi.dart'; + +class FilesDataProvider { + Future listFiles(String path) { + final completer = Completer(); + ListFilesCache( + path: path, + onUpdate: (data) { + if (!completer.isCompleted) completer.complete(data); + }, + ); + return completer.future; + } + + Future createFolder(String fullPath) async { + final webdav = await WebdavApi.webdav; + await webdav.mkcol(PathUri.parse(fullPath)); + } +} diff --git a/lib/state/app/modules/files/repository/files_repository.dart b/lib/state/app/modules/files/repository/files_repository.dart new file mode 100644 index 0000000..341734e --- /dev/null +++ b/lib/state/app/modules/files/repository/files_repository.dart @@ -0,0 +1,11 @@ +import '../../../infrastructure/repository/repository.dart'; +import '../bloc/files_state.dart'; +import '../dataProvider/files_data_provider.dart'; + +class FilesRepository extends Repository { + final FilesDataProvider _provider; + + FilesRepository([FilesDataProvider? provider]) : _provider = provider ?? FilesDataProvider(); + + FilesDataProvider get data => _provider; +} diff --git a/lib/state/app/modules/settings/bloc/settings_cubit.dart b/lib/state/app/modules/settings/bloc/settings_cubit.dart new file mode 100644 index 0000000..19cbc2d --- /dev/null +++ b/lib/state/app/modules/settings/bloc/settings_cubit.dart @@ -0,0 +1,65 @@ +import 'dart:developer'; + +import 'package:easy_debounce/easy_debounce.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +import '../../../../../storage/base/settings.dart'; +import '../../../../../view/settings/defaultSettings.dart'; + +class SettingsCubit extends HydratedCubit { + static const _debounceTag = 'settings_persist'; + + SettingsCubit() : super(DefaultSettings.get()); + + Settings val({bool write = false}) { + if (write) { + // Notify listeners immediately so the UI reflects the mutation right away; + // debounce the actual persistence to disk to avoid hammering on rapid edits. + _emitFreshInstance(); + EasyDebounce.debounce(_debounceTag, const Duration(milliseconds: 500), _emitFreshInstance); + } + return state; + } + + void _emitFreshInstance() { + try { + emit(Settings.fromJson(state.toJson())); + } catch (e) { + log('Failed to refresh settings state: $e'); + } + } + + Future reset() async { + emit(DefaultSettings.get()); + } + + @override + Settings fromJson(Map json) { + try { + return Settings.fromJson(json); + } catch (_) { + try { + return Settings.fromJson(_mergeSettings(json, DefaultSettings.get().toJson())); + } catch (_) { + return DefaultSettings.get(); + } + } + } + + @override + Map? toJson(Settings state) => state.toJson(); + + Map _mergeSettings(Map oldMap, Map newMap) { + final merged = Map.from(newMap); + oldMap.forEach((key, value) { + if (merged.containsKey(key)) { + if (value is Map && merged[key] is Map) { + merged[key] = _mergeSettings(value, merged[key]); + } else { + merged[key] = value; + } + } + }); + return merged; + } +} diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart new file mode 100644 index 0000000..5055e08 --- /dev/null +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -0,0 +1,138 @@ +import 'package:intl/intl.dart'; + +import '../../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../repository/timetable_repository.dart'; +import 'timetable_event.dart'; +import 'timetable_state.dart'; + +class TimetableBloc extends LoadableHydratedBloc { + static const Duration _weekSpan = Duration(days: 7); + static final DateFormat _weekKeyFormat = DateFormat('yyyyMMdd'); + + DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0); + + @override + TimetableRepository repository() => TimetableRepository(); + + @override + TimetableState fromNothing() { + final reference = DateTime.now().add(const Duration(days: 2)); + return TimetableState( + startDate: _startOfWeek(reference), + endDate: _endOfWeek(reference), + ); + } + + @override + TimetableState fromStorage(Map json) => TimetableState.fromJson(json); + + @override + Map? toStorage(TimetableState state) => state.toJson(); + + @override + Future gatherData() async { + final initial = innerState ?? fromNothing(); + await Future.wait([ + _loadCurrentWeek(initial.startDate, initial.endDate), + _loadStaticReferenceData(), + _loadCustomEvents(), + ]); + _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().add(const Duration(days: 2)); + changeWeek(_startOfWeek(reference), _endOfWeek(reference)); + } + + void refresh() => fetch(); + + Future addCustomEvent(CustomTimetableEvent event) async { + await repo.data.addCustomEvent(event); + await _refreshCustomEvents(); + } + + Future updateCustomEvent(String id, CustomTimetableEvent event) async { + await repo.data.updateCustomEvent(id, event); + await _refreshCustomEvents(); + } + + Future removeCustomEvent(String id) async { + await repo.data.removeCustomEvent(id); + await _refreshCustomEvents(); + } + + Future _loadCurrentWeek(DateTime startDate, DateTime endDate) async { + final requestStart = DateTime.now(); + _lastWeekRequestStart = requestStart; + try { + final week = await repo.data.getWeek(startDate, endDate); + if (_lastWeekRequestStart.isAfter(requestStart)) return; + _writeWeekToCache(startDate, week); + } catch (_) { + // Errors are surfaced via LoadableHydratedBloc.fetch's catchError. + rethrow; + } + } + + Future _loadStaticReferenceData() async { + final (rooms, subjects, schoolHolidays) = await ( + repo.data.getRooms(), + repo.data.getSubjects(), + repo.data.getSchoolHolidays(), + ).wait; + + add(DataGathered((s) => s.copyWith( + rooms: rooms, + subjects: subjects, + schoolHolidays: schoolHolidays, + dataVersion: s.dataVersion + 1, + ))); + } + + Future _loadCustomEvents({bool renew = false}) async { + final events = await repo.data.getCustomEvents(renew: renew); + add(DataGathered((s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1))); + } + + Future _refreshCustomEvents() => _loadCustomEvents(renew: true); + + void _prefetchAdjacentWeeks(DateTime start, DateTime end) { + _prefetchWeek(start.subtract(_weekSpan), end.subtract(_weekSpan)); + _prefetchWeek(start.add(_weekSpan), end.add(_weekSpan)); + } + + void _prefetchWeek(DateTime start, DateTime end) { + repo.data.getWeek(start, end).then((week) => _writeWeekToCache(start, week)).catchError((_) {}); + } + + void _writeWeekToCache(DateTime weekStart, GetTimetableResponse week) { + final key = _weekKeyFormat.format(weekStart); + add(DataGathered((s) { + final updated = Map.of(s.weekCache); + updated[key] = week; + return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1); + })); + } + + static DateTime _startOfWeek(DateTime reference) { + final monday = reference.subtract(Duration(days: reference.weekday - 1)); + return DateTime(monday.year, monday.month, monday.day); + } + + static DateTime _endOfWeek(DateTime reference) { + final friday = reference.add(Duration(days: DateTime.daysPerWeek - reference.weekday - 2)); + return DateTime(friday.year, friday.month, friday.day); + } +} diff --git a/lib/state/app/modules/timetable/bloc/timetable_event.dart b/lib/state/app/modules/timetable/bloc/timetable_event.dart new file mode 100644 index 0000000..de90c8e --- /dev/null +++ b/lib/state/app/modules/timetable/bloc/timetable_event.dart @@ -0,0 +1,4 @@ +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import 'timetable_state.dart'; + +sealed class TimetableEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.dart b/lib/state/app/modules/timetable/bloc/timetable_state.dart new file mode 100644 index 0000000..cc88b26 --- /dev/null +++ b/lib/state/app/modules/timetable/bloc/timetable_state.dart @@ -0,0 +1,33 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart'; +import '../../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; +import '../../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; +import '../../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; +import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; + +part 'timetable_state.freezed.dart'; +part 'timetable_state.g.dart'; + +@freezed +abstract class TimetableState with _$TimetableState { + const TimetableState._(); + + const factory TimetableState({ + @Default({}) Map weekCache, + GetRoomsResponse? rooms, + GetSubjectsResponse? subjects, + GetHolidaysResponse? schoolHolidays, + GetCustomTimetableEventResponse? customEvents, + required DateTime startDate, + required DateTime endDate, + @Default(0) int dataVersion, + }) = _TimetableState; + + factory TimetableState.fromJson(Map json) => _$TimetableStateFromJson(json); + + Iterable getAllKnownLessons() => + weekCache.values.expand((response) => response.result); + + bool get hasReferenceData => rooms != null && subjects != null && schoolHolidays != null && customEvents != null; +} diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart b/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart new file mode 100644 index 0000000..1ad6cd1 --- /dev/null +++ b/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart @@ -0,0 +1,304 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'timetable_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$TimetableState { + + Map get weekCache; GetRoomsResponse? get rooms; GetSubjectsResponse? get subjects; GetHolidaysResponse? get schoolHolidays; GetCustomTimetableEventResponse? get customEvents; DateTime get startDate; DateTime get endDate; int get dataVersion; +/// Create a copy of TimetableState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TimetableStateCopyWith get copyWith => _$TimetableStateCopyWithImpl(this as TimetableState, _$identity); + + /// Serializes this TimetableState to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TimetableState&&const DeepCollectionEquality().equals(other.weekCache, weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(weekCache),rooms,subjects,schoolHolidays,customEvents,startDate,endDate,dataVersion); + +@override +String toString() { + return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)'; +} + + +} + +/// @nodoc +abstract mixin class $TimetableStateCopyWith<$Res> { + factory $TimetableStateCopyWith(TimetableState value, $Res Function(TimetableState) _then) = _$TimetableStateCopyWithImpl; +@useResult +$Res call({ + Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion +}); + + + + +} +/// @nodoc +class _$TimetableStateCopyWithImpl<$Res> + implements $TimetableStateCopyWith<$Res> { + _$TimetableStateCopyWithImpl(this._self, this._then); + + final TimetableState _self; + final $Res Function(TimetableState) _then; + +/// Create a copy of TimetableState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) { + return _then(_self.copyWith( +weekCache: null == weekCache ? _self.weekCache : weekCache // ignore: cast_nullable_to_non_nullable +as Map,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable +as GetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable +as GetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable +as GetHolidaysResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable +as GetCustomTimetableEventResponse?,startDate: null == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable +as DateTime,endDate: null == endDate ? _self.endDate : endDate // ignore: cast_nullable_to_non_nullable +as DateTime,dataVersion: null == dataVersion ? _self.dataVersion : dataVersion // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TimetableState]. +extension TimetableStatePatterns on TimetableState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TimetableState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TimetableState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TimetableState value) $default,){ +final _that = this; +switch (_that) { +case _TimetableState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TimetableState value)? $default,){ +final _that = this; +switch (_that) { +case _TimetableState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TimetableState() when $default != null: +return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion) $default,) {final _that = this; +switch (_that) { +case _TimetableState(): +return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,) {final _that = this; +switch (_that) { +case _TimetableState() when $default != null: +return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TimetableState extends TimetableState { + const _TimetableState({final Map weekCache = const {}, this.rooms, this.subjects, this.schoolHolidays, this.customEvents, required this.startDate, required this.endDate, this.dataVersion = 0}): _weekCache = weekCache,super._(); + factory _TimetableState.fromJson(Map json) => _$TimetableStateFromJson(json); + + final Map _weekCache; +@override@JsonKey() Map get weekCache { + if (_weekCache is EqualUnmodifiableMapView) return _weekCache; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_weekCache); +} + +@override final GetRoomsResponse? rooms; +@override final GetSubjectsResponse? subjects; +@override final GetHolidaysResponse? schoolHolidays; +@override final GetCustomTimetableEventResponse? customEvents; +@override final DateTime startDate; +@override final DateTime endDate; +@override@JsonKey() final int dataVersion; + +/// Create a copy of TimetableState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TimetableStateCopyWith<_TimetableState> get copyWith => __$TimetableStateCopyWithImpl<_TimetableState>(this, _$identity); + +@override +Map toJson() { + return _$TimetableStateToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TimetableState&&const DeepCollectionEquality().equals(other._weekCache, _weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_weekCache),rooms,subjects,schoolHolidays,customEvents,startDate,endDate,dataVersion); + +@override +String toString() { + return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)'; +} + + +} + +/// @nodoc +abstract mixin class _$TimetableStateCopyWith<$Res> implements $TimetableStateCopyWith<$Res> { + factory _$TimetableStateCopyWith(_TimetableState value, $Res Function(_TimetableState) _then) = __$TimetableStateCopyWithImpl; +@override @useResult +$Res call({ + Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion +}); + + + + +} +/// @nodoc +class __$TimetableStateCopyWithImpl<$Res> + implements _$TimetableStateCopyWith<$Res> { + __$TimetableStateCopyWithImpl(this._self, this._then); + + final _TimetableState _self; + final $Res Function(_TimetableState) _then; + +/// Create a copy of TimetableState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) { + return _then(_TimetableState( +weekCache: null == weekCache ? _self._weekCache : weekCache // ignore: cast_nullable_to_non_nullable +as Map,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable +as GetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable +as GetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable +as GetHolidaysResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable +as GetCustomTimetableEventResponse?,startDate: null == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable +as DateTime,endDate: null == endDate ? _self.endDate : endDate // ignore: cast_nullable_to_non_nullable +as DateTime,dataVersion: null == dataVersion ? _self.dataVersion : dataVersion // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +// dart format on diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.g.dart b/lib/state/app/modules/timetable/bloc/timetable_state.g.dart new file mode 100644 index 0000000..07960f5 --- /dev/null +++ b/lib/state/app/modules/timetable/bloc/timetable_state.g.dart @@ -0,0 +1,52 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'timetable_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_TimetableState _$TimetableStateFromJson(Map json) => + _TimetableState( + weekCache: + (json['weekCache'] as Map?)?.map( + (k, e) => MapEntry( + k, + GetTimetableResponse.fromJson(e as Map), + ), + ) ?? + const {}, + rooms: json['rooms'] == null + ? null + : GetRoomsResponse.fromJson(json['rooms'] as Map), + subjects: json['subjects'] == null + ? null + : GetSubjectsResponse.fromJson( + json['subjects'] as Map, + ), + schoolHolidays: json['schoolHolidays'] == null + ? null + : GetHolidaysResponse.fromJson( + json['schoolHolidays'] as Map, + ), + customEvents: json['customEvents'] == null + ? null + : GetCustomTimetableEventResponse.fromJson( + json['customEvents'] as Map, + ), + startDate: DateTime.parse(json['startDate'] as String), + endDate: DateTime.parse(json['endDate'] as String), + dataVersion: (json['dataVersion'] as num?)?.toInt() ?? 0, + ); + +Map _$TimetableStateToJson(_TimetableState instance) => + { + 'weekCache': instance.weekCache, + 'rooms': instance.rooms, + 'subjects': instance.subjects, + 'schoolHolidays': instance.schoolHolidays, + 'customEvents': instance.customEvents, + 'startDate': instance.startDate.toIso8601String(), + 'endDate': instance.endDate.toIso8601String(), + 'dataVersion': instance.dataVersion, + }; diff --git a/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart b/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart new file mode 100644 index 0000000..261bb0d --- /dev/null +++ b/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:intl/intl.dart'; + +import '../../../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart'; +import '../../../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.dart'; +import '../../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart'; +import '../../../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.dart'; +import '../../../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart'; +import '../../../../../api/mhsl/customTimetableEvent/remove/removeCustomTimetableEvent.dart'; +import '../../../../../api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.dart'; +import '../../../../../api/mhsl/customTimetableEvent/update/updateCustomTimetableEvent.dart'; +import '../../../../../api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.dart'; +import '../../../../../api/webuntis/queries/getHolidays/getHolidaysCache.dart'; +import '../../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; +import '../../../../../api/webuntis/queries/getRooms/getRoomsCache.dart'; +import '../../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; +import '../../../../../api/webuntis/queries/getSubjects/getSubjectsCache.dart'; +import '../../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; +import '../../../../../api/webuntis/queries/getTimetable/getTimetableCache.dart'; +import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../../../model/accountData.dart'; + +class TimetableDataProvider { + static final DateFormat _dateFormat = DateFormat('yyyyMMdd'); + + Future getWeek(DateTime startDate, DateTime endDate) { + final completer = Completer(); + GetTimetableCache( + startdate: int.parse(_dateFormat.format(startDate)), + enddate: int.parse(_dateFormat.format(endDate)), + onUpdate: (data) { + if (!completer.isCompleted) completer.complete(data); + }, + onError: (e) { + if (!completer.isCompleted) completer.completeError(e); + }, + ); + return completer.future; + } + + Future getRooms() { + final completer = Completer(); + GetRoomsCache(onUpdate: (data) { + if (!completer.isCompleted) completer.complete(data); + }); + return completer.future; + } + + Future getSubjects() { + final completer = Completer(); + GetSubjectsCache(onUpdate: (data) { + if (!completer.isCompleted) completer.complete(data); + }); + return completer.future; + } + + Future getSchoolHolidays() { + final completer = Completer(); + GetHolidaysCache(onUpdate: (data) { + if (!completer.isCompleted) completer.complete(data); + }); + return completer.future; + } + + Future getCustomEvents({bool renew = false}) { + final completer = Completer(); + GetCustomTimetableEventCache( + GetCustomTimetableEventParams(AccountData().getUserSecret()), + renew: renew, + onUpdate: (data) { + if (!completer.isCompleted) completer.complete(data); + }, + ); + return completer.future; + } + + Future addCustomEvent(CustomTimetableEvent event) => + AddCustomTimetableEvent(AddCustomTimetableEventParams(AccountData().getUserSecret(), event)).run(); + + Future updateCustomEvent(String id, CustomTimetableEvent event) => + UpdateCustomTimetableEvent(UpdateCustomTimetableEventParams(id, event)).run(); + + Future removeCustomEvent(String id) => + RemoveCustomTimetableEvent(RemoveCustomTimetableEventParams(id)).run(); +} diff --git a/lib/state/app/modules/timetable/repository/timetable_repository.dart b/lib/state/app/modules/timetable/repository/timetable_repository.dart new file mode 100644 index 0000000..43ac3a7 --- /dev/null +++ b/lib/state/app/modules/timetable/repository/timetable_repository.dart @@ -0,0 +1,11 @@ +import '../../../infrastructure/repository/repository.dart'; +import '../bloc/timetable_state.dart'; +import '../dataProvider/timetable_data_provider.dart'; + +class TimetableRepository extends Repository { + final TimetableDataProvider _provider; + + TimetableRepository([TimetableDataProvider? provider]) : _provider = provider ?? TimetableDataProvider(); + + TimetableDataProvider get data => _provider; +} diff --git a/lib/storage/base/settingsProvider.dart b/lib/storage/base/settingsProvider.dart deleted file mode 100644 index b1fb43f..0000000 --- a/lib/storage/base/settingsProvider.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'dart:convert'; -import 'dart:developer'; -import 'package:easy_debounce/easy_debounce.dart'; -import 'package:flutter/material.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../../view/settings/defaultSettings.dart'; -import 'settings.dart'; - -class SettingsProvider extends ChangeNotifier { - static const String _fieldName = 'settings'; - - late SharedPreferences _storage; - late Settings _settings = DefaultSettings.get(); - - Settings val({bool write = false}) { - if(write) { - notifyListeners(); - EasyDebounce.debounce( - _fieldName, - const Duration(milliseconds: 500), - update - ); - } - return _settings; - } - - SettingsProvider() { - _readFromStorage(); - } - - Future reset() async { - _storage = await SharedPreferences.getInstance(); - _storage.remove(_fieldName); - _settings = DefaultSettings.get(); - await update(); - - notifyListeners(); - } - - Future _readFromStorage() async { - _storage = await SharedPreferences.getInstance(); - - try { - _settings = Settings.fromJson(jsonDecode(_storage.getString(_fieldName)!)); - } catch(exception) { - try { - log('Settings were changed, trying to recover from old Settings: ${exception.toString()}'); - _settings = Settings.fromJson(_mergeSettings(jsonDecode(_storage.getString(_fieldName)!), DefaultSettings.get().toJson())); - log('Settings recovered successfully: ${_settings.toJson().toString()}'); - } catch(exception) { - log('Settings are defective and not recoverable, using defaults: ${exception.toString()}'); - _settings = DefaultSettings.get(); - log('Settings were reset to defaults!'); - } - } - - notifyListeners(); - } - - Future update() async { - await _storage.setString(_fieldName, jsonEncode(_settings.toJson())); - } - - Map _mergeSettings(Map oldMap, Map newMap) { - var mergedMap = Map.from(newMap); - - oldMap.forEach((key, value) { - if (mergedMap.containsKey(key)) { - if (value is Map && mergedMap[key] is Map) { - mergedMap[key] = _mergeSettings(value, mergedMap[key]); - } else { - mergedMap[key] = value; - } - } - }); - - return mergedMap; - } -} diff --git a/lib/storage/timetable/timetableSettings.dart b/lib/storage/timetable/timetableSettings.dart index 26c9d73..72d6a93 100644 --- a/lib/storage/timetable/timetableSettings.dart +++ b/lib/storage/timetable/timetableSettings.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../view/pages/timetable/timetableNameMode.dart'; +import 'timetable_name_mode.dart'; part 'timetableSettings.g.dart'; diff --git a/lib/view/pages/timetable/timetableNameMode.dart b/lib/storage/timetable/timetable_name_mode.dart similarity index 70% rename from lib/view/pages/timetable/timetableNameMode.dart rename to lib/storage/timetable/timetable_name_mode.dart index 6e4f0dd..d6aeeb2 100644 --- a/lib/view/pages/timetable/timetableNameMode.dart +++ b/lib/storage/timetable/timetable_name_mode.dart @@ -1,25 +1,18 @@ import 'package:flutter/material.dart'; -import '../../../widget/dropdownDisplay.dart'; +import '../../widget/dropdownDisplay.dart'; -enum TimetableNameMode { - name, - longName, - alternateName -} +enum TimetableNameMode { name, longName, alternateName } class TimetableNameModes { - static DropdownDisplay getDisplayOptions(TimetableNameMode theme) { - switch(theme) { + static DropdownDisplay getDisplayOptions(TimetableNameMode mode) { + switch (mode) { case TimetableNameMode.name: return DropdownDisplay(icon: Icons.device_unknown_outlined, displayName: 'Name'); - case TimetableNameMode.longName: return DropdownDisplay(icon: Icons.perm_device_info_outlined, displayName: 'Langname'); - case TimetableNameMode.alternateName: return DropdownDisplay(icon: Icons.on_device_training_outlined, displayName: 'Kurzform'); } } } - diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index 6f7d0a5..167b06f 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -2,13 +2,14 @@ import 'dart:developer'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_login/flutter_login.dart'; -import 'package:provider/provider.dart'; import '../../api/marianumcloud/talk/room/getRoom.dart'; import '../../api/marianumcloud/talk/room/getRoomParams.dart'; import '../../model/accountData.dart'; -import '../../model/accountModel.dart'; +import '../../state/app/modules/account/bloc/account_bloc.dart'; +import '../../state/app/modules/account/bloc/account_state.dart'; class Login extends StatefulWidget { const Login({super.key}); @@ -20,7 +21,7 @@ class Login extends StatefulWidget { class _LoginState extends State { bool displayDisclaimerText = true; - String? _checkInput(value)=> (value ?? '').length == 0 ? 'Eingabe erforderlich' : null; + String? _checkInput(String? value) => (value ?? '').isEmpty ? 'Eingabe erforderlich' : null; Future _login(LoginData data) async { await AccountData().removeData(); @@ -55,7 +56,7 @@ class _LoginState extends State { userValidator: _checkInput, passwordValidator: _checkInput, - onSubmitAnimationCompleted: () => Provider.of(context, listen: false).setState(AccountModelState.loggedIn), + onSubmitAnimationCompleted: () => context.read().setStatus(AccountStatus.loggedIn), onLogin: _login, onSignup: null, diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index 86ab4f4..83420bc 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -1,32 +1,22 @@ - import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:loader_overlay/loader_overlay.dart'; -import 'package:nextcloud/nextcloud.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:provider/provider.dart'; import '../../../api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart'; -import '../../../api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart'; -import '../../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart'; -import '../../../api/marianumcloud/webdav/webdavApi.dart'; -import '../../../model/files/filesProps.dart'; -import '../../../storage/base/settingsProvider.dart'; -import '../../../widget/loadingSpinner.dart'; -import '../../../widget/placeholderView.dart'; +import '../../../state/app/infrastructure/loadableState/loadable_state.dart'; +import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; +import '../../../state/app/modules/files/bloc/files_bloc.dart'; +import '../../../state/app/modules/files/bloc/files_state.dart'; +import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../widget/filePick.dart'; +import '../../../widget/placeholderView.dart'; import 'fileElement.dart'; import 'filesUploadDialog.dart'; -class Files extends StatefulWidget { - final List path; - Files({List? path, super.key}) : path = path ?? []; - - @override - State createState() => _FilesState(); -} - class BetterSortOption { String displayName; int Function(CacheableFile, CacheableFile) compare; @@ -35,111 +25,107 @@ class BetterSortOption { BetterSortOption({required this.displayName, required this.icon, required this.compare}); } -enum SortOption { - name, - date, - size -} +enum SortOption { name, date, size } class SortOptions { static Map options = { SortOption.name: BetterSortOption( displayName: 'Name', icon: Icons.sort_by_alpha_outlined, - compare: (CacheableFile a, CacheableFile b) => a.name.compareTo(b.name) + compare: (a, b) => a.name.compareTo(b.name), ), SortOption.date: BetterSortOption( displayName: 'Datum', icon: Icons.history_outlined, - compare: (CacheableFile a, CacheableFile b) => a.modifiedAt!.compareTo(b.modifiedAt!) + compare: (a, b) => a.modifiedAt!.compareTo(b.modifiedAt!), ), SortOption.size: BetterSortOption( displayName: 'Größe', icon: Icons.sd_card_outlined, - compare: (CacheableFile a, CacheableFile b) { - if(a.isDirectory || b.isDirectory) return a.isDirectory ? 1 : 0; - if(a.size == null) return 0; - if(b.size == null) return 1; + compare: (a, b) { + if (a.isDirectory || b.isDirectory) return a.isDirectory ? 1 : 0; + if (a.size == null) return 0; + if (b.size == null) return 1; return a.size!.compareTo(b.size!); - } - ) + }, + ), }; static BetterSortOption getOption(SortOption option) => options[option]!; } -class _FilesState extends State { - FilesProps props = FilesProps(); - ListFilesResponse? data; +class Files extends StatelessWidget { + final List path; - late SettingsProvider settings = Provider.of(context, listen: false); + Files({List? path, super.key}) : path = path ?? []; - SortOption currentSort = SortOption.name; - bool currentSortDirection = true; + @override + Widget build(BuildContext context) => BlocModule>( + create: (_) => FilesBloc(initialPath: path), + child: (context, _, _) => _FilesView(path: path), + ); +} + +class _FilesView extends StatefulWidget { + final List path; + const _FilesView({required this.path}); + + @override + State<_FilesView> createState() => _FilesViewState(); +} + +class _FilesViewState extends State<_FilesView> { + late final SettingsCubit settings; + late SortOption currentSort; + late bool currentSortDirection; @override void initState() { super.initState(); + settings = context.read(); currentSort = settings.val().fileSettings.sortBy; currentSortDirection = settings.val().fileSettings.ascending; - _query(); - } - - void _query() { - ListFilesCache( - path: widget.path.isEmpty ? '/' : widget.path.join('/'), - onUpdate: (ListFilesResponse d) { - d.files.removeWhere((element) => element.name.isEmpty || element.name == widget.path.lastOrNull()); - setState(() { - data = d; - }); - } - ); } Future mediaUpload(List? paths) async { - if(paths == null) return; - + if (paths == null) return; + final bloc = context.read(); pushScreen( context, withNavBar: false, - screen: FilesUploadDialog(filePaths: paths, remotePath: widget.path.join('/'), onUploadFinished: (uploadedFilePaths) => _query()), + screen: FilesUploadDialog( + filePaths: paths, + remotePath: widget.path.join('/'), + onUploadFinished: (_) => bloc.refresh(), + ), ); - - return; } @override Widget build(BuildContext context) { - var files = data?.sortBy( - sortOption: currentSort, - foldersToTop: Provider.of(context).val().fileSettings.sortFoldersToTop, - reversed: currentSortDirection - ) ?? List.empty(); - + final bloc = context.read(); return Scaffold( appBar: AppBar( title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'), actions: [ - // IconButton( - // icon: const Icon(Icons.search), - // onPressed: () => { - // // TODO implement search - // }, - // ), PopupMenuButton( icon: Icon(currentSortDirection ? Icons.text_rotate_up : Icons.text_rotation_down), - itemBuilder: (context) => [true, false].map((e) => PopupMenuItem( - value: e, - enabled: e != currentSortDirection, - child: Row( - children: [ - Icon(e ? Icons.text_rotate_up : Icons.text_rotation_down, color: Theme.of(context).colorScheme.onSurface), - const SizedBox(width: 15), - Text(e ? 'Aufsteigend' : 'Absteigend') - ], - ) - )).toList(), + itemBuilder: (context) => [true, false] + .map((e) => PopupMenuItem( + value: e, + enabled: e != currentSortDirection, + child: Row( + children: [ + Icon( + e ? Icons.text_rotate_up : Icons.text_rotation_down, + color: Theme.of(context).colorScheme.onSurface, + ), + const SizedBox(width: 15), + Text(e ? 'Aufsteigend' : 'Absteigend'), + ], + ), + )) + .toList(), onSelected: (e) { setState(() { currentSortDirection = e; @@ -149,17 +135,19 @@ class _FilesState extends State { ), PopupMenuButton( icon: const Icon(Icons.sort), - itemBuilder: (context) => SortOptions.options.keys.map((key) => PopupMenuItem( - value: key, - enabled: key != currentSort, - child: Row( - children: [ - Icon(SortOptions.getOption(key).icon, color: Theme.of(context).colorScheme.onSurface), - const SizedBox(width: 15), - Text(SortOptions.getOption(key).displayName), - ], - ) - )).toList(), + itemBuilder: (context) => SortOptions.options.keys + .map((key) => PopupMenuItem( + value: key, + enabled: key != currentSort, + child: Row( + children: [ + Icon(SortOptions.getOption(key).icon, color: Theme.of(context).colorScheme.onSurface), + const SizedBox(width: 15), + Text(SortOptions.getOption(key).displayName), + ], + ), + )) + .toList(), onSelected: (e) { setState(() { currentSort = e; @@ -172,81 +160,91 @@ class _FilesState extends State { floatingActionButton: FloatingActionButton( heroTag: 'uploadFile', backgroundColor: Theme.of(context).primaryColor, - onPressed: () { - showDialog(context: context, builder: (context) => SimpleDialog( - children: [ - ListTile( - leading: const Icon(Icons.create_new_folder_outlined), - title: const Text('Ordner erstellen'), - onTap: () { - Navigator.of(context).pop(); - showDialog(context: context, builder: (context) { - var inputController = TextEditingController(); - return AlertDialog( - title: const Text('Neuer Ordner'), - content: TextField( - controller: inputController, - decoration: const InputDecoration( - labelText: 'Name', - ), - ), - actions: [ - TextButton(onPressed: () { - Navigator.of(context).pop(); - }, child: const Text('Abbrechen')), - TextButton(onPressed: () { - WebdavApi.webdav.then((webdav) { - webdav.mkcol(PathUri.parse("${widget.path.join("/")}/${inputController.text}")).then((value) => _query()); - }); - Navigator.of(context).pop(); - }, child: const Text('Ordner erstellen')), - ], - ); - }); - }, - ), - ListTile( - leading: const Icon(Icons.upload_file), - title: const Text('Aus Dateien hochladen'), - onTap: () { - FilePick.documentPick().then(mediaUpload); - Navigator.of(context).pop(); - }, - ), - Visibility( - visible: !Platform.isIOS, - child: ListTile( - leading: const Icon(Icons.add_a_photo_outlined), - title: const Text('Aus Gallerie hochladen'), - onTap: () { - FilePick.multipleGalleryPick().then((value) { - if(value != null) mediaUpload(value.map((e) => e.path).toList()); - }); - Navigator.of(context).pop(); - }, - ), - ), - ], - )); - }, + onPressed: () => _showAddDialog(context, bloc), child: const Icon(Icons.add), ), - body: data == null ? const LoadingSpinner() : data!.files.isEmpty ? const PlaceholderView(icon: Icons.folder_off_rounded, text: 'Der Ordner ist leer') : LoaderOverlay( - child: RefreshIndicator( - onRefresh: () { - _query(); - return Future.delayed(const Duration(seconds: 3)); + body: LoadableStateConsumer( + child: (state, _) { + final listing = state.listing; + if (listing == null) return const SizedBox.shrink(); + if (listing.files.isEmpty) { + return const PlaceholderView(icon: Icons.folder_off_rounded, text: 'Der Ordner ist leer'); + } + final files = listing.sortBy( + sortOption: currentSort, + foldersToTop: context.watch().val().fileSettings.sortFoldersToTop, + reversed: currentSortDirection, + ); + return LoaderOverlay( + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: files.length, + itemBuilder: (context, index) => FileElement(files[index], widget.path, bloc.refresh), + ), + ); + }, + ), + ); + } + + void _showAddDialog(BuildContext context, FilesBloc bloc) { + showDialog( + context: context, + builder: (dialogCtx) => SimpleDialog(children: [ + ListTile( + leading: const Icon(Icons.create_new_folder_outlined), + title: const Text('Ordner erstellen'), + onTap: () { + Navigator.of(dialogCtx).pop(); + _showCreateFolderDialog(context, bloc); }, - child: ListView.builder( - padding: EdgeInsets.zero, - itemCount: files.length, - itemBuilder: (context, index) { - var file = files.toList()[index]; - return FileElement(file, widget.path, _query); + ), + ListTile( + leading: const Icon(Icons.upload_file), + title: const Text('Aus Dateien hochladen'), + onTap: () { + FilePick.documentPick().then(mediaUpload); + Navigator.of(dialogCtx).pop(); + }, + ), + Visibility( + visible: !Platform.isIOS, + child: ListTile( + leading: const Icon(Icons.add_a_photo_outlined), + title: const Text('Aus Gallerie hochladen'), + onTap: () { + FilePick.multipleGalleryPick().then((value) { + if (value != null) mediaUpload(value.map((e) => e.path).toList()); + }); + Navigator.of(dialogCtx).pop(); }, ), - ) - ) + ), + ]), + ); + } + + void _showCreateFolderDialog(BuildContext context, FilesBloc bloc) { + final inputController = TextEditingController(); + showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: const Text('Neuer Ordner'), + content: TextField( + controller: inputController, + decoration: const InputDecoration(labelText: 'Name'), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Abbrechen')), + TextButton( + onPressed: () { + bloc.createFolder(inputController.text); + Navigator.of(dialogCtx).pop(); + }, + child: const Text('Ordner erstellen'), + ), + ], + ), ); } } diff --git a/lib/view/pages/files/filesUploadDialog.dart b/lib/view/pages/files/filesUploadDialog.dart index 72803c2..bc0112f 100644 --- a/lib/view/pages/files/filesUploadDialog.dart +++ b/lib/view/pages/files/filesUploadDialog.dart @@ -65,6 +65,28 @@ class _FilesUploadDialogState extends State { ); } + void _showUploadError(String message) { + setState(() { + _isUploading = false; + _overallProgressValue = 0.0; + _infoText = ''; + }); + showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('Upload fehlgeschlagen'), + contentPadding: const EdgeInsets.all(10), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Schließen', textAlign: TextAlign.center), + ), + ], + ), + ); + } + Future uploadFiles({bool override = false}) async { setState(() { _isUploading = true; @@ -74,16 +96,24 @@ class _FilesUploadDialogState extends State { } }); - var webdavClient = await WebdavApi.webdav; + final webdavClient = await WebdavApi.webdav; if (!override) { - var result = (await webdavClient.propfind(PathUri.parse(widget.remotePath))).responses; + List result; + try { + result = (await webdavClient.propfind(PathUri.parse(widget.remotePath))).responses; + } catch (e) { + if (!mounted) return; + _showUploadError('Verbindung fehlgeschlagen: $e'); + return; + } var conflictingFiles = _uploadableFiles.where((file) { var fileName = file.fileName; return result.any((element) => Uri.decodeComponent(element.href!).endsWith('/$fileName')); }).toList(); if(conflictingFiles.isNotEmpty) { + if (!mounted) return; bool replaceFiles = await showDialog( context: context, barrierDismissible: false, @@ -157,17 +187,24 @@ class _FilesUploadDialogState extends State { _infoText = '${_uploadableFiles.indexOf(file) + 1}/${_uploadableFiles.length}'; }); - var uploadTask = await webdavClient.putFile( - File(filePath), - FileStat.statSync(filePath), - PathUri.parse(fullRemotePath), - onProgress: (progress) { - setState(() { - file._uploadProgress = progress; - _overallProgressValue = ((progress + _uploadableFiles.indexOf(file)) / _uploadableFiles.length).toDouble(); - }); - }, - ); + final dynamic uploadTask; + try { + uploadTask = await webdavClient.putFile( + File(filePath), + FileStat.statSync(filePath), + PathUri.parse(fullRemotePath), + onProgress: (progress) { + setState(() { + file._uploadProgress = progress; + _overallProgressValue = ((progress + _uploadableFiles.indexOf(file)) / _uploadableFiles.length).toDouble(); + }); + }, + ); + } catch (e) { + if (!mounted) return; + _showUploadError('Upload fehlgeschlagen für "$fileName": $e'); + return; + } if(uploadTask.statusCode < 200 || uploadTask.statusCode > 299) { setState(() { @@ -175,6 +212,7 @@ class _FilesUploadDialogState extends State { _overallProgressValue = 0.0; _infoText = ''; }); + if (!mounted) return; Navigator.of(context).pop(); showHttpErrorCode(uploadTask.statusCode); } else { @@ -187,6 +225,7 @@ class _FilesUploadDialogState extends State { _overallProgressValue = 0.0; _infoText = ''; }); + if (!mounted) return; Navigator.of(context).pop(); widget.onUploadFinished(uploadetFilePaths); } diff --git a/lib/view/pages/more/feedback/feedbackDialog.dart b/lib/view/pages/more/feedback/feedbackDialog.dart index 80d7daf..1b532f2 100644 --- a/lib/view/pages/more/feedback/feedbackDialog.dart +++ b/lib/view/pages/more/feedback/feedbackDialog.dart @@ -6,13 +6,13 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:loader_overlay/loader_overlay.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:badges/badges.dart' as badges; import '../../../../api/mhsl/server/feedback/addFeedback.dart'; import '../../../../api/mhsl/server/feedback/addFeedbackParams.dart'; import '../../../../model/accountData.dart'; -import '../../../../storage/base/settingsProvider.dart'; +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../widget/filePick.dart'; import '../../../../widget/focusBehaviour.dart'; import '../../../../widget/infoDialog.dart'; @@ -113,7 +113,7 @@ class _FeedbackDialogState extends State { child: Visibility( visible: _error != null, child: Visibility( - visible: Provider.of(context, listen: false).val().devToolsEnabled, + visible: context.read().val().devToolsEnabled, replacement: const Text('Senden fehlgeschlagen, bitte überprüfe die Internetverbindung.', textAlign: TextAlign.center, style: TextStyle(color: Colors.red)), child: Text('Senden fehlgeschlagen: \n $_error', textAlign: TextAlign.center, style: const TextStyle(color: Colors.red)), ), @@ -156,13 +156,16 @@ class _FeedbackDialogState extends State { appVersion: int.parse((await PackageInfo.fromPlatform()).buildNumber), ) ).run().then((value) { + if (!context.mounted) return; Navigator.of(context).pop(); InfoDialog.show(context, 'Danke für dein Feedback!'); context.loaderOverlay.hide(); }).catchError((error, trace) { + if (!mounted) return; setState(() { _error = error.toString(); }); + if (!context.mounted) return; context.loaderOverlay.hide(); }); }, diff --git a/lib/view/pages/more/roomplan/roomplan.dart b/lib/view/pages/more/roomplan/roomplan.dart index edabc5e..efbc774 100644 --- a/lib/view/pages/more/roomplan/roomplan.dart +++ b/lib/view/pages/more/roomplan/roomplan.dart @@ -13,7 +13,7 @@ class Roomplan extends StatelessWidget { imageProvider: Image.asset('assets/img/raumplan.jpg').image, minScale: 0.5, maxScale: 2.0, - backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.background), + backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), ), ); } diff --git a/lib/view/pages/more/share/appSharePlatformView.dart b/lib/view/pages/more/share/appSharePlatformView.dart index b25d7c1..b793d4e 100644 --- a/lib/view/pages/more/share/appSharePlatformView.dart +++ b/lib/view/pages/more/share/appSharePlatformView.dart @@ -8,7 +8,7 @@ class AppSharePlatformView extends StatelessWidget { @override Widget build(BuildContext context) { - var foregroundColor = Theme.of(context).colorScheme.onBackground; + var foregroundColor = Theme.of(context).colorScheme.onSurface; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, diff --git a/lib/view/pages/more/share/selectShareTypeDialog.dart b/lib/view/pages/more/share/selectShareTypeDialog.dart index 0b768bc..001c86b 100644 --- a/lib/view/pages/more/share/selectShareTypeDialog.dart +++ b/lib/view/pages/more/share/selectShareTypeDialog.dart @@ -23,14 +23,14 @@ class SelectShareTypeDialog extends StatelessWidget { title: const Text('Per Link teilen'), trailing: const Icon(Icons.arrow_right), onTap: () { - Share.share( - sharePositionOrigin: SharePositionOrigin.get(context), - subject: 'App Teilen', - 'Hol dir die für das Marianum maßgeschneiderte App:' - '\n\nAndroid: https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client ' - '\niOS: https://apps.apple.com/us/app/marianum-fulda/id6458789560 ' - '\n\nViel Spaß!' - ); + SharePlus.instance.share(ShareParams( + sharePositionOrigin: SharePositionOrigin.get(context), + subject: 'App Teilen', + text: 'Hol dir die für das Marianum maßgeschneiderte App:' + '\n\nAndroid: https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client ' + '\niOS: https://apps.apple.com/us/app/marianum-fulda/id6458789560 ' + '\n\nViel Spaß!', + )); }, ) ], diff --git a/lib/view/pages/overhang.dart b/lib/view/pages/overhang.dart index 7b3db61..074e3e9 100644 --- a/lib/view/pages/overhang.dart +++ b/lib/view/pages/overhang.dart @@ -3,12 +3,13 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:in_app_review/in_app_review.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../extensions/renderNotNull.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import '../../state/app/modules/app_modules.dart'; -import '../../storage/base/settingsProvider.dart'; +import '../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../storage/base/settings.dart' as model; import '../../widget/centeredLeading.dart'; import '../../widget/infoDialog.dart'; import '../settings/defaultSettings.dart'; @@ -27,7 +28,9 @@ class _OverhangState extends State { bool editMode = false; @override - Widget build(BuildContext context) => Consumer(builder: (context, settings, child) => Scaffold( + Widget build(BuildContext context) => BlocBuilder(builder: (context, _) { + final settings = context.read(); + return Scaffold( appBar: AppBar( title: const Text('Mehr'), actions: [ @@ -42,9 +45,11 @@ class _OverhangState extends State { ], ), body: editMode ? _sorting() : _overhang(), - )); + ); + }); - Widget _sorting() => Consumer(builder: (context, settings, child) { + Widget _sorting() => BlocBuilder(builder: (context, _) { + final settings = context.read(); void changeVisibility(Modules module) { var hidden = settings.val(write: true).modulesSettings.hiddenModules; hidden.contains(module) ? hidden.remove(module) : (hidden.length < 3 ? hidden.add(module) : null); @@ -107,8 +112,14 @@ class _OverhangState extends State { trailing: const Icon(Icons.arrow_right), onTap: () { InAppReview.instance.openStoreListing(appStoreId: '6458789560').then( - (value) => InfoDialog.show(context, 'Vielen Dank!'), - onError: (error) => InfoDialog.show(context, error.toString()) + (value) { + if (!context.mounted) return; + InfoDialog.show(context, 'Vielen Dank!'); + }, + onError: (error) { + if (!context.mounted) return; + InfoDialog.show(context, error.toString()); + }, ); }, ); diff --git a/lib/view/pages/talk/chatList.dart b/lib/view/pages/talk/chatList.dart index 905a11d..df0f4bc 100644 --- a/lib/view/pages/talk/chatList.dart +++ b/lib/view/pages/talk/chatList.dart @@ -1,79 +1,85 @@ - -import 'dart:async'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_split_view/flutter_split_view.dart'; -import 'package:provider/provider.dart'; -import '../../../api/marianumcloud/talk/createRoom/createRoom.dart'; -import '../../../api/marianumcloud/talk/createRoom/createRoomParams.dart'; -import '../../../model/chatList/chatListProps.dart'; +import '../../../state/app/infrastructure/loadableState/loadable_state.dart'; +import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; +import '../../../state/app/modules/chatList/bloc/chat_list_bloc.dart'; +import '../../../state/app/modules/chatList/bloc/chat_list_state.dart'; import '../../../notification/notifyUpdater.dart'; -import '../../../storage/base/settingsProvider.dart'; +import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../widget/confirmDialog.dart'; -import '../../../widget/loadingSpinner.dart'; import 'components/chatTile.dart'; import 'components/splitViewPlaceholder.dart'; import 'joinChat.dart'; import 'searchChat.dart'; -class ChatList extends StatefulWidget { +class ChatList extends StatelessWidget { const ChatList({super.key}); @override - State createState() => _ChatListState(); + Widget build(BuildContext context) => BlocModule>( + create: (_) => ChatListBloc(), + child: (context, bloc, _) => const _ChatListView(), + ); } -class _ChatListState extends State { - late SettingsProvider settings; +class _ChatListView extends StatefulWidget { + const _ChatListView(); + + @override + State<_ChatListView> createState() => _ChatListViewState(); +} + +class _ChatListViewState extends State<_ChatListView> { + late final SettingsCubit _settings; @override void initState() { super.initState(); - settings = Provider.of(context, listen: false); + _settings = context.read(); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _query(); - - if(!settings.val().notificationSettings.enabled && !settings.val().notificationSettings.askUsageDismissed) { - settings.val(write: true).notificationSettings.askUsageDismissed = true; - - ConfirmDialog( - icon: Icons.notifications_active_outlined, - title: 'Benachrichtigungen aktivieren', - content: 'Auf wunsch kannst du Push-Benachrichtigungen aktivieren. Deine Einstellungen kannst du jederzeit ändern.', - confirmButton: 'Weiter', - onConfirm: () { - FirebaseMessaging.instance.requestPermission( - provisional: false - ).then((value) { - switch (value.authorizationStatus) { - case AuthorizationStatus.authorized: - NotifyUpdater.enableAfterDisclaimer(settings).asDialog(context); - break; - case AuthorizationStatus.denied: - showDialog(context: context, builder: (context) => const AlertDialog( - content: Text('Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.'), - )); - break; - default: - break; - } - }); - }, - ).asDialog(context); - } - }); + WidgetsBinding.instance.addPostFrameCallback((_) => _maybeAskForNotificationPermission()); } - void _query({bool renew = false}) { - Provider.of(context, listen: false).run(renew: renew); + void _maybeAskForNotificationPermission() { + final notificationSettings = _settings.val().notificationSettings; + if (notificationSettings.enabled || notificationSettings.askUsageDismissed) return; + + _settings.val(write: true).notificationSettings.askUsageDismissed = true; + ConfirmDialog( + icon: Icons.notifications_active_outlined, + title: 'Benachrichtigungen aktivieren', + content: 'Auf wunsch kannst du Push-Benachrichtigungen aktivieren. Deine Einstellungen kannst du jederzeit ändern.', + confirmButton: 'Weiter', + onConfirm: () { + FirebaseMessaging.instance.requestPermission(provisional: false).then((value) { + if (!mounted) return; + switch (value.authorizationStatus) { + case AuthorizationStatus.authorized: + NotifyUpdater.enableAfterDisclaimer(_settings).asDialog(context); + break; + case AuthorizationStatus.denied: + showDialog( + context: context, + builder: (_) => const AlertDialog( + content: Text('Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.'), + ), + ); + break; + default: + break; + } + }); + }, + ).asDialog(context); } @override Widget build(BuildContext context) { - ChatListProps? latestData; - + final bloc = context.read(); return SplitView.material( placeholder: const SplitViewPlaceholder(), breakpoint: 1000, @@ -83,63 +89,50 @@ class _ChatListState extends State { actions: [ IconButton( icon: const Icon(Icons.search), - onPressed: () async { - if(latestData == null) return; - showSearch(context: context, delegate: SearchChat(latestData!.getRoomsResponse.data.toList())); + onPressed: () { + final rooms = bloc.state.data?.rooms; + if (rooms == null) return; + showSearch(context: context, delegate: SearchChat(rooms.data.toList())); }, - ) + ), ], ), floatingActionButton: FloatingActionButton( heroTag: 'createChat', backgroundColor: Theme.of(context).primaryColor, - onPressed: () async { + onPressed: () { showSearch(context: context, delegate: JoinChat()).then((username) { - if(username == null) return; - + if (username == null || !context.mounted) return; ConfirmDialog( title: 'Chat starten', content: "Möchtest du einen Chat mit Nutzer '$username' starten?", confirmButton: 'Chat starten', onConfirm: () { - CreateRoom(CreateRoomParams( - roomType: 1, - invite: username, - )).run().then((value) { - _query(renew: true); - }); + bloc.createDirectChat(username); }, ).asDialog(context); }); }, child: const Icon(Icons.add_comment_outlined), ), - body: Consumer( - builder: (context, data, child) { + body: LoadableStateConsumer( + child: (state, _) { + final rooms = state.rooms; + if (rooms == null) return const SizedBox.shrink(); - if(data.primaryLoading()) return const LoadingSpinner(); - latestData = data; - var chats = []; - for (var chatRoom in data.getRoomsResponse.sortBy( + final talkSettings = context.watch().val().talkSettings; + final sorted = rooms.sortBy( lastActivity: true, - favoritesToTop: Provider.of(context).val().talkSettings.sortFavoritesToTop, - unreadToTop: Provider.of(context).val().talkSettings.sortUnreadToTop, - ) - ) { - var hasDraft = settings.val().talkSettings.drafts.containsKey(chatRoom.token); - chats.add(ChatTile(data: chatRoom, query: _query, hasDraft: hasDraft)); - } + favoritesToTop: talkSettings.sortFavoritesToTop, + unreadToTop: talkSettings.sortUnreadToTop, + ); - return RefreshIndicator( - color: Theme.of(context).primaryColor, - onRefresh: () { - _query(renew: true); - return Future.delayed(const Duration(seconds: 3)); - }, - child: ListView( - padding: EdgeInsets.zero, - children: chats - ), + return ListView( + padding: EdgeInsets.zero, + children: sorted.map((room) { + final hasDraft = _settings.val().talkSettings.drafts.containsKey(room.token); + return ChatTile(data: room, hasDraft: hasDraft); + }).toList(), ); }, ), diff --git a/lib/view/pages/talk/chatView.dart b/lib/view/pages/talk/chatView.dart index 4d6ca48..63bf7cc 100644 --- a/lib/view/pages/talk/chatView.dart +++ b/lib/view/pages/talk/chatView.dart @@ -1,12 +1,12 @@ - import 'package:flutter/material.dart'; -import '../../../extensions/dateTime.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../api/marianumcloud/talk/chat/getChatResponse.dart'; import '../../../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../../../extensions/dateTime.dart'; +import '../../../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../../../state/app/modules/chat/bloc/chat_state.dart'; import '../../../theming/appTheme.dart'; -import '../../../model/chatList/chatProps.dart'; import '../../../widget/clickableAppBar.dart'; import '../../../widget/loadingSpinner.dart'; import '../../../widget/userAvatar.dart'; @@ -27,66 +27,63 @@ class ChatView extends StatefulWidget { } class _ChatViewState extends State { - final ScrollController _listController = ScrollController(); - @override - void initState() { - super.initState(); - } - - void _query({bool renew = false}) { - Provider.of(context, listen: false).setQueryToken(widget.room.token); + void _refresh() { + context.read().setToken(widget.room.token); } @override - Widget build(BuildContext context) => Consumer( - builder: (context, data, child) { - var messages = List.empty(growable: true); + Widget build(BuildContext context) => BlocBuilder( + builder: (context, _) { + final state = context.watch().state.data ?? const ChatState(); + final response = state.chatResponse; + final isLoading = response == null; - if(!data.primaryLoading()) { + final messages = []; + if (response != null) { var lastDate = DateTime.now(); - data.getChatResponse.sortByTimestamp().forEach((element) { - var elementDate = DateTime.fromMillisecondsSinceEpoch(element.timestamp * 1000); + for (final element in response.sortByTimestamp()) { + final elementDate = DateTime.fromMillisecondsSinceEpoch(element.timestamp * 1000); - if(element.systemMessage.contains('reaction')) return; - if(element.systemMessage.contains('poll_voted')) return; - var commonRead = int.parse(data.getChatResponse.headers?['x-chat-last-common-read'] ?? '0'); + if (element.systemMessage.contains('reaction')) continue; + if (element.systemMessage.contains('poll_voted')) continue; + final commonRead = int.parse(response.headers?['x-chat-last-common-read'] ?? '0'); - if(!elementDate.isSameDay(lastDate)) { + if (!elementDate.isSameDay(lastDate)) { lastDate = elementDate; messages.add(ChatBubble( context: context, isSender: false, bubbleData: GetChatResponseObject.getDateDummy(element.timestamp), chatData: widget.room, - refetch: _query, + refetch: ({bool renew = false}) => _refresh(), )); } - messages.add( - ChatBubble( - context: context, - isSender: element.actorId == widget.selfId && element.messageType == GetRoomResponseObjectMessageType.comment, - bubbleData: element, - chatData: widget.room, - refetch: _query, - isRead: element.id <= commonRead, - selfId: widget.selfId, - ) - ); - }); - if(data.getChatResponse.data.length >= 200) { + messages.add(ChatBubble( + context: context, + isSender: element.actorId == widget.selfId && + element.messageType == GetRoomResponseObjectMessageType.comment, + bubbleData: element, + chatData: widget.room, + refetch: ({bool renew = false}) => _refresh(), + isRead: element.id <= commonRead, + selfId: widget.selfId, + )); + } + + if (response.data.length >= 200) { messages.insert(0, ChatBubble( context: context, isSender: false, bubbleData: GetChatResponseObject.getTextDummy( - 'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. ' - 'Um ältere Nachrichten abzurufen verwende die Webversion unter https://cloud.marianum-fulda.de' + 'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. ' + 'Um ältere Nachrichten abzurufen verwende die Webversion unter https://cloud.marianum-fulda.de', ), chatData: widget.room, - refetch: _query, + refetch: ({bool renew = false}) => _refresh(), )); } } @@ -94,9 +91,7 @@ class _ChatViewState extends State { return Scaffold( backgroundColor: const Color(0xffefeae2), appBar: ClickableAppBar( - onTap: () { - TalkNavigator.pushSplitView(context, ChatInfo(widget.room)); - }, + onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)), appBar: AppBar( title: Row( children: [ @@ -104,7 +99,7 @@ class _ChatViewState extends State { const SizedBox(width: 10), Expanded( child: Text(widget.room.displayName, overflow: TextOverflow.ellipsis, maxLines: 1), - ) + ), ], ), ), @@ -117,26 +112,27 @@ class _ChatViewState extends State { opacity: 1, repeat: ImageRepeat.repeat, invertColors: AppTheme.isDarkMode(context), - ) + ), ), - child: data.primaryLoading() ? const LoadingSpinner() : Column( - children: [ - Expanded( - child: ListView( - reverse: true, - controller: _listController, - children: messages.reversed.toList(), + child: isLoading + ? const LoadingSpinner() + : Column( + children: [ + Expanded( + child: ListView( + reverse: true, + controller: _listController, + children: messages.reversed.toList(), + ), + ), + Container( + color: Theme.of(context).colorScheme.surface, + child: TalkNavigator.isSecondaryVisible(context) + ? ChatTextfield(widget.room.token, selfId: widget.selfId) + : SafeArea(child: ChatTextfield(widget.room.token, selfId: widget.selfId)), + ), + ], ), - ), - Container( - color: Theme.of(context).colorScheme.surface, - child: TalkNavigator.isSecondaryVisible(context) - ? ChatTextfield(widget.room.token, selfId: widget.selfId) - : SafeArea(child: ChatTextfield(widget.room.token, selfId: widget.selfId) - ), - ) - ], - ), ), ); }, diff --git a/lib/view/pages/talk/components/answerReference.dart b/lib/view/pages/talk/components/answerReference.dart index ce02745..6a39b09 100644 --- a/lib/view/pages/talk/components/answerReference.dart +++ b/lib/view/pages/talk/components/answerReference.dart @@ -16,8 +16,8 @@ class AnswerReference extends StatelessWidget { return DecoratedBox( decoration: BoxDecoration( color: referenceMessage.actorId == selfId - ? style.getSelfStyle(false).color!.withGreen(200).withOpacity(0.2) - : style.getRemoteStyle(false).color!.withWhite(200).withOpacity(0.2), + ? style.getSelfStyle(false).color!.withGreen(200).withValues(alpha: 0.2) + : style.getRemoteStyle(false).color!.withWhite(200).withValues(alpha: 0.2), borderRadius: const BorderRadius.all(Radius.circular(5)), border: Border(left: BorderSide( color: referenceMessage.actorId == selfId diff --git a/lib/view/pages/talk/components/chatBubble.dart b/lib/view/pages/talk/components/chatBubble.dart index 141c47e..c5f216e 100644 --- a/lib/view/pages/talk/components/chatBubble.dart +++ b/lib/view/pages/talk/components/chatBubble.dart @@ -8,7 +8,7 @@ import 'package:jiffy/jiffy.dart'; import 'package:open_filex/open_filex.dart'; import '../../../../api/marianumcloud/talk/getPoll/getPollState.dart'; import '../../../../extensions/text.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; import '../../../../api/marianumcloud/talk/deleteMessage/deleteMessage.dart'; @@ -17,7 +17,7 @@ import '../../../../api/marianumcloud/talk/deleteReactMessage/deleteReactMessage import '../../../../api/marianumcloud/talk/reactMessage/reactMessage.dart'; import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart'; import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; -import '../../../../model/chatList/chatProps.dart'; +import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../widget/debug/debugTile.dart'; import '../../../../widget/loadingSpinner.dart'; import '../../files/fileElement.dart'; @@ -189,9 +189,9 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM child: ListTile( leading: const Icon(Icons.reply_outlined), title: const Text('Antworten'), - onTap: () => { - Provider.of(context, listen: false).setReferenceMessageId(widget.bubbleData.id, context, widget.chatData.token), - Navigator.of(context).pop(), + onTap: () { + context.read().setReferenceMessageId(widget.bubbleData.id); + Navigator.of(context).pop(); }, ), ), @@ -236,7 +236,8 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM title: const Text('Nachricht löschen'), onTap: () { DeleteMessage(widget.chatData.token, widget.bubbleData.id).run().then((value) { - Provider.of(context, listen: false).run(); + if (!context.mounted) return; + context.read().refresh(); Navigator.of(context).pop(); }); }, @@ -294,7 +295,7 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM _position = const Offset(0, 0); }); if(widget.bubbleData.isReplyable && isAction) { - Provider.of(context, listen: false).setReferenceMessageId(widget.bubbleData.id, context, widget.chatData.token); + context.read().setReferenceMessageId(widget.bubbleData.id); } }, onLongPress: showOptionsDialog, @@ -341,6 +342,7 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM TextButton(onPressed: () { downloadCore?.then((value) { if(!value.isCancelled) value.cancel(); + if (!context.mounted) return; Navigator.of(context).pop(); }); setState(() { diff --git a/lib/view/pages/talk/components/chatBubbleStyles.dart b/lib/view/pages/talk/components/chatBubbleStyles.dart index 5cf527a..7451dd1 100644 --- a/lib/view/pages/talk/components/chatBubbleStyles.dart +++ b/lib/view/pages/talk/components/chatBubbleStyles.dart @@ -5,14 +5,16 @@ import '../../../../theming/appTheme.dart'; extension ColorExtensions on Color { Color invert() { - final r = 255 - red; - final g = 255 - green; - final b = 255 - blue; - - return Color.fromARGB((opacity * 255).round(), r, g, b); + final invertedR = 1.0 - r; + final invertedG = 1.0 - g; + final invertedB = 1.0 - b; + return Color.from(alpha: a, red: invertedR, green: invertedG, blue: invertedB); } - Color withWhite(int whiteValue) => Color.fromARGB(alpha, whiteValue, whiteValue, whiteValue); + Color withWhite(int whiteValue) { + final value = whiteValue / 255.0; + return Color.from(alpha: a, red: value, green: value, blue: value); + } } class ChatBubbleStyles { diff --git a/lib/view/pages/talk/components/chatTextfield.dart b/lib/view/pages/talk/components/chatTextfield.dart index 2da2f9a..a056984 100644 --- a/lib/view/pages/talk/components/chatTextfield.dart +++ b/lib/view/pages/talk/components/chatTextfield.dart @@ -1,17 +1,17 @@ - import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nextcloud/nextcloud.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:provider/provider.dart'; import '../../../../api/marianumcloud/files-sharing/fileSharingApi.dart'; import '../../../../api/marianumcloud/files-sharing/fileSharingApiParams.dart'; import '../../../../api/marianumcloud/talk/sendMessage/sendMessage.dart'; import '../../../../api/marianumcloud/talk/sendMessage/sendMessageParams.dart'; import '../../../../api/marianumcloud/webdav/webdavApi.dart'; -import '../../../../model/chatList/chatProps.dart'; -import '../../../../storage/base/settingsProvider.dart'; +import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../widget/filePick.dart'; import '../../../../widget/focusBehaviour.dart'; import '../../files/filesUploadDialog.dart'; @@ -20,6 +20,7 @@ import 'answerReference.dart'; class ChatTextfield extends StatefulWidget { final String sendToToken; final String? selfId; + const ChatTextfield(this.sendToToken, {this.selfId, super.key}); @override @@ -27,207 +28,214 @@ class ChatTextfield extends StatefulWidget { } class _ChatTextfieldState extends State { - late SettingsProvider settings; + late SettingsCubit settings; final TextEditingController _textBoxController = TextEditingController(); bool isLoading = false; - void _query() { - Provider.of(context, listen: false).run(); - } - void share(String shareFolder, List filePaths) { - for (var element in filePaths) { - var fileName = element.split(Platform.pathSeparator).last; + for (final element in filePaths) { + final fileName = element.split(Platform.pathSeparator).last; FileSharingApi().share(FileSharingApiParams( - shareType: 10, - shareWith: widget.sendToToken, - path: '$shareFolder/$fileName', - )).then((value) => _query()); + shareType: 10, + shareWith: widget.sendToToken, + path: '$shareFolder/$fileName', + )).then((_) { + if (mounted) context.read().refresh(); + }); } } Future mediaUpload(List? paths) async { if (paths == null) return; - var shareFolder = 'MarianumMobile'; - WebdavApi.webdav.then((webdav) { - webdav.mkcol(PathUri.parse('/$shareFolder')); - }); + const shareFolder = 'MarianumMobile'; + WebdavApi.webdav.then((webdav) => webdav.mkcol(PathUri.parse('/$shareFolder'))); + if (!mounted) return; pushScreen( context, withNavBar: false, screen: FilesUploadDialog( filePaths: paths, remotePath: shareFolder, - onUploadFinished: (uploadedFilePaths) { - share(shareFolder, uploadedFilePaths); - }, + onUploadFinished: (uploaded) => share(shareFolder, uploaded), uniqueNames: true, ), ); } - void setDraft(String text) { - if(text.isNotEmpty) { - settings.val(write: true).talkSettings.drafts[widget.sendToToken] = text; + void _setDraft(String text) { + final talkSettings = settings.val(write: true).talkSettings; + if (text.isNotEmpty) { + talkSettings.drafts[widget.sendToToken] = text; } else { - settings.val(write: true).talkSettings.drafts.removeWhere((key, value) => key == widget.sendToToken); + talkSettings.drafts.removeWhere((key, _) => key == widget.sendToToken); + } + } + + void _setDraftReply(int? messageId) { + final talkSettings = settings.val(write: true).talkSettings; + if (messageId != null) { + talkSettings.draftReplies[widget.sendToToken] = messageId; + } else { + talkSettings.draftReplies.removeWhere((key, _) => key == widget.sendToToken); } } @override void initState() { super.initState(); - settings = Provider.of(context, listen: false); - Provider.of(context, listen: false).unsafeInternalSetReferenceMessageId = - settings.val().talkSettings.draftReplies[widget.sendToToken]; + settings = context.read(); + final draftReply = settings.val().talkSettings.draftReplies[widget.sendToToken]; + if (draftReply != null) { + context.read().setReferenceMessageId(draftReply); + } } @override Widget build(BuildContext context) { _textBoxController.text = settings.val().talkSettings.drafts[widget.sendToToken] ?? ''; + final chatBloc = context.watch(); + final chatState = chatBloc.state.data; - return Stack( - children: [ - Align( - alignment: Alignment.bottomLeft, - child: Container( - padding: const EdgeInsets.only(left: 10, bottom: 3, top: 3, right: 10), - width: double.infinity, - child: Column( - children: [ - Consumer( - builder: (context, data, child) { - if(data.getReferenceMessageId != null) { - var referenceMessage = data.getChatResponse.sortByTimestamp().where((element) => element.id == data.getReferenceMessageId).last; - return Row( - children: [ - Expanded( - child: AnswerReference( - context: context, - referenceMessage: referenceMessage, - selfId: widget.selfId, - ), - ), - IconButton( - onPressed: () => data.setReferenceMessageId(null, context, widget.sendToToken), - icon: const Icon(Icons.close_outlined), - padding: const EdgeInsets.only(left: 0), - ), - ], - ); - } else { - return const SizedBox.shrink(); - } - }, - ), - Row( - children: [ - GestureDetector( - onTap: (){ - showDialog(context: context, builder: (context) => SimpleDialog( - children: [ - ListTile( - leading: const Icon(Icons.file_open), - title: const Text('Aus Dateien auswählen'), - onTap: () { - FilePick.documentPick().then(mediaUpload); - Navigator.of(context).pop(); - }, - ), - Visibility( - visible: !Platform.isIOS, - child: ListTile( - leading: const Icon(Icons.image), - title: const Text('Aus Gallerie auswählen'), - onTap: () { - FilePick.multipleGalleryPick().then((value) { - if(value != null) mediaUpload(value.map((e) => e.path).toList()); - }); - Navigator.of(context).pop(); - }, - ), - ), - ], - )); - }, - child: Material( - elevation: 5, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(30), - ), - child: Container( - height: 30, - width: 30, - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - borderRadius: BorderRadius.circular(30), - ), - child: const Icon(Icons.attach_file_outlined, color: Colors.white, size: 20, ), - ), - ) - ), - const SizedBox(width: 15), - Expanded( - child: TextField( - autocorrect: true, - textCapitalization: TextCapitalization.sentences, - controller: _textBoxController, - maxLines: 7, - minLines: 1, - decoration: const InputDecoration( - hintText: 'Nachricht schreiben...', - border: InputBorder.none, - ), - onChanged: (String text) { - if(text.trim().toLowerCase() == 'marbot marbot marbot') { - var newText = 'Roboter sind cool und so, aber Marbots sind besser!'; - _textBoxController.text = newText; - text = newText; - } - setDraft(text); - }, - onTapOutside: (PointerDownEvent event) => FocusBehaviour.textFieldTapOutside(context), - ), - ), - const SizedBox(width: 15), - FloatingActionButton( - mini: true, - onPressed: () { - if(_textBoxController.text.isEmpty) return; - if(isLoading) return; - - setState(() { - isLoading = true; - }); - SendMessage(widget.sendToToken, SendMessageParams( - _textBoxController.text, - replyTo: Provider.of(context, listen: false).getReferenceMessageId.toString() - )).run().then((value) { - _query(); - setState(() { - isLoading = false; - }); - _textBoxController.text = ''; - setDraft(''); - Provider.of(context, listen: false).setReferenceMessageId(null, context, widget.sendToToken); - }); - }, - backgroundColor: Theme.of(context).primaryColor, - elevation: 5, - child: isLoading - ? Container(padding: const EdgeInsets.all(10), child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) - : const Icon(Icons.send, color: Colors.white, size: 18), - ), - ], - ), - ], + Widget replyBanner = const SizedBox.shrink(); + if (chatState != null && chatState.referenceMessageId != null && chatState.chatResponse != null) { + try { + final referenceMessage = chatState.chatResponse!.sortByTimestamp().firstWhere( + (e) => e.id == chatState.referenceMessageId, + ); + replyBanner = Row( + children: [ + Expanded( + child: AnswerReference( + context: context, + referenceMessage: referenceMessage, + selfId: widget.selfId, + ), ), + IconButton( + 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 */} + } + return Stack(children: [ + Align( + alignment: Alignment.bottomLeft, + child: Container( + padding: const EdgeInsets.only(left: 10, bottom: 3, top: 3, right: 10), + width: double.infinity, + child: Column( + children: [ + replyBanner, + Row(children: [ + GestureDetector( + onTap: () { + showDialog(context: context, builder: (dialogCtx) => SimpleDialog(children: [ + ListTile( + leading: const Icon(Icons.file_open), + title: const Text('Aus Dateien auswählen'), + onTap: () { + FilePick.documentPick().then(mediaUpload); + Navigator.of(dialogCtx).pop(); + }, + ), + Visibility( + visible: !Platform.isIOS, + child: ListTile( + leading: const Icon(Icons.image), + title: const Text('Aus Gallerie auswählen'), + onTap: () { + FilePick.multipleGalleryPick().then((value) { + if (value != null) mediaUpload(value.map((e) => e.path).toList()); + }); + Navigator.of(dialogCtx).pop(); + }, + ), + ), + ])); + }, + child: Material( + elevation: 5, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)), + child: Container( + height: 30, + width: 30, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(30), + ), + child: const Icon(Icons.attach_file_outlined, color: Colors.white, size: 20), + ), + ), + ), + const SizedBox(width: 15), + Expanded( + child: TextField( + autocorrect: true, + textCapitalization: TextCapitalization.sentences, + controller: _textBoxController, + maxLines: 7, + minLines: 1, + decoration: const InputDecoration( + hintText: 'Nachricht schreiben...', + border: InputBorder.none, + ), + onChanged: (text) { + if (text.trim().toLowerCase() == 'marbot marbot marbot') { + const newText = 'Roboter sind cool und so, aber Marbots sind besser!'; + _textBoxController.text = newText; + text = newText; + } + _setDraft(text); + }, + onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context), + ), + ), + const SizedBox(width: 15), + FloatingActionButton( + mini: true, + onPressed: () { + if (_textBoxController.text.isEmpty || isLoading) return; + + setState(() => isLoading = true); + SendMessage( + widget.sendToToken, + SendMessageParams( + _textBoxController.text, + replyTo: chatBloc.state.data?.referenceMessageId?.toString(), + ), + ).run().then((_) { + if (!mounted) return; + chatBloc.refresh(); + setState(() => isLoading = false); + _textBoxController.text = ''; + _setDraft(''); + chatBloc.setReferenceMessageId(null); + _setDraftReply(null); + }); + }, + backgroundColor: Theme.of(context).primaryColor, + elevation: 5, + child: isLoading + ? Container( + padding: const EdgeInsets.all(10), + child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ) + : const Icon(Icons.send, color: Colors.white, size: 18), + ), + ]), + ], ), ), - ], - ); + ), + ]); } } diff --git a/lib/view/pages/talk/components/chatTile.dart b/lib/view/pages/talk/components/chatTile.dart index 7c313dc..53d20b4 100644 --- a/lib/view/pages/talk/components/chatTile.dart +++ b/lib/view/pages/talk/components/chatTile.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:jiffy/jiffy.dart'; -import 'package:provider/provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import '../../../../api/marianumcloud/talk/chat/richObjectStringProcessor.dart'; import '../../../../api/marianumcloud/talk/leaveRoom/leaveRoom.dart'; @@ -10,7 +9,9 @@ import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; import '../../../../api/marianumcloud/talk/setFavorite/setFavorite.dart'; import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarker.dart'; import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dart'; -import '../../../../model/chatList/chatProps.dart'; +import '../../../../model/accountData.dart'; +import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../../../../state/app/modules/chatList/bloc/chat_list_bloc.dart'; import '../../../../widget/confirmDialog.dart'; import '../../../../widget/debug/debugTile.dart'; import '../../../../widget/userAvatar.dart'; @@ -19,167 +20,177 @@ import '../talkNavigator.dart'; class ChatTile extends StatefulWidget { final GetRoomResponseObject data; - final void Function({bool renew}) query; final bool disableContextActions; final bool hasDraft; - const ChatTile({super.key, required this.data, required this.query, this.disableContextActions = false, this.hasDraft = false}); + const ChatTile({super.key, required this.data, this.disableContextActions = false, this.hasDraft = false}); @override State createState() => _ChatTileState(); } class _ChatTileState extends State { - late String selfUsername; + String? selfUsername; @override void initState() { super.initState(); - SharedPreferences.getInstance().then((value) => { - selfUsername = value.getString('username')! + AccountData().waitForPopulation().then((_) { + if (!mounted) return; + setState(() => selfUsername = AccountData().isPopulated() ? AccountData().getUsername() : null); }); } + void _refreshList() => context.read().refresh(); + void setCurrentAsRead() { SetReadMarker( - widget.data.token, - true, - setReadMarkerParams: SetReadMarkerParams( - lastReadMessage: widget.data.lastMessage.id - ) - ).run().then((value) => widget.query(renew: true)); + widget.data.token, + true, + setReadMarkerParams: SetReadMarkerParams(lastReadMessage: widget.data.lastMessage.id), + ).run().then((_) { + if (!mounted) return; + _refreshList(); + }); } @override - Widget build(BuildContext context) => Consumer(builder: (context, chatData, child) { - var isGroup = widget.data.type != GetRoomResponseObjectConversationType.oneToOne; - var circleAvatar = UserAvatar(id: isGroup ? widget.data.token : widget.data.name, isGroup: isGroup); + Widget build(BuildContext context) { + final chatBloc = context.watch(); + final isGroup = widget.data.type != GetRoomResponseObjectConversationType.oneToOne; + final circleAvatar = UserAvatar(id: isGroup ? widget.data.token : widget.data.name, isGroup: isGroup); + return ListTile( - style: ListTileStyle.list, - tileColor: chatData.currentToken() == widget.data.token && TalkNavigator.isSecondaryVisible(context) - ? Theme.of(context).primaryColor.withAlpha(100) - : null, - leading: Stack( + style: ListTileStyle.list, + tileColor: chatBloc.state.data?.currentToken == widget.data.token && TalkNavigator.isSecondaryVisible(context) + ? Theme.of(context).primaryColor.withAlpha(100) + : null, + leading: Stack( + children: [ + circleAvatar, + Visibility( + visible: widget.data.isFavorite, + child: Positioned( + right: 0, + bottom: 0, + child: Container( + padding: const EdgeInsets.all(1), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withAlpha(200), + borderRadius: BorderRadius.circular(90.0), + ), + child: const Icon(Icons.star, color: Colors.amberAccent, size: 15), + ), + ), + ) + ], + ), + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: Text(widget.data.displayName, overflow: TextOverflow.ellipsis)), + if (widget.hasDraft) ...[ + const SizedBox(width: 5), + const Icon(Icons.edit_outlined, size: 15), + ], + ], + ), + subtitle: Text( + '${Jiffy.parseFromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).fromNow()}: ' + '${RichObjectStringProcessor.parseToString(widget.data.lastMessage.message.replaceAll("\n", " "), widget.data.lastMessage.messageParameters)}', + overflow: TextOverflow.ellipsis, + ), + trailing: widget.data.unreadMessages <= 0 + ? null + : Container( + padding: const EdgeInsets.all(1), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(30), + ), + constraints: const BoxConstraints(minWidth: 20, minHeight: 20), + child: Text( + '${widget.data.unreadMessages}', + style: const TextStyle(color: Colors.white, fontSize: 15), + textAlign: TextAlign.center, + ), + ), + onTap: () { + if (selfUsername == null) return; + setCurrentAsRead(); + final view = ChatView(room: widget.data, selfId: selfUsername!, avatar: circleAvatar); + TalkNavigator.pushSplitView(context, view, overrideToSingleSubScreen: true); + context.read().setToken(widget.data.token); + }, + onLongPress: () { + if (widget.disableContextActions) return; + showDialog(context: context, builder: (dialogCtx) => SimpleDialog( children: [ - circleAvatar, Visibility( - visible: widget.data.isFavorite, - child: Positioned( - right: 0, - bottom: 0, - child: Container( - padding: const EdgeInsets.all(1), - decoration: BoxDecoration( - color: Theme.of(context).primaryColor.withAlpha(200), - borderRadius: BorderRadius.circular(90.0), - ), - child: const Icon(Icons.star, color: Colors.amberAccent, size: 15), - ), - ), - ) - ], - ), - title: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text(widget.data.displayName, overflow: TextOverflow.ellipsis), - ), - if(widget.hasDraft) ...[ - const SizedBox(width: 5), - const Icon(Icons.edit_outlined, size: 15), - ], - ], - ), - subtitle: Text("${Jiffy.parseFromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).fromNow()}: ${RichObjectStringProcessor.parseToString(widget.data.lastMessage.message.replaceAll("\n", " "), widget.data.lastMessage.messageParameters)}", overflow: TextOverflow.ellipsis), - trailing: widget.data.unreadMessages <= 0 - ? null - : Container( - padding: const EdgeInsets.all(1), - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - borderRadius: BorderRadius.circular(30), - ), - constraints: const BoxConstraints( - minWidth: 20, - minHeight: 20, - ), - child: Text( - '${widget.data.unreadMessages}', - style: const TextStyle( - color: Colors.white, - fontSize: 15, - ), - textAlign: TextAlign.center, - ), - ), - onTap: () async { - setCurrentAsRead(); - var view = ChatView(room: widget.data, selfId: selfUsername, avatar: circleAvatar); - TalkNavigator.pushSplitView(context, view, overrideToSingleSubScreen: true); - Provider.of(context, listen: false).setQueryToken(widget.data.token); - }, - onLongPress: () { - if(widget.disableContextActions) return; - showDialog(context: context, builder: (context) => SimpleDialog( - children: [ - Visibility( - visible: widget.data.unreadMessages > 0, - replacement: ListTile( - leading: const Icon(Icons.mark_chat_unread_outlined), - title: const Text('Als ungelesen markieren'), - onTap: () { - SetReadMarker(widget.data.token, false).run().then((value) => widget.query(renew: true)); - Navigator.of(context).pop(); - }, - ), - child: ListTile( - leading: const Icon(Icons.mark_chat_read_outlined), - title: const Text('Als gelesen markieren'), - onTap: () { - setCurrentAsRead(); - Navigator.of(context).pop(); - }, - ), - ), - Visibility( - visible: widget.data.isFavorite, - replacement: ListTile( - leading: const Icon(Icons.star_outline), - title: const Text('Zu Favoriten hinzufügen'), - onTap: () { - SetFavorite(widget.data.token, true).run().then((value) => widget.query(renew: true)); - Navigator.of(context).pop(); - }, - ), - child: ListTile( - leading: const Icon(Icons.stars_outlined), - title: const Text('Von Favoriten entfernen'), - onTap: () { - SetFavorite(widget.data.token, false).run().then((value) => widget.query(renew: true)); - Navigator.of(context).pop(); - }, - ), - ), - ListTile( - leading: const Icon(Icons.delete_outline), - title: const Text('Konversation verlassen'), + visible: widget.data.unreadMessages > 0, + replacement: ListTile( + leading: const Icon(Icons.mark_chat_unread_outlined), + title: const Text('Als ungelesen markieren'), onTap: () { - ConfirmDialog( - title: 'Chat verlassen', - content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.', - confirmButton: 'Löschen', - onConfirm: () { - LeaveRoom(widget.data.token).run().then((value) => widget.query(renew: true)); - Navigator.of(context).pop(); - }, - ).asDialog(context); + SetReadMarker(widget.data.token, false).run().then((_) { + if (mounted) _refreshList(); + }); + Navigator.of(dialogCtx).pop(); }, ), - DebugTile(context).jsonData(widget.data.toJson()), - ], - )); - }, - ); - }); + child: ListTile( + leading: const Icon(Icons.mark_chat_read_outlined), + title: const Text('Als gelesen markieren'), + onTap: () { + setCurrentAsRead(); + Navigator.of(dialogCtx).pop(); + }, + ), + ), + Visibility( + visible: widget.data.isFavorite, + replacement: ListTile( + leading: const Icon(Icons.star_outline), + title: const Text('Zu Favoriten hinzufügen'), + onTap: () { + SetFavorite(widget.data.token, true).run().then((_) { + if (mounted) _refreshList(); + }); + Navigator.of(dialogCtx).pop(); + }, + ), + child: ListTile( + leading: const Icon(Icons.stars_outlined), + title: const Text('Von Favoriten entfernen'), + onTap: () { + SetFavorite(widget.data.token, false).run().then((_) { + if (mounted) _refreshList(); + }); + Navigator.of(dialogCtx).pop(); + }, + ), + ), + ListTile( + leading: const Icon(Icons.delete_outline), + title: const Text('Konversation verlassen'), + onTap: () { + ConfirmDialog( + title: 'Chat verlassen', + content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.', + confirmButton: 'Löschen', + onConfirm: () { + LeaveRoom(widget.data.token).run().then((_) { + if (mounted) _refreshList(); + }); + Navigator.of(dialogCtx).pop(); + }, + ).asDialog(dialogCtx); + }, + ), + DebugTile(dialogCtx).jsonData(widget.data.toJson()), + ], + )); + }, + ); + } } diff --git a/lib/view/pages/talk/components/pollOptionsList.dart b/lib/view/pages/talk/components/pollOptionsList.dart index 30b3ab3..c7eb9ea 100644 --- a/lib/view/pages/talk/components/pollOptionsList.dart +++ b/lib/view/pages/talk/components/pollOptionsList.dart @@ -26,7 +26,7 @@ class _PollOptionsListState extends State { ? (widget.pollData.votes['option-$optionId'] as num?) ?? 0 : 0; var numVoters = widget.pollData.numVoters ?? 0; - double portion = numVoters == 0 ? 0 : (votes / numVoters); + final portion = numVoters == 0 ? 0.0 : (votes / numVoters); return ListTile( // enabled: false, diff --git a/lib/view/pages/talk/searchChat.dart b/lib/view/pages/talk/searchChat.dart index 3f39738..ebca04d 100644 --- a/lib/view/pages/talk/searchChat.dart +++ b/lib/view/pages/talk/searchChat.dart @@ -25,7 +25,7 @@ class SearchChat extends SearchDelegate { itemCount: items.length, itemBuilder: (context, index) { var item = items.elementAt(index); - return ChatTile(data: item, disableContextActions: true, query: ({bool renew = true}) {}); + return ChatTile(data: item, disableContextActions: true); }, ); } diff --git a/lib/view/pages/timetable/appointmenetComponent.dart b/lib/view/pages/timetable/appointmenetComponent.dart deleted file mode 100644 index 0f2d3e4..0000000 --- a/lib/view/pages/timetable/appointmenetComponent.dart +++ /dev/null @@ -1,92 +0,0 @@ - -import 'package:flutter/material.dart'; -import 'package:syncfusion_flutter_calendar/calendar.dart'; - -import 'CrossPainter.dart'; - -class AppointmentComponent extends StatefulWidget { - final CalendarAppointmentDetails details; - final bool crossedOut; - const AppointmentComponent({super.key, required this.details, this.crossedOut = false}); - - @override - State createState() => _AppointmentComponentState(); -} - -class _AppointmentComponentState extends State { - @override - Widget build(BuildContext context) { - final Appointment meeting = widget.details.appointments.first; - final appointmentHeight = widget.details.bounds.height; - - return Stack( - children: [ - Column( - children: [ - Container( - padding: const EdgeInsets.all(3), - height: appointmentHeight, - alignment: Alignment.topLeft, - decoration: BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: const BorderRadius.all(Radius.circular(5)), - color: meeting.color.withAlpha(meeting.endTime.isBefore(DateTime.now()) ? 100 : 255), - ), - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FittedBox( - fit: BoxFit.fitWidth, - child: Text( - meeting.subject, - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - softWrap: false, - ), - ), - FittedBox( - fit: BoxFit.fitWidth, - child: Text( - (meeting.location == null || meeting.location!.isEmpty ? ' ' : meeting.location!), - maxLines: 3, - overflow: TextOverflow.ellipsis, - softWrap: true, - style: const TextStyle( - color: Colors.white, - fontSize: 10, - ), - ), - ) - ], - ), - ), - ), - ], - ), - Visibility( - visible: widget.crossedOut, - child: Positioned.fill( - child: Container( - decoration: BoxDecoration( - border: Border.all( - width: 2, - color: Colors.red.withAlpha(200), - ), - borderRadius: const BorderRadius.all(Radius.circular(5)), - ), - child: CustomPaint( - painter: CrossPainter(), - ), - ) - ), - ), - ], - ); - } -} diff --git a/lib/view/pages/timetable/appointmentDetails.dart b/lib/view/pages/timetable/appointmentDetails.dart deleted file mode 100644 index 9adb671..0000000 --- a/lib/view/pages/timetable/appointmentDetails.dart +++ /dev/null @@ -1,226 +0,0 @@ - -import 'dart:async'; - -import 'package:bottom_sheet/bottom_sheet.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; -import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:provider/provider.dart'; -import 'package:rrule/rrule.dart'; -import 'package:syncfusion_flutter_calendar/calendar.dart'; - -import '../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; -import '../../../api/mhsl/customTimetableEvent/remove/removeCustomTimetableEvent.dart'; -import '../../../api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.dart'; -import '../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; -import '../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; -import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; -import '../../../model/timetable/timetableProps.dart'; -import '../../../widget/centeredLeading.dart'; -import '../../../widget/confirmDialog.dart'; -import '../../../widget/debug/debugTile.dart'; -import '../../../widget/unimplementedDialog.dart'; -import '../more/roomplan/roomplan.dart'; -import 'arbitraryAppointment.dart'; -import 'customTimetableEventEditDialog.dart'; - -class AppointmentDetails { - static String _getEventPrefix(String? code) { - if(code == 'cancelled') return 'Entfällt: '; - if(code == 'irregular') return 'Änderung: '; - return code ?? ''; - } - - static void show(BuildContext context, TimetableProps webuntisData, Appointment appointment) { - (appointment.id! as ArbitraryAppointment).handlers( - (webuntis) => _webuntis(context, webuntisData, appointment, webuntis), - (customData) => _custom(context, webuntisData, customData) - ); - } - - static void _bottomSheet( - BuildContext context, - Widget Function(BuildContext context) header, - SliverChildListDelegate Function(BuildContext context) body - ) { - showStickyFlexibleBottomSheet( - minHeight: 0, - initHeight: 0.4, - maxHeight: 0.7, - anchors: [0, 0.4, 0.7], - isSafeArea: true, - maxHeaderHeight: 100, - - context: context, - headerBuilder: (context, bottomSheetOffset) => header(context), - bodyBuilder: (context, bottomSheetOffset) => body(context) - ); - } - - static void _webuntis(BuildContext context, TimetableProps webuntisData, Appointment appointment, GetTimetableResponseObject timetableData) { - GetSubjectsResponseObject subject; - GetRoomsResponseObject room; - - try { - subject = webuntisData.getSubjectsResponse.result.firstWhere((subject) => subject.id == timetableData.su[0].id); - } catch(e) { - subject = GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true); - } - - try { - room = webuntisData.getRoomsResponse.result.firstWhere((room) => room.id == timetableData.ro[0].id); - } catch(e) { - room = GetRoomsResponseObject(0, '?', 'Unbekannt', true, '?'); - } - - _bottomSheet( - context, - (context) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text('${_getEventPrefix(timetableData.code)}${subject.alternateName}', textAlign: TextAlign.center, style: const TextStyle(fontSize: 25), overflow: TextOverflow.ellipsis), - Text(subject.longName), - Text("${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: "HH:mm")} - ${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: "HH:mm")}", style: const TextStyle(fontSize: 15)), - ], - ), - ), - - (context) => SliverChildListDelegate( - [ - const Divider(), - ListTile( - leading: const Icon(Icons.notifications_active), - title: Text("Status: ${timetableData.code != null ? "Geändert" : "Regulär"}"), - ), - ListTile( - leading: const Icon(Icons.room), - title: Text('Raum: ${room.name} (${room.longName})'), - trailing: IconButton( - icon: const Icon(Icons.house_outlined), - onPressed: () { - pushScreen(context, withNavBar: false, screen: const Roomplan()); - }, - ), - ), - ListTile( - leading: const Icon(Icons.person), - title: timetableData.te.isNotEmpty - ? Text("Lehrkraft: ${timetableData.te[0].name} ${timetableData.te[0].longname.isNotEmpty ? "(${timetableData.te[0].longname})" : ""}") - : const Text('?'), - trailing: Visibility( - visible: !kReleaseMode, - child: IconButton( - icon: const Icon(Icons.textsms_outlined), - onPressed: () { - UnimplementedDialog.show(context); - }, - ), - ), - ), - ListTile( - leading: const Icon(Icons.abc), - title: Text('Typ: ${timetableData.activityType}'), - ), - ListTile( - leading: const Icon(Icons.people), - title: Text("Klasse(n): ${timetableData.kl.map((e) => e.name).join(", ")}"), - ), - DebugTile(context).jsonData(timetableData.toJson()), - ], - ) - ); - } - - static Completer deleteCustomEvent(BuildContext context, CustomTimetableEvent appointment) { - var future = Completer(); - ConfirmDialog( - title: 'Termin löschen', - content: "Der ${appointment.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.", - confirmButton: 'Löschen', - onConfirm: () { - RemoveCustomTimetableEvent( - RemoveCustomTimetableEventParams( - appointment.id - ) - ).run().then((value) { - Provider.of(context, listen: false).run(renew: true); - future.complete(); - }); - }, - ).asDialog(context); - return future; - } - - static void _custom(BuildContext context, TimetableProps webuntisData, CustomTimetableEvent appointment) { - _bottomSheet( - context, - (context) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(appointment.title, style: const TextStyle(fontSize: 25, overflow: TextOverflow.ellipsis)), - Text("${Jiffy.parseFromDateTime(appointment.startDate).format(pattern: "HH:mm")} - ${Jiffy.parseFromDateTime(appointment.endDate).format(pattern: "HH:mm")}", style: const TextStyle(fontSize: 15)), - ], - ), - ), - (context) => SliverChildListDelegate( - [ - const Divider(), - Center( - child: Wrap( - children: [ - TextButton.icon( - onPressed: () { - Navigator.of(context).pop(); - showDialog( - context: context, - builder: (context) => CustomTimetableEventEditDialog(existingEvent: appointment), - ); - }, - label: const Text('Bearbeiten'), - icon: const Icon(Icons.edit_outlined), - ), - TextButton.icon( - onPressed: () { - deleteCustomEvent(context, appointment).future.then((value) => Navigator.of(context).pop()); - }, - label: const Text('Löschen'), - icon: const Icon(Icons.delete_outline), - ), - ], - ), - ), - const Divider(), - ListTile( - leading: const Icon(Icons.info_outline), - title: Text(appointment.description.isEmpty ? 'Keine Beschreibung' : appointment.description), - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.repeat_outlined)), - title: Text("Serie: ${appointment.rrule.isNotEmpty ? "Wiederholend" : "Einmailg"}"), - subtitle: FutureBuilder( - future: RruleL10nEn.create(), - builder: (context, snapshot) { - if(appointment.rrule.isEmpty) return const Text('Keine weiteren vorkomnisse'); - if(snapshot.data == null) return const Text('...'); - var rrule = RecurrenceRule.fromString(appointment.rrule); - if(!rrule.canFullyConvertToText) return const Text('Keine genauere Angabe möglich.'); - return Text(rrule.toText(l10n: snapshot.data!)); - }, - ) - ), - DebugTile(context).child( - ListTile( - leading: const CenteredLeading(Icon(Icons.rule)), - title: const Text('RRule'), - subtitle: Text(appointment.rrule.isEmpty ? 'Keine' : appointment.rrule), - ) - ), - DebugTile(context).jsonData(appointment.toJson()), - ] - ) - ); - } -} diff --git a/lib/view/pages/timetable/arbitraryAppointment.dart b/lib/view/pages/timetable/arbitraryAppointment.dart deleted file mode 100644 index 46c9b1b..0000000 --- a/lib/view/pages/timetable/arbitraryAppointment.dart +++ /dev/null @@ -1,19 +0,0 @@ - -import '../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; -import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; - -class ArbitraryAppointment { - GetTimetableResponseObject? webuntis; - CustomTimetableEvent? custom; - - ArbitraryAppointment({this.webuntis, this.custom}); - - bool hasWebuntis() => webuntis != null; - - bool hasCustom() => custom != null; - - void handlers(void Function(GetTimetableResponseObject webuntisData) webuntis, void Function(CustomTimetableEvent customData) custom) { - if(hasWebuntis()) webuntis(this.webuntis!); - if(hasCustom()) custom(this.custom!); - } -} diff --git a/lib/view/pages/timetable/customTimetableEventEditDialog.dart b/lib/view/pages/timetable/customTimetableEventEditDialog.dart deleted file mode 100644 index ba21525..0000000 --- a/lib/view/pages/timetable/customTimetableEventEditDialog.dart +++ /dev/null @@ -1,233 +0,0 @@ - -import 'dart:developer'; - -import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; -import '../../../extensions/dateTime.dart'; -import 'package:provider/provider.dart'; -import 'package:rrule_generator/rrule_generator.dart'; -import 'package:time_range_picker/time_range_picker.dart'; - -import '../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart'; -import '../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.dart'; -import '../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; -import '../../../api/mhsl/customTimetableEvent/update/updateCustomTimetableEvent.dart'; -import '../../../api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.dart'; -import '../../../model/accountData.dart'; -import '../../../model/timetable/timetableProps.dart'; -import '../../../widget/focusBehaviour.dart'; -import '../../../widget/infoDialog.dart'; -import 'customTimetableColors.dart'; - -class CustomTimetableEventEditDialog extends StatefulWidget { - final CustomTimetableEvent? existingEvent; - const CustomTimetableEventEditDialog({this.existingEvent, super.key}); - - @override - State createState() => _AddCustomTimetableEventDialogState(); -} - -class _AddCustomTimetableEventDialogState extends State { - late DateTime _date = widget.existingEvent?.startDate ?? DateTime.now(); - late TimeOfDay _startTime = widget.existingEvent?.startDate.toTimeOfDay() ?? const TimeOfDay(hour: 08, minute: 00); - late TimeOfDay _endTime = widget.existingEvent?.endDate.toTimeOfDay() ?? const TimeOfDay(hour: 09, minute: 30); - late final TextEditingController _eventName = TextEditingController(text: widget.existingEvent?.title); - late final TextEditingController _eventDescription = TextEditingController(text: widget.existingEvent?.description); - late String _recurringRule = widget.existingEvent?.rrule ?? ''; - late CustomTimetableColors _customTimetableColor = CustomTimetableColors.values.firstWhere( - (element) => element.name == widget.existingEvent?.color, - orElse: () => TimetableColors.defaultColor - ); - - late bool isEditingExisting = widget.existingEvent != null; - - bool validate() { - if(_eventName.text.isEmpty) return false; - return true; - } - - void fetchTimetable() { - Provider.of(context, listen: false).run(renew: true); - } - - @override - Widget build(BuildContext context) => AlertDialog( - insetPadding: const EdgeInsets.all(20), - contentPadding: const EdgeInsets.all(10), - title: const Text('Termin hinzufügen'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: TextField( - controller: _eventName, - autofocus: true, - decoration: const InputDecoration( - labelText: 'Terminname', - border: OutlineInputBorder() - ), - onTapOutside: (PointerDownEvent event) => FocusBehaviour.textFieldTapOutside(context), - ), - ), - ListTile( - title: TextField( - controller: _eventDescription, - maxLines: 2, - minLines: 2, - decoration: const InputDecoration( - labelText: 'Beschreibung', - border: OutlineInputBorder() - ), - onTapOutside: (PointerDownEvent event) => FocusBehaviour.textFieldTapOutside(context), - ), - ), - const Divider(), - ListTile( - leading: const Icon(Icons.date_range_outlined), - title: Text(Jiffy.parseFromDateTime(_date).yMMMd), - subtitle: const Text('Datum'), - onTap: () async { - final pickedDate = await showDatePicker( - context: context, - initialDate: _date, - firstDate: DateTime.now().subtract(const Duration(days: 30)), - lastDate: DateTime.now().add(const Duration(days: 30)), - ); - if (pickedDate != null && pickedDate != _date) { - setState(() { - _date = pickedDate; - }); - } - }, - ), - ListTile( - leading: const Icon(Icons.access_time_outlined), - title: Text('${_startTime.format(context).toString()} - ${_endTime.format(context).toString()}'), - subtitle: const Text('Zeitraum'), - onTap: () async { - TimeRange timeRange = await showTimeRangePicker( - context: context, - start: _startTime, - end: _endTime, - disabledTime: TimeRange(startTime: const TimeOfDay(hour: 16, minute: 30), endTime: const TimeOfDay(hour: 08, minute: 00)), - disabledColor: Colors.grey, - paintingStyle: PaintingStyle.fill, - interval: const Duration(minutes: 5), - fromText: 'Beginnend', - toText: 'Endend', - strokeColor: Theme.of(context).colorScheme.secondary, - minDuration: const Duration(minutes: 15), - selectedColor: Theme.of(context).primaryColor, - ticks: 24, - ); - - setState(() { - _startTime = timeRange.startTime; - _endTime = timeRange.endTime; - }); - }, - ), - const Divider(), - ListTile( - leading: const Icon(Icons.color_lens_outlined), - title: const Text('Farbgebung'), - trailing: DropdownButton( - value: _customTimetableColor, - icon: const Icon(Icons.arrow_drop_down), - items: CustomTimetableColors.values.map((e) => DropdownMenuItem( - value: e, - enabled: e != _customTimetableColor, - child: Row( - children: [ - Icon(Icons.circle, color: TimetableColors.getDisplayOptions(e).color), - const SizedBox(width: 10), - Text(TimetableColors.getDisplayOptions(e).displayName), - ], - ), - )).toList(), - onChanged: (e) { - setState(() { - _customTimetableColor = e!; - }); - }, - ), - ), - const Divider(), - RRuleGenerator( - config: RRuleGeneratorConfig( - headerEnabled: true, - weekdayBackgroundColor: Theme.of(context).colorScheme.secondary, - weekdaySelectedBackgroundColor: Theme.of(context).primaryColor, - weekdayColor: Colors.black, - ), - initialRRule: _recurringRule, - textDelegate: const GermanRRuleTextDelegate(), - onChange: (String newValue) { - log('Rule: $newValue'); - setState(() { - _recurringRule = newValue; - }); - }, - ) - ], - ), - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('Abbrechen'), - ), - TextButton( - onPressed: () { - if(!validate()) return; - - var editedEvent = CustomTimetableEvent( - id: '', - title: _eventName.text, - description: _eventDescription.text, - startDate: _date.withTime(_startTime), - endDate: _date.withTime(_endTime), - color: _customTimetableColor.name, - rrule: _recurringRule, - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - ); - - if(!isEditingExisting) { - AddCustomTimetableEvent( - AddCustomTimetableEventParams( - AccountData().getUserSecret(), - editedEvent - ) - ).run().then((value) { - Navigator.of(context).pop(); - fetchTimetable(); - }) - .catchError((error, stack) { - InfoDialog.show(context, error.toString()); - }); - } else { - UpdateCustomTimetableEvent( - UpdateCustomTimetableEventParams( - widget.existingEvent?.id ?? '', - editedEvent - ) - ).run().then((value) { - Navigator.of(context).pop(); - fetchTimetable(); - }) - .catchError((error, stack) { - InfoDialog.show(context, error.toString()); - }); - } - - - }, - child: Text(isEditingExisting ? 'Speichern' : 'Erstellen'), - ), - ], - ); -} diff --git a/lib/view/pages/timetable/customTimetableColors.dart b/lib/view/pages/timetable/custom_events/custom_event_colors.dart similarity index 72% rename from lib/view/pages/timetable/customTimetableColors.dart rename to lib/view/pages/timetable/custom_events/custom_event_colors.dart index 1b65838..a4540fe 100644 --- a/lib/view/pages/timetable/customTimetableColors.dart +++ b/lib/view/pages/timetable/custom_events/custom_event_colors.dart @@ -1,35 +1,30 @@ import 'package:flutter/material.dart'; -import '../../../theming/darkAppTheme.dart'; +import '../../../../theming/darkAppTheme.dart'; -enum CustomTimetableColors { - orange, - red, - green, - blue -} +enum CustomTimetableColors { orange, red, green, blue } class TimetableColors { static const CustomTimetableColors defaultColor = CustomTimetableColors.orange; static ColorModeDisplay getDisplayOptions(CustomTimetableColors color) { - switch(color) { + switch (color) { case CustomTimetableColors.green: return ColorModeDisplay(color: Colors.green, displayName: 'Grün'); - case CustomTimetableColors.blue: return ColorModeDisplay(color: Colors.blue, displayName: 'Blau'); - case CustomTimetableColors.orange: return ColorModeDisplay(color: Colors.orange.shade800, displayName: 'Orange'); - case CustomTimetableColors.red: return ColorModeDisplay(color: DarkAppTheme.marianumRed, displayName: 'Rot'); - } } - static Color getColorFromString(String color) => getDisplayOptions(CustomTimetableColors.values.firstWhere((element) => element.name == color, orElse: () => TimetableColors.defaultColor)).color; + static Color getColorFromString(String color) => + getDisplayOptions(CustomTimetableColors.values.firstWhere( + (e) => e.name == color, + orElse: () => defaultColor, + )).color; } class ColorModeDisplay { 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 new file mode 100644 index 0000000..b383134 --- /dev/null +++ b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart @@ -0,0 +1,190 @@ +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:rrule_generator/rrule_generator.dart'; +import 'package:time_range_picker/time_range_picker.dart'; + +import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../extensions/dateTime.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../../widget/focusBehaviour.dart'; +import '../../../../widget/infoDialog.dart'; +import 'custom_event_colors.dart'; + +class CustomEventEditDialog extends StatefulWidget { + final CustomTimetableEvent? existingEvent; + + const CustomEventEditDialog({this.existingEvent, super.key}); + + @override + State createState() => _CustomEventEditDialogState(); +} + +class _CustomEventEditDialogState extends State { + late DateTime _date = widget.existingEvent?.startDate ?? DateTime.now(); + late TimeOfDay _startTime = widget.existingEvent?.startDate.toTimeOfDay() ?? const TimeOfDay(hour: 8, minute: 0); + late TimeOfDay _endTime = widget.existingEvent?.endDate.toTimeOfDay() ?? const TimeOfDay(hour: 9, minute: 30); + late final TextEditingController _name = TextEditingController(text: widget.existingEvent?.title); + late final TextEditingController _description = TextEditingController(text: widget.existingEvent?.description); + late String _rrule = widget.existingEvent?.rrule ?? ''; + late CustomTimetableColors _color = CustomTimetableColors.values.firstWhere( + (e) => e.name == widget.existingEvent?.color, + orElse: () => TimetableColors.defaultColor, + ); + + bool get _isEditing => widget.existingEvent != null; + + bool _validate() => _name.text.isNotEmpty; + + void _save() { + if (!_validate()) return; + + final edited = CustomTimetableEvent( + id: widget.existingEvent?.id ?? '', + title: _name.text, + description: _description.text, + startDate: _date.withTime(_startTime), + endDate: _date.withTime(_endTime), + color: _color.name, + rrule: _rrule, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + final bloc = context.read(); + final future = _isEditing + ? bloc.updateCustomEvent(widget.existingEvent!.id, edited) + : bloc.addCustomEvent(edited); + + future.then((_) { + if (!mounted) return; + Navigator.of(context).pop(); + }).catchError((Object error) { + if (!mounted) return; + InfoDialog.show(context, error.toString()); + }); + } + + Future _pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _date, + firstDate: DateTime.now().subtract(const Duration(days: 30)), + lastDate: DateTime.now().add(const Duration(days: 30)), + ); + if (picked != null && picked != _date) setState(() => _date = picked); + } + + Future _pickTimeRange() async { + final range = await showTimeRangePicker( + context: context, + start: _startTime, + end: _endTime, + disabledTime: TimeRange( + startTime: const TimeOfDay(hour: 16, minute: 30), + endTime: const TimeOfDay(hour: 8, minute: 0), + ), + disabledColor: Colors.grey, + paintingStyle: PaintingStyle.fill, + interval: const Duration(minutes: 5), + fromText: 'Beginnend', + toText: 'Endend', + strokeColor: Theme.of(context).colorScheme.secondary, + minDuration: const Duration(minutes: 15), + selectedColor: Theme.of(context).primaryColor, + ticks: 24, + ); + setState(() { + _startTime = range.startTime; + _endTime = range.endTime; + }); + } + + @override + Widget build(BuildContext context) => AlertDialog( + insetPadding: const EdgeInsets.all(20), + contentPadding: const EdgeInsets.all(10), + title: Text(_isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: TextField( + controller: _name, + autofocus: true, + decoration: const InputDecoration(labelText: 'Terminname', border: OutlineInputBorder()), + onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context), + ), + ), + ListTile( + title: TextField( + controller: _description, + maxLines: 2, + minLines: 2, + decoration: const InputDecoration(labelText: 'Beschreibung', border: OutlineInputBorder()), + onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context), + ), + ), + const Divider(), + ListTile( + leading: const Icon(Icons.date_range_outlined), + title: Text(Jiffy.parseFromDateTime(_date).yMMMd), + subtitle: const Text('Datum'), + onTap: _pickDate, + ), + ListTile( + leading: const Icon(Icons.access_time_outlined), + title: Text('${_startTime.format(context)} - ${_endTime.format(context)}'), + subtitle: const Text('Zeitraum'), + onTap: _pickTimeRange, + ), + const Divider(), + ListTile( + leading: const Icon(Icons.color_lens_outlined), + title: const Text('Farbgebung'), + trailing: DropdownButton( + value: _color, + icon: const Icon(Icons.arrow_drop_down), + items: CustomTimetableColors.values + .map((e) => DropdownMenuItem( + value: e, + enabled: e != _color, + child: Row( + children: [ + Icon(Icons.circle, color: TimetableColors.getDisplayOptions(e).color), + const SizedBox(width: 10), + Text(TimetableColors.getDisplayOptions(e).displayName), + ], + ), + )) + .toList(), + onChanged: (e) => setState(() => _color = e!), + ), + ), + const Divider(), + RRuleGenerator( + config: RRuleGeneratorConfig( + headerEnabled: true, + weekdayBackgroundColor: Theme.of(context).colorScheme.secondary, + weekdaySelectedBackgroundColor: Theme.of(context).primaryColor, + weekdayColor: Colors.black, + ), + initialRRule: _rrule, + textDelegate: const GermanRRuleTextDelegate(), + onChange: (newValue) { + log('Rule: $newValue'); + setState(() => _rrule = newValue); + }, + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')), + TextButton(onPressed: _save, child: Text(_isEditing ? 'Speichern' : 'Erstellen')), + ], + ); +} diff --git a/lib/view/pages/timetable/custom_events/custom_events_view.dart b/lib/view/pages/timetable/custom_events/custom_events_view.dart new file mode 100644 index 0000000..34af737 --- /dev/null +++ b/lib/view/pages/timetable/custom_events/custom_events_view.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; + +import '../../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; +import '../../../../widget/centeredLeading.dart'; +import '../../../../widget/placeholderView.dart'; +import '../details/delete_custom_event.dart'; +import 'custom_event_edit_dialog.dart'; + +class CustomEventsView extends StatelessWidget { + const CustomEventsView({super.key}); + + void _openCreateDialog(BuildContext context) { + showDialog( + context: context, + builder: (_) => const CustomEventEditDialog(), + barrierDismissible: false, + ); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('Eigene Termine'), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () => _openCreateDialog(context), + ), + ], + ), + body: LoadableStateConsumer( + child: (state, _) { + final events = state.customEvents?.events ?? const []; + + if (events.isEmpty) { + return PlaceholderView( + icon: Icons.calendar_today_outlined, + text: 'Keine Einträge vorhanden', + button: TextButton( + onPressed: () => _openCreateDialog(context), + child: const Text('Termin erstellen'), + ), + ); + } + + return ListView( + children: events.map((e) => ListTile( + title: Text(e.title), + subtitle: Text( + '${e.rrule.isNotEmpty ? "wiederholend, " : ""}' + 'beginnend ${Jiffy.parseFromDateTime(e.startDate).fromNow()}', + ), + leading: CenteredLeading(Icon(e.rrule.isEmpty ? Icons.event_outlined : Icons.event_repeat_outlined)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: () => showDialog( + context: context, + builder: (_) => CustomEventEditDialog(existingEvent: e), + ), + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => showDeleteCustomEventDialog(context, e), + ), + ], + ), + )).toList(), + ); + }, + ), + ); +} diff --git a/lib/view/pages/timetable/data/arbitrary_appointment.dart b/lib/view/pages/timetable/data/arbitrary_appointment.dart new file mode 100644 index 0000000..d9bb91a --- /dev/null +++ b/lib/view/pages/timetable/data/arbitrary_appointment.dart @@ -0,0 +1,24 @@ +import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; + +sealed class ArbitraryAppointment { + const ArbitraryAppointment(); + + T when({ + required T Function(GetTimetableResponseObject lesson) webuntis, + required T Function(CustomTimetableEvent event) custom, + }) => switch (this) { + WebuntisAppointment(:final lesson) => webuntis(lesson), + CustomAppointment(:final event) => custom(event), + }; +} + +class WebuntisAppointment extends ArbitraryAppointment { + final GetTimetableResponseObject lesson; + const WebuntisAppointment(this.lesson); +} + +class CustomAppointment extends ArbitraryAppointment { + final CustomTimetableEvent event; + const CustomAppointment(this.event); +} diff --git a/lib/view/pages/timetable/data/lesson_color.dart b/lib/view/pages/timetable/data/lesson_color.dart new file mode 100644 index 0000000..18477a4 --- /dev/null +++ b/lib/view/pages/timetable/data/lesson_color.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'lesson_status.dart'; + +class LessonColor { + static const Color cancelled = Color(0xff000000); + static const Color irregular = Color(0xff8F19B3); + static const Color teacherChanged = Color(0xFF29639B); + static const Color parseFallback = Color(0xff404040); + + static Color forStatus(LessonStatus status, ColorScheme scheme) { + switch (status) { + case LessonStatus.cancelled: + return cancelled; + case LessonStatus.irregular: + return irregular; + case LessonStatus.teacherChanged: + return teacherChanged; + case LessonStatus.past: + case LessonStatus.regular: + return scheme.primary; + case LessonStatus.ongoing: + return Color.from( + alpha: scheme.primary.a, + red: 200 / 255, + green: scheme.primary.g, + blue: scheme.primary.b, + ); + } + } +} diff --git a/lib/view/pages/timetable/data/lesson_status.dart b/lib/view/pages/timetable/data/lesson_status.dart new file mode 100644 index 0000000..933f3cb --- /dev/null +++ b/lib/view/pages/timetable/data/lesson_status.dart @@ -0,0 +1,21 @@ +import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; + +enum LessonStatus { + cancelled, + irregular, + teacherChanged, + past, + ongoing, + regular, +} + +class LessonStatusClassifier { + static LessonStatus classify(GetTimetableResponseObject lesson, DateTime startTime, DateTime endTime, DateTime now) { + if (lesson.code == 'cancelled') return LessonStatus.cancelled; + if (lesson.code == 'irregular' || (lesson.te.isNotEmpty && lesson.te.first.id == 0)) return LessonStatus.irregular; + if (lesson.te.any((t) => t.orgname != null)) return LessonStatus.teacherChanged; + if (endTime.isBefore(now)) return LessonStatus.past; + if (startTime.isBefore(now) && endTime.isAfter(now)) return LessonStatus.ongoing; + return LessonStatus.regular; + } +} diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart new file mode 100644 index 0000000..8360c8c --- /dev/null +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -0,0 +1,136 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; +import '../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; +import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../../storage/timetable/timetableSettings.dart'; +import '../../../../storage/timetable/timetable_name_mode.dart'; +import '../custom_events/custom_event_colors.dart'; +import 'arbitrary_appointment.dart'; +import 'lesson_color.dart'; +import 'lesson_status.dart'; +import 'webuntis_time.dart'; + +class TimetableAppointmentFactory { + final List lessons; + final List customEvents; + final GetRoomsResponse rooms; + final GetSubjectsResponse subjects; + final TimetableSettings settings; + final ColorScheme colorScheme; + final DateTime now; + + TimetableAppointmentFactory({ + required this.lessons, + required this.customEvents, + required this.rooms, + required this.subjects, + required this.settings, + required this.colorScheme, + required this.now, + }); + + List build() { + final source = settings.connectDoubleLessons ? _mergeAdjacentLessons(lessons) : lessons; + return [ + ...source.map(_lessonToAppointment), + ...customEvents.map(_customEventToAppointment), + ]; + } + + Appointment _lessonToAppointment(GetTimetableResponseObject lesson) { + try { + final startTime = WebuntisTime.parse(lesson.date, lesson.startTime); + final endTime = WebuntisTime.parse(lesson.date, lesson.endTime); + final status = LessonStatusClassifier.classify(lesson, startTime, endTime, now); + + return Appointment( + id: WebuntisAppointment(lesson), + startTime: startTime, + endTime: endTime, + subject: _subjectName(lesson), + location: _locationLabel(lesson), + notes: lesson.activityType, + color: LessonColor.forStatus(status, colorScheme), + ); + } catch (_) { + return Appointment( + id: WebuntisAppointment(lesson), + startTime: WebuntisTime.parse(lesson.date, lesson.startTime), + endTime: WebuntisTime.parse(lesson.date, lesson.endTime), + subject: 'Änderung', + notes: lesson.info, + location: 'Unbekannt', + color: LessonColor.parseFallback, + startTimeZone: '', + endTimeZone: '', + ); + } + } + + Appointment _customEventToAppointment(CustomTimetableEvent event) => Appointment( + id: CustomAppointment(event), + startTime: event.startDate, + endTime: event.endDate, + location: event.description, + subject: event.title, + recurrenceRule: event.rrule, + color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name), + startTimeZone: '', + endTimeZone: '', + ); + + String _subjectName(GetTimetableResponseObject lesson) { + final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id); + if (subject == null) return 'Unbekannt'; + return switch (settings.timetableNameMode) { + TimetableNameMode.name => subject.name, + TimetableNameMode.longName => subject.longName, + TimetableNameMode.alternateName => subject.alternateName, + }; + } + + String _locationLabel(GetTimetableResponseObject lesson) { + final roomName = rooms.result.firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id)?.name ?? 'Unbekannt'; + final teacherName = lesson.te.firstOrNull?.longname ?? 'Unbekannt'; + return '$roomName\n$teacherName'; + } + + // Pure: returns a new list, does not mutate input. + static List _mergeAdjacentLessons( + List input, { + Duration maxGap = const Duration(minutes: 5), + }) { + if (input.isEmpty) return const []; + + final sorted = [...input]..sort((a, b) => + WebuntisTime.parse(a.date, a.startTime).compareTo(WebuntisTime.parse(b.date, b.startTime))); + + final merged = [sorted.first]; + for (var i = 1; i < sorted.length; i++) { + final previous = merged.last; + final current = sorted[i]; + if (_canMerge(previous, current, maxGap)) { + previous.endTime = current.endTime; + } else { + merged.add(current); + } + } + return merged; + } + + static bool _canMerge(GetTimetableResponseObject a, GetTimetableResponseObject b, Duration maxGap) { + final aSubject = a.su.firstOrNull?.id; + final bSubject = b.su.firstOrNull?.id; + if (aSubject == null || bSubject == null || aSubject != bSubject) return false; + if (a.ro.firstOrNull?.id != b.ro.firstOrNull?.id) return false; + if (a.te.firstOrNull?.id != b.te.firstOrNull?.id) return false; + if (a.code != b.code) return false; + + final gap = WebuntisTime.parse(b.date, b.startTime).difference(WebuntisTime.parse(a.date, a.endTime)); + return gap <= maxGap; + } +} diff --git a/lib/view/pages/timetable/data/webuntis_time.dart b/lib/view/pages/timetable/data/webuntis_time.dart new file mode 100644 index 0000000..bd9b6fa --- /dev/null +++ b/lib/view/pages/timetable/data/webuntis_time.dart @@ -0,0 +1,14 @@ +import 'package:intl/intl.dart'; + +class WebuntisTime { + static final DateFormat _dateFormat = DateFormat('yyyyMMdd'); + + static DateTime parse(int date, int time) { + final timeString = time.toString().padLeft(4, '0'); + return DateTime.parse('$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}'); + } + + static int formatDate(DateTime date) => int.parse(_dateFormat.format(date)); + + static String dateKey(DateTime date) => _dateFormat.format(date); +} diff --git a/lib/view/pages/timetable/details/_bottom_sheet.dart b/lib/view/pages/timetable/details/_bottom_sheet.dart new file mode 100644 index 0000000..c50f3f0 --- /dev/null +++ b/lib/view/pages/timetable/details/_bottom_sheet.dart @@ -0,0 +1,20 @@ +import 'package:bottom_sheet/bottom_sheet.dart'; +import 'package:flutter/material.dart'; + +void showAppointmentBottomSheet( + BuildContext context, { + required Widget Function(BuildContext context) header, + required SliverChildListDelegate Function(BuildContext context) body, +}) { + showStickyFlexibleBottomSheet( + minHeight: 0, + initHeight: 0.4, + maxHeight: 0.7, + anchors: [0, 0.4, 0.7], + isSafeArea: true, + maxHeaderHeight: 100, + context: context, + headerBuilder: (context, _) => header(context), + bodyBuilder: (context, _) => body(context), + ); +} diff --git a/lib/view/pages/timetable/details/appointment_details_dispatcher.dart b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart new file mode 100644 index 0000000..4a6ae76 --- /dev/null +++ b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../data/arbitrary_appointment.dart'; +import 'custom_event_sheet.dart'; +import 'webuntis_lesson_sheet.dart'; + +class AppointmentDetailsDispatcher { + static void show(BuildContext context, TimetableBloc bloc, Appointment appointment) { + final id = appointment.id; + if (id is! ArbitraryAppointment) return; + + id.when( + webuntis: (lesson) => WebuntisLessonSheet.show(context, bloc, appointment, lesson), + custom: (event) => CustomEventSheet.show(context, event), + ); + } +} diff --git a/lib/view/pages/timetable/details/custom_event_sheet.dart b/lib/view/pages/timetable/details/custom_event_sheet.dart new file mode 100644 index 0000000..9f11099 --- /dev/null +++ b/lib/view/pages/timetable/details/custom_event_sheet.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:rrule/rrule.dart'; + +import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../widget/centeredLeading.dart'; +import '../../../../widget/debug/debugTile.dart'; +import '../custom_events/custom_event_edit_dialog.dart'; +import '_bottom_sheet.dart'; +import 'delete_custom_event.dart'; + +class CustomEventSheet { + static void show(BuildContext context, CustomTimetableEvent event) { + showAppointmentBottomSheet( + context, + header: (_) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(event.title, style: const TextStyle(fontSize: 25, overflow: TextOverflow.ellipsis)), + Text( + '${Jiffy.parseFromDateTime(event.startDate).format(pattern: 'HH:mm')} - ' + '${Jiffy.parseFromDateTime(event.endDate).format(pattern: 'HH:mm')}', + style: const TextStyle(fontSize: 15), + ), + ], + ), + ), + body: (sheetCtx) => SliverChildListDelegate([ + const Divider(), + Center( + child: Wrap( + children: [ + TextButton.icon( + onPressed: () { + Navigator.of(sheetCtx).pop(); + showDialog( + context: context, + builder: (_) => CustomEventEditDialog(existingEvent: event), + ); + }, + label: const Text('Bearbeiten'), + icon: const Icon(Icons.edit_outlined), + ), + TextButton.icon( + onPressed: () { + showDeleteCustomEventDialog(context, event).future.then((_) { + if (!sheetCtx.mounted) return; + Navigator.of(sheetCtx).pop(); + }); + }, + label: const Text('Löschen'), + icon: const Icon(Icons.delete_outline), + ), + ], + ), + ), + const Divider(), + ListTile( + leading: const Icon(Icons.info_outline), + title: Text(event.description.isEmpty ? 'Keine Beschreibung' : event.description), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.repeat_outlined)), + title: Text('Serie: ${event.rrule.isNotEmpty ? "Wiederholend" : "Einmalig"}'), + subtitle: FutureBuilder( + future: RruleL10nEn.create(), + builder: (_, snapshot) { + if (event.rrule.isEmpty) return const Text('Keine weiteren Vorkommnisse'); + if (snapshot.data == null) return const Text('...'); + final rrule = RecurrenceRule.fromString(event.rrule); + if (!rrule.canFullyConvertToText) return const Text('Keine genauere Angabe möglich.'); + return Text(rrule.toText(l10n: snapshot.data!)); + }, + ), + ), + DebugTile(sheetCtx).child( + ListTile( + leading: const CenteredLeading(Icon(Icons.rule)), + title: const Text('RRule'), + subtitle: Text(event.rrule.isEmpty ? 'Keine' : event.rrule), + ), + ), + DebugTile(sheetCtx).jsonData(event.toJson()), + ]), + ); + } +} diff --git a/lib/view/pages/timetable/details/delete_custom_event.dart b/lib/view/pages/timetable/details/delete_custom_event.dart new file mode 100644 index 0000000..ad362d5 --- /dev/null +++ b/lib/view/pages/timetable/details/delete_custom_event.dart @@ -0,0 +1,24 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../../widget/confirmDialog.dart'; + +Completer showDeleteCustomEventDialog(BuildContext context, CustomTimetableEvent event) { + final completer = Completer(); + final bloc = context.read(); + ConfirmDialog( + title: 'Termin löschen', + content: 'Der ${event.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.', + confirmButton: 'Löschen', + onConfirm: () { + bloc.removeCustomEvent(event.id).then(completer.complete).onError((Object error, StackTrace stack) { + completer.completeError(error, stack); + }); + }, + ).asDialog(context); + return completer; +} diff --git a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart new file mode 100644 index 0000000..1fb0bd7 --- /dev/null +++ b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart @@ -0,0 +1,110 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; +import '../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; +import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; +import '../../../../widget/debug/debugTile.dart'; +import '../../../../widget/unimplementedDialog.dart'; +import '../../more/roomplan/roomplan.dart'; +import '_bottom_sheet.dart'; + +class WebuntisLessonSheet { + static void show(BuildContext context, TimetableBloc bloc, Appointment appointment, GetTimetableResponseObject lesson) { + final state = bloc.state.data; + if (state == null) return; + + final subject = _resolveSubject(state, lesson); + final room = _resolveRoom(state, lesson); + + showAppointmentBottomSheet( + context, + header: (_) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${_codePrefix(lesson.code)}${subject.alternateName}', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 25), + overflow: TextOverflow.ellipsis, + ), + Text(subject.longName), + Text( + '${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - ' + '${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}', + style: const TextStyle(fontSize: 15), + ), + ], + ), + ), + body: (_) => SliverChildListDelegate([ + const Divider(), + ListTile( + leading: const Icon(Icons.notifications_active), + title: Text('Status: ${lesson.code != null ? "Geändert" : "Regulär"}'), + ), + ListTile( + leading: const Icon(Icons.room), + title: Text('Raum: ${room.name} (${room.longName})'), + trailing: IconButton( + icon: const Icon(Icons.house_outlined), + onPressed: () => pushScreen(context, withNavBar: false, screen: const Roomplan()), + ), + ), + ListTile( + leading: const Icon(Icons.person), + title: lesson.te.isNotEmpty + ? Text( + 'Lehrkraft: ${lesson.te[0].name}' + '${lesson.te[0].longname.isNotEmpty ? " (${lesson.te[0].longname})" : ""}', + ) + : const Text('?'), + trailing: Visibility( + visible: !kReleaseMode, + child: IconButton( + icon: const Icon(Icons.textsms_outlined), + onPressed: () => UnimplementedDialog.show(context), + ), + ), + ), + ListTile( + leading: const Icon(Icons.abc), + title: Text('Typ: ${lesson.activityType}'), + ), + ListTile( + leading: const Icon(Icons.people), + title: Text('Klasse(n): ${lesson.kl.map((e) => e.name).join(", ")}'), + ), + DebugTile(context).jsonData(lesson.toJson()), + ]), + ); + } + + static String _codePrefix(String? code) { + if (code == 'cancelled') return 'Entfällt: '; + if (code == 'irregular') return 'Änderung: '; + return code ?? ''; + } + + static GetSubjectsResponseObject _resolveSubject(TimetableState state, GetTimetableResponseObject lesson) { + try { + return state.subjects!.result.firstWhere((s) => s.id == lesson.su[0].id); + } catch (_) { + return GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true); + } + } + + static GetRoomsResponseObject _resolveRoom(TimetableState state, GetTimetableResponseObject lesson) { + try { + return state.rooms!.result.firstWhere((r) => r.id == lesson.ro[0].id); + } catch (_) { + return GetRoomsResponseObject(0, '?', 'Unbekannt', true, '?'); + } + } +} diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index 208551c..cfcac4a 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -1,26 +1,25 @@ import 'dart:async'; -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import '../../../extensions/dateTime.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; -import '../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; -import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; -import '../../../model/timetable/timetableProps.dart'; -import '../../../storage/base/settingsProvider.dart'; -import '../../../widget/loadingSpinner.dart'; -import '../../../widget/placeholderView.dart'; -import 'appointmenetComponent.dart'; -import 'appointmentDetails.dart'; -import 'arbitraryAppointment.dart'; -import 'customTimetableColors.dart'; -import 'customTimetableEventEditDialog.dart'; -import 'timeRegionComponent.dart'; -import 'timetableEvents.dart'; -import 'timetableNameMode.dart'; -import 'viewCustomTimetableEvents.dart'; +import '../../../extensions/dateTime.dart'; +import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; +import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../state/app/modules/timetable/bloc/timetable_state.dart'; +import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import 'custom_events/custom_event_edit_dialog.dart'; +import 'custom_events/custom_events_view.dart'; +import 'data/arbitrary_appointment.dart'; +import 'data/timetable_appointment_factory.dart'; +import 'details/appointment_details_dispatcher.dart'; +import 'widgets/appointment_tile.dart'; +import 'widgets/lesson_appointment_source.dart'; +import 'widgets/special_regions_builder.dart'; +import 'widgets/time_region_tile.dart'; + +enum _CalendarAction { addEvent, viewEvents } class Timetable extends StatefulWidget { const Timetable({super.key}); @@ -29,362 +28,152 @@ class Timetable extends StatefulWidget { State createState() => _TimetableState(); } -enum CalendarActions { addEvent, viewEvents } - class _TimetableState extends State { - CalendarController controller = CalendarController(); - late Timer updateTimings; - late final SettingsProvider settings; + final CalendarController _controller = CalendarController(); + late Timer _highlightTicker; + + LessonAppointmentSource? _cachedSource; + int? _lastDataVersion; @override void initState() { - settings = Provider.of(context, listen: false); - - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - Provider.of(context, listen: false).run(); - }); - - controller.displayDate = DateTime.now().add(const Duration(days: 2)); - - updateTimings = Timer.periodic(const Duration(seconds: 30), (Timer t) => setState((){})); - super.initState(); + _controller.displayDate = _initialDisplayDate(); + + _highlightTicker = Timer.periodic(const Duration(seconds: 30), (_) { + if (mounted) setState(() => _cachedSource = null); + }); } - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Stunden & Vertretungsplan'), - actions: [ - IconButton( - icon: const Icon(Icons.home_outlined), - onPressed: () { - controller.displayDate = DateTime.now().add(const Duration(days: 2)); - } - ), - PopupMenuButton( - icon: const Icon(Icons.edit_calendar_outlined), - itemBuilder: (context) => CalendarActions.values.map( - (e) { - String title; - Icon icon; - switch(e) { - case CalendarActions.addEvent: - title = 'Kalendereintrag hinzufügen'; - icon = const Icon(Icons.add); - case CalendarActions.viewEvents: - title = 'Kalendereinträge anzeigen'; - icon = const Icon(Icons.perm_contact_calendar_outlined); - } - return PopupMenuItem( - value: e, - child: ListTile( - title: Text(title), - leading: icon, - ) - ); - } - ).toList(), - onSelected: (value) { - switch(value) { - case CalendarActions.addEvent: - showDialog( - context: context, - builder: (context) => const CustomTimetableEventEditDialog(), - barrierDismissible: false, - ); - case CalendarActions.viewEvents: - Navigator.of(context).push(MaterialPageRoute(builder: (context) => const ViewCustomTimetableEvents())); - } - }, - ) - ], - ), - body: Consumer( - builder: (context, value, child) { - - if(value.hasError) { - return PlaceholderView( - icon: Icons.calendar_month, - text: 'Webuntis error: ${value.error.toString()}', - button: TextButton( - child: const Text('Neu laden'), - onPressed: () { - controller.displayDate = DateTime.now().add(const Duration(days: 2)); - Provider.of(context, listen: false).resetWeek(); - }, - ), - ); - } - - if(value.primaryLoading()) return const LoadingSpinner(); - - var holidays = value.getHolidaysResponse; - - return RefreshIndicator( - child: SfCalendar( - timeZone: 'W. Europe Standard Time', - view: CalendarView.workWeek, - dataSource: _buildTableEvents(value), - - maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday), - minDate: DateTime.now().subtract(const Duration (days: 14)).nextWeekday(DateTime.sunday), - - controller: controller, - - onViewChanged: (ViewChangedDetails details) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - Provider.of(context, listen: false).updateWeek(details.visibleDates.first, details.visibleDates.last); - }); - }, - - onTap: (calendarTapDetails) { - if(calendarTapDetails.appointments == null) return; - Appointment tapped = calendarTapDetails.appointments!.first; - AppointmentDetails.show(context, value, tapped); - }, - - firstDayOfWeek: DateTime.monday, - specialRegions: _buildSpecialTimeRegions(holidays), - timeSlotViewSettings: const TimeSlotViewSettings( - startHour: 07.5, - endHour: 16.5, - timeInterval: Duration(minutes: 30), - timeFormat: 'HH:mm', - dayFormat: 'EE', - timeIntervalHeight: 40, - ), - - timeRegionBuilder: (BuildContext context, TimeRegionDetails timeRegionDetails) => TimeRegionComponent(details: timeRegionDetails), - appointmentBuilder: (BuildContext context, CalendarAppointmentDetails details) => AppointmentComponent( - details: details, - crossedOut: _isCrossedOut(details) - ), - - headerHeight: 0, - selectionDecoration: const BoxDecoration(), - - allowAppointmentResize: false, - allowDragAndDrop: false, - allowViewNavigation: false, - ), - onRefresh: () async { - Provider.of(context, listen: false).run(renew: true); - return Future.delayed(const Duration(seconds: 3)); - } - ); - }, - ), - ); - @override void dispose() { - updateTimings.cancel(); + _highlightTicker.cancel(); super.dispose(); } - List _buildSpecialTimeRegions(GetHolidaysResponse holidays) { - var lastMonday = DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.monday); - var firstBreak = lastMonday.copyWith(hour: 10, minute: 15); - var secondBreak = lastMonday.copyWith(hour: 13, minute: 50); + DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2)); - var holidayList = holidays.result.map((holiday) { - var startDay = _parseWebuntisTimestamp(holiday.startDate, 0); - var dayCount = _parseWebuntisTimestamp(holiday.endDate, 0) - .difference(startDay) - .inDays; - var days = List.generate(dayCount, (index) => startDay.add(Duration(days: index))); - - return days.map((holidayDay) => TimeRegion( - startTime: holidayDay.copyWith(hour: 07, minute: 55), - endTime: holidayDay.copyWith(hour: 16, minute: 30), - text: 'holiday:${holiday.name}', - color: Theme - .of(context) - .disabledColor - .withAlpha(50), - iconData: Icons.holiday_village_outlined - )); - }).expand((e) => e); - - bool isInHoliday(DateTime time) => holidayList.any((element) => element.startTime.isSameDay(time)); - - return [ - ...holidayList, - - if(!isInHoliday(firstBreak)) - TimeRegion( - startTime: firstBreak, - endTime: firstBreak.add(const Duration(minutes: 20)), - recurrenceRule: 'FREQ=DAILY;INTERVAL=1', - text: 'centerIcon', - color: Theme.of(context).primaryColor.withAlpha(50), - iconData: Icons.restaurant - ), - - if(!isInHoliday(secondBreak)) - TimeRegion( - startTime: secondBreak, - endTime: secondBreak.add(const Duration(minutes: 15)), - recurrenceRule: 'FREQ=DAILY;INTERVAL=1', - text: 'centerIcon', - color: Theme.of(context).primaryColor.withAlpha(50), - iconData: Icons.restaurant - ), - ]; + void _jumpToToday() { + _controller.displayDate = _initialDisplayDate(); } - List _removeDuplicates(TimetableProps data, Duration maxTimeBetweenDouble) { - - var timetableList = data.getTimetableResponse.result.toList(); - - if(timetableList.isEmpty) return timetableList; - - timetableList.sort((a, b) => _parseWebuntisTimestamp(a.date, a.startTime).compareTo(_parseWebuntisTimestamp(b.date, b.startTime))); - - var previousElement = timetableList.first; - for(var i = 1; i < timetableList.length; i++) { - var currentElement = timetableList.elementAt(i); - - bool isSameLesson() { - var currentSubjectId = currentElement.su.firstOrNull?.id; - var previousSubjectId = previousElement.su.firstOrNull?.id; - - if(currentSubjectId == null || previousSubjectId == null || currentSubjectId != previousSubjectId) return false; - - var currentRoomId = currentElement.ro.firstOrNull?.id; - var previousRoomId = previousElement.ro.firstOrNull?.id; - - if(currentRoomId != previousRoomId) return false; - - var currentTeacherId = currentElement.te.firstOrNull?.id; - var previousTeacherId = previousElement.te.firstOrNull?.id; - - if(currentTeacherId != previousTeacherId) return false; - - var currentStatusCode = currentElement.code; - var previousStatusCode = previousElement.code; - - if(currentStatusCode != previousStatusCode) return false; - - return true; - } - - bool isNotSeparated() => _parseWebuntisTimestamp(previousElement.date, previousElement.endTime).add(maxTimeBetweenDouble) - .isSameOrAfter(_parseWebuntisTimestamp(currentElement.date, currentElement.startTime)); - - if(isSameLesson() && isNotSeparated()) { - previousElement.endTime = currentElement.endTime; - timetableList.remove(currentElement); - i--; - } else { - previousElement = currentElement; - } - } - - return timetableList; - } - - TimetableEvents _buildTableEvents(TimetableProps data) { - - var timetableList = data.getTimetableResponse.result.toList(); - - if(settings.val().timetableSettings.connectDoubleLessons) { - timetableList = _removeDuplicates(data, const Duration(minutes: 5)); - } - - var appointments = timetableList.map((element) { - - var rooms = data.getRoomsResponse; - var subjects = data.getSubjectsResponse; - - try { - var startTime = _parseWebuntisTimestamp(element.date, element.startTime); - var endTime = _parseWebuntisTimestamp(element.date, element.endTime); - - var subject = subjects.result.firstWhereOrNull((subject) => subject.id == element.su.firstOrNull?.id); - var subjectName = 'Unbekannt'; - if(subject != null) { - subjectName = { - TimetableNameMode.name: subject.name, - TimetableNameMode.longName: subject.longName, - TimetableNameMode.alternateName: subject.alternateName, - }[settings.val().timetableSettings.timetableNameMode]!; - } - - return Appointment( - id: ArbitraryAppointment(webuntis: element), - startTime: startTime, - endTime: endTime, - subject: subjectName, - location: '' - '${rooms.result.firstWhereOrNull((room) => room.id == element.ro.firstOrNull?.id)?.name ?? 'Unbekannt'}' - '\n' - '${element.te.firstOrNull?.longname ?? 'Unbekannt'}', - notes: element.activityType, - color: _getEventColor(element, startTime, endTime), + void _onAction(_CalendarAction action) { + switch (action) { + case _CalendarAction.addEvent: + showDialog( + context: context, + builder: (_) => const CustomEventEditDialog(), + barrierDismissible: false, ); - } catch(e) { - var endTime = _parseWebuntisTimestamp(element.date, element.endTime); - return Appointment( - id: ArbitraryAppointment(webuntis: element), - startTime: _parseWebuntisTimestamp(element.date, element.startTime), - endTime: endTime, - subject: 'Änderung', - notes: element.info, - location: 'Unbekannt', - color: const Color(0xff404040), - startTimeZone: '', - endTimeZone: '', - ); - } - }).toList(); - - appointments.addAll(data.getCustomTimetableEventResponse.events.map((customEvent) => Appointment( - id: ArbitraryAppointment(custom: customEvent), - startTime: customEvent.startDate, - endTime: customEvent.endDate, - location: customEvent.description, - subject: customEvent.title, - recurrenceRule: customEvent.rrule, - color: TimetableColors.getColorFromString(customEvent.color ?? TimetableColors.defaultColor.name), - startTimeZone: '', - endTimeZone: '', - ))); - - return TimetableEvents(appointments); - } - - DateTime _parseWebuntisTimestamp(int date, int time) { - var timeString = time.toString().padLeft(4, '0'); - return DateTime.parse('$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}'); - } - - Color _getEventColor(GetTimetableResponseObject webuntisElement, DateTime startTime, DateTime endTime) { - // Cancelled - if(webuntisElement.code == 'cancelled') return const Color(0xff000000); - - // Any changes or no teacher at this element - if(webuntisElement.code == 'irregular' || webuntisElement.te.first.id == 0) return const Color(0xff8F19B3); - - // Teacher has changed - if(webuntisElement.te.any((element) => element.orgname != null)) return const Color(0xFF29639B); - - // Event was in the past - if(endTime.isBefore(DateTime.now())) return Theme.of(context).primaryColor; - - // Event takes currently place - if(endTime.isAfter(DateTime.now()) && startTime.isBefore(DateTime.now())) return Theme.of(context).primaryColor.withRed(200); - - // Fallback - return Theme.of(context).primaryColor; - } - - bool _isCrossedOut(CalendarAppointmentDetails calendarEntry) { - var appointment = calendarEntry.appointments.first.id as ArbitraryAppointment; - if(appointment.hasWebuntis()) { - return appointment.webuntis!.code == 'cancelled'; + case _CalendarAction.viewEvents: + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const CustomEventsView())); } + } + + LessonAppointmentSource _appointmentSource(TimetableState state) { + if (_cachedSource != null && _lastDataVersion == state.dataVersion) { + return _cachedSource!; + } + _lastDataVersion = state.dataVersion; + + final settings = context.read(); + final appointments = TimetableAppointmentFactory( + lessons: state.getAllKnownLessons().toList(), + customEvents: state.customEvents?.events ?? const [], + rooms: state.rooms!, + subjects: state.subjects!, + settings: settings.val().timetableSettings, + colorScheme: Theme.of(context).colorScheme, + now: DateTime.now(), + ).build(); + + return _cachedSource = LessonAppointmentSource(appointments); + } + + @override + Widget build(BuildContext context) { + final bloc = context.read(); + return Scaffold( + appBar: AppBar( + title: const Text('Stunden & Vertretungsplan'), + actions: [ + IconButton(icon: const Icon(Icons.home_outlined), onPressed: _jumpToToday), + PopupMenuButton<_CalendarAction>( + 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), + ), + ), + ], + ), + ], + ), + body: LoadableStateConsumer( + child: (state, _) => _calendar(state, bloc), + ), + ); + } + + Widget _calendar(TimetableState state, TimetableBloc bloc) { + if (!state.hasReferenceData) return const SizedBox.shrink(); + + return SfCalendar( + timeZone: 'W. Europe Standard Time', + view: CalendarView.workWeek, + dataSource: _appointmentSource(state), + maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday), + minDate: DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.sunday), + controller: _controller, + onViewChanged: (details) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + bloc.changeWeek(details.visibleDates.first, details.visibleDates.last); + }); + }, + onTap: (tap) { + if (tap.appointments == null || tap.appointments!.isEmpty) return; + AppointmentDetailsDispatcher.show(context, bloc, tap.appointments!.first); + }, + firstDayOfWeek: DateTime.monday, + specialRegions: SpecialRegionsBuilder( + holidays: state.schoolHolidays!, + colorScheme: Theme.of(context).colorScheme, + disabledColor: Theme.of(context).disabledColor, + ).build(), + timeSlotViewSettings: const TimeSlotViewSettings( + startHour: 7.5, + endHour: 16.5, + timeInterval: Duration(minutes: 30), + timeFormat: 'HH:mm', + dayFormat: 'EE', + timeIntervalHeight: 40, + ), + timeRegionBuilder: (_, details) => TimeRegionTile(details: details), + appointmentBuilder: (_, details) => AppointmentTile( + details: details, + crossedOut: _isCrossedOut(details), + ), + headerHeight: 0, + selectionDecoration: const BoxDecoration(), + allowAppointmentResize: false, + allowDragAndDrop: false, + allowViewNavigation: false, + ); + } + + bool _isCrossedOut(CalendarAppointmentDetails details) { + final appointment = details.appointments.first; + final id = appointment.id; + if (id is WebuntisAppointment) return id.lesson.code == 'cancelled'; return false; } } diff --git a/lib/view/pages/timetable/timetableEvents.dart b/lib/view/pages/timetable/timetableEvents.dart deleted file mode 100644 index 8450df7..0000000 --- a/lib/view/pages/timetable/timetableEvents.dart +++ /dev/null @@ -1,8 +0,0 @@ - -import 'package:syncfusion_flutter_calendar/calendar.dart'; - -class TimetableEvents extends CalendarDataSource { - TimetableEvents(List source) { - appointments = source; - } -} diff --git a/lib/view/pages/timetable/viewCustomTimetableEvents.dart b/lib/view/pages/timetable/viewCustomTimetableEvents.dart deleted file mode 100644 index f089184..0000000 --- a/lib/view/pages/timetable/viewCustomTimetableEvents.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; -import 'package:provider/provider.dart'; - -import '../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart'; -import '../../../model/timetable/timetableProps.dart'; -import '../../../widget/centeredLeading.dart'; -import '../../../widget/loadingSpinner.dart'; -import '../../../widget/placeholderView.dart'; -import 'appointmentDetails.dart'; -import 'customTimetableEventEditDialog.dart'; - -class ViewCustomTimetableEvents extends StatefulWidget { - const ViewCustomTimetableEvents({super.key}); - - @override - State createState() => _ViewCustomTimetableEventsState(); -} - -class _ViewCustomTimetableEventsState extends State { - late Future events; - - @override - void initState() { - super.initState(); - } - - _openCreateDialog() { - showDialog( - context: context, - builder: (context) => const CustomTimetableEventEditDialog(), - barrierDismissible: false, - ); - } - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Eigene Termine'), - actions: [ - IconButton( - icon: const Icon(Icons.add), - onPressed: _openCreateDialog, - ) - ], - ), - body: Consumer(builder: (context, value, child) { - if(value.primaryLoading()) return const LoadingSpinner(); - - var listView = ListView( - children: value.getCustomTimetableEventResponse.events.map((e) => ListTile( - title: Text(e.title), - subtitle: Text("${e.rrule.isNotEmpty ? "wiederholdend, " : ""}beginnend ${Jiffy.parseFromDateTime(e.startDate).fromNow()}"), - leading: CenteredLeading(Icon(e.rrule.isEmpty ? Icons.event_outlined : Icons.event_repeat_outlined)), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit_outlined), - onPressed: () { - showDialog(context: context, builder: (context) => CustomTimetableEventEditDialog(existingEvent: e)); - }, - ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: () { - AppointmentDetails.deleteCustomEvent(context, e); - }, - ) - ], - ), - )).toList(), - ); - - var placeholder = PlaceholderView( - icon: Icons.calendar_today_outlined, - text: 'Keine Einträge vorhanden', - button: TextButton( - onPressed: _openCreateDialog, - child: const Text('Termin erstellen'), - ), - ); - - return RefreshIndicator( - onRefresh: () { - Provider.of(context, listen: false).run(renew: true); - return Future.delayed(const Duration(seconds: 3)); - }, - child: value.getCustomTimetableEventResponse.events.isEmpty - ? placeholder - : listView - ); - }), - ); -} diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart new file mode 100644 index 0000000..15fcea1 --- /dev/null +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import 'cross_painter.dart'; + +class AppointmentTile extends StatelessWidget { + final CalendarAppointmentDetails details; + final bool crossedOut; + + const AppointmentTile({super.key, required this.details, this.crossedOut = false}); + + @override + Widget build(BuildContext context) { + final Appointment meeting = details.appointments.first; + final isPast = meeting.endTime.isBefore(DateTime.now()); + final color = meeting.color.withAlpha(isPast ? 100 : 255); + + return Stack( + children: [ + Container( + padding: const EdgeInsets.all(3), + height: details.bounds.height, + alignment: Alignment.topLeft, + decoration: BoxDecoration( + shape: BoxShape.rectangle, + borderRadius: const BorderRadius.all(Radius.circular(5)), + color: color, + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FittedBox( + fit: BoxFit.fitWidth, + child: Text( + meeting.subject, + style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500), + maxLines: 1, + softWrap: false, + ), + ), + FittedBox( + fit: BoxFit.fitWidth, + child: Text( + meeting.location?.isNotEmpty == true ? meeting.location! : ' ', + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 10), + ), + ), + ], + ), + ), + ), + if (crossedOut) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + border: Border.all(width: 2, color: Colors.red.withAlpha(200)), + borderRadius: const BorderRadius.all(Radius.circular(5)), + ), + child: CustomPaint(painter: CrossPainter()), + ), + ), + ], + ); + } +} diff --git a/lib/view/pages/timetable/CrossPainter.dart b/lib/view/pages/timetable/widgets/cross_painter.dart similarity index 100% rename from lib/view/pages/timetable/CrossPainter.dart rename to lib/view/pages/timetable/widgets/cross_painter.dart diff --git a/lib/view/pages/timetable/widgets/lesson_appointment_source.dart b/lib/view/pages/timetable/widgets/lesson_appointment_source.dart new file mode 100644 index 0000000..9269184 --- /dev/null +++ b/lib/view/pages/timetable/widgets/lesson_appointment_source.dart @@ -0,0 +1,7 @@ +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +class LessonAppointmentSource extends CalendarDataSource { + LessonAppointmentSource(List source) { + appointments = source; + } +} diff --git a/lib/view/pages/timetable/widgets/special_regions_builder.dart b/lib/view/pages/timetable/widgets/special_regions_builder.dart new file mode 100644 index 0000000..0ab0115 --- /dev/null +++ b/lib/view/pages/timetable/widgets/special_regions_builder.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; +import '../../../../extensions/dateTime.dart'; +import '../data/webuntis_time.dart'; +import 'time_region_tile.dart'; + +class SpecialRegionsBuilder { + final GetHolidaysResponse holidays; + final ColorScheme colorScheme; + final Color disabledColor; + + SpecialRegionsBuilder({ + required this.holidays, + required this.colorScheme, + required this.disabledColor, + }); + + List build() { + final lastMonday = DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.monday); + final firstBreak = lastMonday.copyWith(hour: 10, minute: 15); + final secondBreak = lastMonday.copyWith(hour: 13, minute: 50); + + final holidayRegions = _buildHolidayRegions().toList(); + bool isInHoliday(DateTime time) => holidayRegions.any((region) => region.startTime.isSameDay(time)); + + return [ + ...holidayRegions, + if (!isInHoliday(firstBreak)) _breakRegion(firstBreak, const Duration(minutes: 20)), + if (!isInHoliday(secondBreak)) _breakRegion(secondBreak, const Duration(minutes: 15)), + ]; + } + + Iterable _buildHolidayRegions() => holidays.result.expand((holiday) { + final startDay = WebuntisTime.parse(holiday.startDate, 0); + final dayCount = WebuntisTime.parse(holiday.endDate, 0).difference(startDay).inDays; + final days = List.generate(dayCount, (i) => startDay.add(Duration(days: i))); + return days.map((day) => TimeRegion( + startTime: day.copyWith(hour: 7, minute: 55), + endTime: day.copyWith(hour: 16, minute: 30), + text: '$kTimeRegionHolidayPrefix${holiday.name}', + color: disabledColor.withAlpha(50), + iconData: Icons.holiday_village_outlined, + )); + }); + + TimeRegion _breakRegion(DateTime start, Duration duration) => TimeRegion( + startTime: start, + endTime: start.add(duration), + recurrenceRule: 'FREQ=DAILY;INTERVAL=1', + text: kTimeRegionCenterIcon, + color: colorScheme.primary.withAlpha(50), + iconData: Icons.restaurant, + ); +} diff --git a/lib/view/pages/timetable/timeRegionComponent.dart b/lib/view/pages/timetable/widgets/time_region_tile.dart similarity index 62% rename from lib/view/pages/timetable/timeRegionComponent.dart rename to lib/view/pages/timetable/widgets/time_region_tile.dart index 01d3d7e..10c074b 100644 --- a/lib/view/pages/timetable/timeRegionComponent.dart +++ b/lib/view/pages/timetable/widgets/time_region_tile.dart @@ -1,31 +1,28 @@ import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; -class TimeRegionComponent extends StatefulWidget { +const String kTimeRegionCenterIcon = 'centerIcon'; +const String kTimeRegionHolidayPrefix = 'holiday:'; + +class TimeRegionTile extends StatelessWidget { final TimeRegionDetails details; - const TimeRegionComponent({super.key, required this.details}); - @override - State createState() => _TimeRegionComponentState(); -} + const TimeRegionTile({super.key, required this.details}); -class _TimeRegionComponentState extends State { @override Widget build(BuildContext context) { - var text = widget.details.region.text!; - var color = widget.details.region.color; + final text = details.region.text ?? ''; + final color = details.region.color; - if (text == 'centerIcon') { + if (text == kTimeRegionCenterIcon) { return Container( color: color, alignment: Alignment.center, - child: Icon( - widget.details.region.iconData, - size: 17, - color: Theme.of(context).primaryColor, - ), + child: Icon(details.region.iconData, size: 17, color: Theme.of(context).primaryColor), ); - } else if(text.startsWith('holiday')) { + } + + if (text.startsWith(kTimeRegionHolidayPrefix)) { return Container( color: color, alignment: Alignment.center, @@ -38,7 +35,7 @@ class _TimeRegionComponentState extends State { RotatedBox( quarterTurns: 1, child: Text( - text.split(':').last, + text.substring(kTimeRegionHolidayPrefix.length), maxLines: 1, style: const TextStyle( fontWeight: FontWeight.bold, diff --git a/lib/view/settings/defaultSettings.dart b/lib/view/settings/defaultSettings.dart index 7b99b25..9348a6d 100644 --- a/lib/view/settings/defaultSettings.dart +++ b/lib/view/settings/defaultSettings.dart @@ -13,7 +13,7 @@ import '../../storage/notification/notificationSettings.dart'; import '../../storage/talk/talkSettings.dart'; import '../../storage/timetable/timetableSettings.dart'; import '../pages/files/files.dart'; -import '../pages/timetable/timetableNameMode.dart'; +import '../../storage/timetable/timetable_name_mode.dart'; class DefaultSettings { static Settings get() => Settings( diff --git a/lib/view/settings/devToolsSettings.dart b/lib/view/settings/devToolsSettings.dart index 4dc8877..147861f 100644 --- a/lib/view/settings/devToolsSettings.dart +++ b/lib/view/settings/devToolsSettings.dart @@ -2,16 +2,16 @@ import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../storage/base/settingsProvider.dart'; +import '../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../widget/centeredLeading.dart'; import '../../widget/confirmDialog.dart'; import '../../widget/debug/cacheView.dart'; import '../../widget/debug/jsonViewer.dart'; class DevToolsSettings extends StatefulWidget { - final SettingsProvider settings; + final SettingsCubit settings; const DevToolsSettings({required this.settings, super.key}); @override @@ -83,7 +83,7 @@ class _DevToolsSettingsState extends State { content: 'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten sind nicht betroffen.', confirmButton: 'Unwiederruflich Löschen', onConfirm: () { - Provider.of(context, listen: false).reset(); + context.read().reset(); }, ).asDialog(context); }, diff --git a/lib/view/settings/settings.dart b/lib/view/settings/settings.dart index 0d3f58d..85cfbc5 100644 --- a/lib/view/settings/settings.dart +++ b/lib/view/settings/settings.dart @@ -3,18 +3,19 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../model/accountData.dart'; -import '../../model/timetable/timetableProps.dart'; import '../../notification/notifyUpdater.dart'; -import '../../storage/base/settingsProvider.dart'; +import '../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../storage/base/settings.dart' as model; import '../../theming/appTheme.dart'; import '../../widget/centeredLeading.dart'; import '../../widget/confirmDialog.dart'; import '../../widget/debug/cacheView.dart'; -import '../pages/timetable/timetableNameMode.dart'; +import '../../storage/timetable/timetable_name_mode.dart'; import 'defaultSettings.dart'; import 'devToolsSettings.dart'; import 'privacyInfo.dart'; @@ -36,7 +37,9 @@ class _SettingsState extends State { bool developerMode = false; @override - Widget build(BuildContext context) => Consumer(builder: (context, settings, child) => Scaffold( + Widget build(BuildContext context) => BlocBuilder(builder: (context, _) { + final settings = context.read(); + return Scaffold( appBar: AppBar( title: const Text('Einstellungen'), ), @@ -58,7 +61,8 @@ class _SettingsState extends State { value.clear(), }).then((value) async { PaintingBinding.instance.imageCache.clear(); - Provider.of(context, listen: false).reset(); + if (!context.mounted) return; + context.read().reset(); const CacheView().clear(); AccountData().removeData(context: context); Navigator.popUntil(context, (route) => !Navigator.canPop(context)); @@ -115,7 +119,7 @@ class _SettingsState extends State { )).toList(), onChanged: (value) { settings.val(write: true).timetableSettings.timetableNameMode = value!; - Provider.of(context, listen: false).run(renew: false); + context.read().refresh(); }, ) ), @@ -126,7 +130,7 @@ class _SettingsState extends State { value: settings.val().timetableSettings.connectDoubleLessons, onChanged: (e) { settings.val(write: true).timetableSettings.connectDoubleLessons = e!; - Provider.of(context, listen: false).run(renew: false); + context.read().refresh(); }, ), ), @@ -215,6 +219,7 @@ class _SettingsState extends State { title: const Text('Informationen und Lizenzen'), onTap: () { PackageInfo.fromPlatform().then((appInfo) { + if (!context.mounted) return; showAboutDialog( context: context, applicationIcon: const Icon(Icons.apps), @@ -308,5 +313,6 @@ class _SettingsState extends State { ), ], ), - )); + ); + }); } diff --git a/lib/widget/breaker/breaker.dart b/lib/widget/breaker/breaker.dart new file mode 100644 index 0000000..9c66de2 --- /dev/null +++ b/lib/widget/breaker/breaker.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; +import '../../state/app/modules/breaker/bloc/breaker_bloc.dart'; +import '../../widget/placeholderView.dart'; + +class Breaker extends StatelessWidget { + final BreakerArea breaker; + final Widget child; + + const Breaker({required this.breaker, required this.child, super.key}); + + @override + Widget build(BuildContext context) { + final bloc = context.watch(); + final blocked = bloc.isBlocked(breaker); + if (blocked != null) { + return PlaceholderView( + icon: Icons.app_blocking_outlined, + text: 'Die App / Dieser Bereich ist zurzeit nicht verfügbar!\n\n' + '${blocked.isEmpty ? "Es wurde vom Server kein Grund übermittelt.\nAktualisiere die App und versuche es später erneut" : blocked}', + ); + } + return child; + } +} diff --git a/lib/widget/debug/debugTile.dart b/lib/widget/debug/debugTile.dart index bc5a8c4..7a29fcc 100644 --- a/lib/widget/debug/debugTile.dart +++ b/lib/widget/debug/debugTile.dart @@ -1,8 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../storage/base/settingsProvider.dart'; +import '../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../centeredLeading.dart'; import 'jsonViewer.dart'; @@ -11,29 +11,27 @@ class DebugTile { bool onlyInDebug; DebugTile(this.context, {this.onlyInDebug = false}); - bool devConditionFulfilled() => Provider.of(context, listen: false).val().devToolsEnabled && (onlyInDebug ? kDebugMode : true); + bool devConditionFulfilled() => + context.read().val().devToolsEnabled && (onlyInDebug ? kDebugMode : true); Widget jsonData(Map data, {bool ignoreConfig = false}) => callback( - title: 'JSON daten anzeigen', - onTab: () => JsonViewer.asDialog(context, data) - ); + title: 'JSON daten anzeigen', + onTab: () => JsonViewer.asDialog(context, data), + ); Widget callback({String title = 'Debugaktion', required void Function() onTab}) => child( - ListTile( - leading: const CenteredLeading(Icon(Icons.developer_mode_outlined)), - title: Text(title), - subtitle: const Text('Entwicklermodus aktiviert'), - onTap: onTab, - ) - ); + ListTile( + leading: const CenteredLeading(Icon(Icons.developer_mode_outlined)), + title: Text(title), + subtitle: const Text('Entwicklermodus aktiviert'), + onTap: onTab, + ), + ); - Widget child(Widget child) => Visibility( - visible: devConditionFulfilled(), - child: child, - ); + Widget child(Widget child) => Visibility(visible: devConditionFulfilled(), child: child); void run(void Function() callback) { - if(!devConditionFulfilled()) return; + if (!devConditionFulfilled()) return; callback(); } } diff --git a/lib/widget/debug/jsonViewer.dart b/lib/widget/debug/jsonViewer.dart index 465a98b..389ddc5 100644 --- a/lib/widget/debug/jsonViewer.dart +++ b/lib/widget/debug/jsonViewer.dart @@ -29,11 +29,13 @@ class JsonViewer extends StatelessWidget { actions: [ TextButton(onPressed: () { Clipboard.setData(ClipboardData(text: JsonViewer.format(dataMap))).then((value) { + if (!context.mounted) return; showDialog(context: context, builder: (context) => const AlertDialog(content: Text('Formatiertes JSON wurde erfolgreich in deiner Zwischenlage abgelegt.'))); }); }, child: const Text('Kopieren')), TextButton(onPressed: () { Clipboard.setData(ClipboardData(text: dataMap.toString())).then((value) { + if (!context.mounted) return; showDialog(context: context, builder: (context) => const AlertDialog(content: Text('Unformatiertes JSON wurde erfolgreich in deiner Zwischenablage abgelegt.'))); }); }, child: const Text('Inline Kopieren')), diff --git a/lib/widget/fileViewer.dart b/lib/widget/fileViewer.dart index 54f6557..8ab5c58 100644 --- a/lib/widget/fileViewer.dart +++ b/lib/widget/fileViewer.dart @@ -4,11 +4,11 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:open_filex/open_filex.dart'; import 'package:photo_view/photo_view.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:share_plus/share_plus.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; -import '../storage/base/settingsProvider.dart'; +import '../state/app/modules/settings/bloc/settings_cubit.dart'; import '../utils/FileSaver.dart'; import 'infoDialog.dart'; import 'placeholderView.dart'; @@ -32,7 +32,7 @@ enum FileViewingActions { class _FileViewerState extends State { PhotoViewController photoViewController = PhotoViewController(); - late SettingsProvider settings = Provider.of(context, listen: false); + late SettingsCubit settings = context.read(); late bool openExternal; @override @@ -137,6 +137,7 @@ class _FileViewerState extends State { default: OpenFilex.open(widget.path).then((result) { + if (!context.mounted) return; Navigator.of(context).pop(); if(result.type != ResultType.done) { showDialog(context: context, builder: (context) => AlertDialog( diff --git a/lib/widget/infoDialog.dart b/lib/widget/infoDialog.dart index e30a61e..001b2ed 100644 --- a/lib/widget/infoDialog.dart +++ b/lib/widget/infoDialog.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; class InfoDialog { - static show(BuildContext context, String info) { + static void show(BuildContext context, String info) { showDialog(context: context, builder: (context) => AlertDialog( content: Text(info), contentPadding: const EdgeInsets.all(20), diff --git a/lib/widget/placeholderView.dart b/lib/widget/placeholderView.dart index e890332..ce14d8d 100644 --- a/lib/widget/placeholderView.dart +++ b/lib/widget/placeholderView.dart @@ -23,7 +23,7 @@ class PlaceholderView extends StatelessWidget { textAlign: TextAlign.center, ), const SizedBox(height: 30), - if(button != null) button!, + ?button, ], ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 9563857..aa90db4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,8 @@ dependencies: url: https://github.com/Harsh223/flowder.git flutter_app_badge: ^2.0.2 flutter_bloc: ^9.0.0 + flutter_secure_storage: ^9.2.4 + intl: ^0.20.2 flutter_linkify: ^6.0.0 flutter_local_notifications: ^21.0.0 flutter_login: ^6.0.0 From e8faa77e70d704b35ceb11fbac2938a5fd766fde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Tue, 5 May 2026 13:49:45 +0200 Subject: [PATCH 02/23] refactored timetable --- .../get/getCustomTimetableEventCache.dart | 12 +- lib/api/requestCache.dart | 47 +- .../queries/authenticate/authenticate.dart | 22 +- .../queries/getHolidays/getHolidaysCache.dart | 11 +- .../queries/getRooms/getRoomsCache.dart | 11 +- .../queries/getSubjects/getSubjectsCache.dart | 11 +- .../getTimegridUnits/getTimegridUnits.dart | 22 + .../getTimegridUnitsCache.dart | 20 + .../getTimegridUnitsResponse.dart | 38 + .../getTimegridUnitsResponse.g.dart | 64 ++ .../getTimetable/getTimetableCache.dart | 8 +- .../timetable/bloc/timetable_bloc.dart | 100 ++- .../timetable/bloc/timetable_state.dart | 2 + .../bloc/timetable_state.freezed.dart | 43 +- .../timetable/bloc/timetable_state.g.dart | 6 + .../dataProvider/timetable_data_provider.dart | 129 ++- .../custom_event_edit_dialog.dart | 36 +- .../pages/timetable/data/calendar_layout.dart | 8 + .../pages/timetable/data/lesson_color.dart | 13 +- .../data/lesson_period_schedule.dart | 94 +++ .../data/timetable_appointment_factory.dart | 5 +- lib/view/pages/timetable/timetable.dart | 116 +-- .../timetable/widgets/appointment_tile.dart | 97 +-- .../widgets/custom_workweek_calendar.dart | 761 ++++++++++++++++++ .../widgets/lesson_appointment_source.dart | 7 - .../widgets/special_regions_builder.dart | 22 +- .../timetable/widgets/time_region_tile.dart | 12 +- lib/widget/userAvatar.dart | 156 +++- pubspec.yaml | 1 + 29 files changed, 1574 insertions(+), 300 deletions(-) create mode 100644 lib/api/webuntis/queries/getTimegridUnits/getTimegridUnits.dart create mode 100644 lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart create mode 100644 lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart create mode 100644 lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.g.dart create mode 100644 lib/view/pages/timetable/data/calendar_layout.dart create mode 100644 lib/view/pages/timetable/data/lesson_period_schedule.dart create mode 100644 lib/view/pages/timetable/widgets/custom_workweek_calendar.dart delete mode 100644 lib/view/pages/timetable/widgets/lesson_appointment_source.dart diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart b/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart index 1f67d5f..f0a0119 100644 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart +++ b/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart @@ -8,7 +8,17 @@ import 'getCustomTimetableEventResponse.dart'; class GetCustomTimetableEventCache extends RequestCache { GetCustomTimetableEventParams params; - GetCustomTimetableEventCache(this.params, {onUpdate, renew}) : super(RequestCache.cacheMinute, onUpdate, renew: renew) { + GetCustomTimetableEventCache( + this.params, { + void Function(GetCustomTimetableEventResponse)? onUpdate, + void Function(Exception)? onError, + bool? renew, + }) : super( + RequestCache.cacheMinute, + onUpdate, + onError: onError ?? RequestCache.ignore, + renew: renew, + ) { start('customTimetableEvents'); } diff --git a/lib/api/requestCache.dart b/lib/api/requestCache.dart index cbbf15b..2588a22 100644 --- a/lib/api/requestCache.dart +++ b/lib/api/requestCache.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:localstore/localstore.dart'; @@ -17,29 +18,41 @@ abstract class RequestCache { void Function(Exception) onError; bool? renew; + final Completer _ready = Completer(); + + /// Resolves when [start] has finished, regardless of whether the network + /// call succeeded, failed, or was skipped due to a fresh cache. Callers + /// can await this to know when both the cache lookup and the network + /// attempt have settled. + Future get ready => _ready.future; + RequestCache(this.maxCacheTime, this.onUpdate, {this.onError = ignore, this.renew = false}); static void ignore(Exception e) {} Future start(String document) async { - final tableData = await Localstore.instance.collection(collection).doc(document).get(); - if (tableData != null) { - onUpdate?.call(onLocalData(tableData['json'])); - } - - if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < (tableData?['lastupdate'] ?? 0)) { - if (renew == null || !renew!) return; - } - try { - final newValue = await onLoad(); - onUpdate?.call(newValue); - Localstore.instance.collection(collection).doc(document).set({ - 'json': jsonEncode(newValue), - 'lastupdate': DateTime.now().millisecondsSinceEpoch, - }); - } on Exception catch (e) { - onError(e); + final tableData = await Localstore.instance.collection(collection).doc(document).get(); + if (tableData != null) { + onUpdate?.call(onLocalData(tableData['json'])); + } + + if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < (tableData?['lastupdate'] ?? 0)) { + if (renew == null || !renew!) return; + } + + try { + final newValue = await onLoad(); + onUpdate?.call(newValue); + Localstore.instance.collection(collection).doc(document).set({ + 'json': jsonEncode(newValue), + 'lastupdate': DateTime.now().millisecondsSinceEpoch, + }); + } on Exception catch (e) { + onError(e); + } + } finally { + if (!_ready.isCompleted) _ready.complete(); } } diff --git a/lib/api/webuntis/queries/authenticate/authenticate.dart b/lib/api/webuntis/queries/authenticate/authenticate.dart index c42f62c..483278e 100644 --- a/lib/api/webuntis/queries/authenticate/authenticate.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate.dart @@ -14,11 +14,23 @@ class Authenticate extends WebuntisApi { @override Future run() async { awaitingResponse = true; - var rawAnswer = await query(this); - AuthenticateResponse response = finalize(AuthenticateResponse.fromJson(jsonDecode(rawAnswer)['result'])); - _lastResponse = response; - if(!awaitedResponse.isCompleted) awaitedResponse.complete(); - return response; + try { + var rawAnswer = await query(this); + AuthenticateResponse response = finalize(AuthenticateResponse.fromJson(jsonDecode(rawAnswer)['result'])); + _lastResponse = response; + if(!awaitedResponse.isCompleted) awaitedResponse.complete(); + return response; + } catch (e) { + // Surface the error to anyone waiting on the current completer, then + // install a fresh one so a future attempt can succeed. Without this, + // any later call to getSession() would hang forever on a completer + // that is already settled with no listeners (or never settles at all). + if(!awaitedResponse.isCompleted) awaitedResponse.completeError(e); + awaitedResponse = Completer(); + rethrow; + } finally { + awaitingResponse = false; + } } static bool awaitingResponse = false; diff --git a/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart b/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart index c4e4627..e986965 100644 --- a/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart +++ b/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart @@ -5,7 +5,16 @@ import 'getHolidays.dart'; import 'getHolidaysResponse.dart'; class GetHolidaysCache extends RequestCache { - GetHolidaysCache({void Function(GetHolidaysResponse)? onUpdate}) : super(RequestCache.cacheDay, onUpdate) { + GetHolidaysCache({ + void Function(GetHolidaysResponse)? onUpdate, + void Function(Exception)? onError, + bool? renew, + }) : super( + RequestCache.cacheDay, + onUpdate, + onError: onError ?? RequestCache.ignore, + renew: renew, + ) { start('wu-holidays'); } diff --git a/lib/api/webuntis/queries/getRooms/getRoomsCache.dart b/lib/api/webuntis/queries/getRooms/getRoomsCache.dart index 33d00ee..00c155f 100644 --- a/lib/api/webuntis/queries/getRooms/getRoomsCache.dart +++ b/lib/api/webuntis/queries/getRooms/getRoomsCache.dart @@ -5,7 +5,16 @@ import 'getRooms.dart'; import 'getRoomsResponse.dart'; class GetRoomsCache extends RequestCache { - GetRoomsCache({void Function(GetRoomsResponse)? onUpdate}) : super(RequestCache.cacheHour, onUpdate) { + GetRoomsCache({ + void Function(GetRoomsResponse)? onUpdate, + void Function(Exception)? onError, + bool? renew, + }) : super( + RequestCache.cacheHour, + onUpdate, + onError: onError ?? RequestCache.ignore, + renew: renew, + ) { start('wu-rooms'); } diff --git a/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart b/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart index bec137b..6e834a4 100644 --- a/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart +++ b/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart @@ -5,7 +5,16 @@ import 'getSubjects.dart'; import 'getSubjectsResponse.dart'; class GetSubjectsCache extends RequestCache { - GetSubjectsCache({void Function(GetSubjectsResponse)? onUpdate}) : super(RequestCache.cacheHour, onUpdate) { + GetSubjectsCache({ + void Function(GetSubjectsResponse)? onUpdate, + void Function(Exception)? onError, + bool? renew, + }) : super( + RequestCache.cacheHour, + onUpdate, + onError: onError ?? RequestCache.ignore, + renew: renew, + ) { start('wu-subjects'); } diff --git a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnits.dart b/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnits.dart new file mode 100644 index 0000000..0e9c38f --- /dev/null +++ b/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnits.dart @@ -0,0 +1,22 @@ +import 'dart:convert'; +import 'dart:developer'; + +import '../../webuntisApi.dart'; +import 'getTimegridUnitsResponse.dart'; + +class GetTimegridUnits extends WebuntisApi { + GetTimegridUnits() : super('getTimegridUnits', null); + + @override + Future run() async { + var rawAnswer = await query(this); + try { + return finalize(GetTimegridUnitsResponse.fromJson(jsonDecode(rawAnswer))); + } catch (e, trace) { + log(trace.toString()); + log('Failed to parse getTimegridUnits data with server response: $rawAnswer'); + } + + throw Exception('Failed to parse getTimegridUnits server response: $rawAnswer'); + } +} diff --git a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart b/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart new file mode 100644 index 0000000..6de483d --- /dev/null +++ b/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart @@ -0,0 +1,20 @@ +import 'dart:convert'; + +import '../../../requestCache.dart'; +import 'getTimegridUnits.dart'; +import 'getTimegridUnitsResponse.dart'; + +class GetTimegridUnitsCache extends RequestCache { + GetTimegridUnitsCache({ + void Function(GetTimegridUnitsResponse)? onUpdate, + bool? renew, + }) : super(RequestCache.cacheDay, onUpdate, renew: renew) { + start('wu-timegrid'); + } + + @override + Future onLoad() => GetTimegridUnits().run(); + + @override + GetTimegridUnitsResponse onLocalData(String json) => GetTimegridUnitsResponse.fromJson(jsonDecode(json)); +} diff --git a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart b/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart new file mode 100644 index 0000000..a730567 --- /dev/null +++ b/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart @@ -0,0 +1,38 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../../apiResponse.dart'; + +part 'getTimegridUnitsResponse.g.dart'; + +@JsonSerializable(explicitToJson: true) +class GetTimegridUnitsResponse extends ApiResponse { + List result; + + GetTimegridUnitsResponse(this.result); + + factory GetTimegridUnitsResponse.fromJson(Map json) => _$GetTimegridUnitsResponseFromJson(json); + Map toJson() => _$GetTimegridUnitsResponseToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class GetTimegridUnitsResponseDay { + int day; + List timeUnits; + + GetTimegridUnitsResponseDay(this.day, this.timeUnits); + + factory GetTimegridUnitsResponseDay.fromJson(Map json) => _$GetTimegridUnitsResponseDayFromJson(json); + Map toJson() => _$GetTimegridUnitsResponseDayToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class GetTimegridUnitsResponseUnit { + String name; + int startTime; + int endTime; + + GetTimegridUnitsResponseUnit(this.name, this.startTime, this.endTime); + + factory GetTimegridUnitsResponseUnit.fromJson(Map json) => _$GetTimegridUnitsResponseUnitFromJson(json); + Map toJson() => _$GetTimegridUnitsResponseUnitToJson(this); +} diff --git a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.g.dart b/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.g.dart new file mode 100644 index 0000000..b6fc909 --- /dev/null +++ b/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'getTimegridUnitsResponse.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GetTimegridUnitsResponse _$GetTimegridUnitsResponseFromJson( + Map json, +) => + GetTimegridUnitsResponse( + (json['result'] as List) + .map( + (e) => GetTimegridUnitsResponseDay.fromJson( + e as Map, + ), + ) + .toList(), + ) + ..headers = (json['headers'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ); + +Map _$GetTimegridUnitsResponseToJson( + GetTimegridUnitsResponse instance, +) => { + 'headers': ?instance.headers, + 'result': instance.result.map((e) => e.toJson()).toList(), +}; + +GetTimegridUnitsResponseDay _$GetTimegridUnitsResponseDayFromJson( + Map json, +) => GetTimegridUnitsResponseDay( + (json['day'] as num).toInt(), + (json['timeUnits'] as List) + .map( + (e) => GetTimegridUnitsResponseUnit.fromJson(e as Map), + ) + .toList(), +); + +Map _$GetTimegridUnitsResponseDayToJson( + GetTimegridUnitsResponseDay instance, +) => { + 'day': instance.day, + 'timeUnits': instance.timeUnits.map((e) => e.toJson()).toList(), +}; + +GetTimegridUnitsResponseUnit _$GetTimegridUnitsResponseUnitFromJson( + Map json, +) => GetTimegridUnitsResponseUnit( + json['name'] as String, + (json['startTime'] as num).toInt(), + (json['endTime'] as num).toInt(), +); + +Map _$GetTimegridUnitsResponseUnitToJson( + GetTimegridUnitsResponseUnit instance, +) => { + 'name': instance.name, + 'startTime': instance.startTime, + 'endTime': instance.endTime, +}; diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart b/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart index 0872b70..c834030 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart +++ b/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart @@ -15,7 +15,13 @@ class GetTimetableCache extends RequestCache { void Function(Exception)? onError, required this.startdate, required this.enddate, - }) : super(RequestCache.cacheMinute, onUpdate, onError: onError ?? RequestCache.ignore) { + bool? renew, + }) : super( + RequestCache.cacheMinute, + onUpdate, + onError: onError ?? RequestCache.ignore, + renew: renew, + ) { start('wu-timetable-$startdate-$enddate'); } diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart index 5055e08..1b142e9 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -14,6 +14,16 @@ class TimetableBloc extends LoadableHydratedBloc TimetableRepository(); @@ -35,11 +45,23 @@ class TimetableBloc extends LoadableHydratedBloc gatherData() async { final initial = innerState ?? fromNothing(); + final renew = _forceRenew; + _forceRenew = false; + + Object? firstError; + void recordError(Object e) { + firstError ??= e; + } + await Future.wait([ - _loadCurrentWeek(initial.startDate, initial.endDate), - _loadStaticReferenceData(), - _loadCustomEvents(), + _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); } @@ -73,41 +95,69 @@ class TimetableBloc extends LoadableHydratedBloc _loadCurrentWeek(DateTime startDate, DateTime endDate) async { + 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); + final week = await repo.data.getWeek(startDate, endDate, onError: onError, renew: renew); if (_lastWeekRequestStart.isAfter(requestStart)) return; _writeWeekToCache(startDate, week); - } catch (_) { - // Errors are surfaced via LoadableHydratedBloc.fetch's catchError. - rethrow; + } catch (e) { + onError?.call(e); } } - Future _loadStaticReferenceData() async { - final (rooms, subjects, schoolHolidays) = await ( - repo.data.getRooms(), - repo.data.getSubjects(), - repo.data.getSchoolHolidays(), - ).wait; + Future _loadStaticReferenceData({ + void Function(Object)? onError, + bool renew = false, + }) async { + try { + final (rooms, subjects, schoolHolidays) = await ( + repo.data.getRooms(onError: onError, renew: renew), + repo.data.getSubjects(onError: onError, renew: renew), + repo.data.getSchoolHolidays(onError: onError, renew: renew), + ).wait; - add(DataGathered((s) => s.copyWith( - rooms: rooms, - subjects: subjects, - schoolHolidays: schoolHolidays, - dataVersion: s.dataVersion + 1, - ))); + add(Emit((s) => s.copyWith( + rooms: rooms, + subjects: subjects, + schoolHolidays: schoolHolidays, + dataVersion: s.dataVersion + 1, + ))); + } catch (e) { + onError?.call(e); + } + + try { + final timegrid = await repo.data.getTimegrid(renew: renew); + add(Emit((s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1))); + } catch (_) { + // Timegrid load failure falls back to a hardcoded schedule in the UI layer. + } } - Future _loadCustomEvents({bool renew = false}) async { - final events = await repo.data.getCustomEvents(renew: renew); + Future _loadCustomEvents({ + void Function(Object)? onError, + bool renew = false, + }) async { + try { + final events = await repo.data.getCustomEvents(renew: renew, onError: onError); + add(Emit((s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1))); + } catch (e) { + onError?.call(e); + } + } + + Future _refreshCustomEvents() async { + final events = await repo.data.getCustomEvents(renew: true); add(DataGathered((s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1))); } - Future _refreshCustomEvents() => _loadCustomEvents(renew: true); - void _prefetchAdjacentWeeks(DateTime start, DateTime end) { _prefetchWeek(start.subtract(_weekSpan), end.subtract(_weekSpan)); _prefetchWeek(start.add(_weekSpan), end.add(_weekSpan)); @@ -119,7 +169,7 @@ class TimetableBloc extends LoadableHydratedBloc.of(s.weekCache); updated[key] = week; return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1); diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.dart b/lib/state/app/modules/timetable/bloc/timetable_state.dart index cc88b26..1d1b2ea 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.dart @@ -4,6 +4,7 @@ import '../../../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEvent import '../../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; import '../../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; import '../../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; +import '../../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart'; import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; part 'timetable_state.freezed.dart'; @@ -18,6 +19,7 @@ abstract class TimetableState with _$TimetableState { GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, + GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, required DateTime startDate, required DateTime endDate, diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart b/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart index 1ad6cd1..4af71be 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$TimetableState { - Map get weekCache; GetRoomsResponse? get rooms; GetSubjectsResponse? get subjects; GetHolidaysResponse? get schoolHolidays; GetCustomTimetableEventResponse? get customEvents; DateTime get startDate; DateTime get endDate; int get dataVersion; + Map get weekCache; GetRoomsResponse? get rooms; GetSubjectsResponse? get subjects; GetHolidaysResponse? get schoolHolidays; GetTimegridUnitsResponse? get timegrid; GetCustomTimetableEventResponse? get customEvents; DateTime get startDate; DateTime get endDate; int get dataVersion; /// Create a copy of TimetableState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -28,16 +28,16 @@ $TimetableStateCopyWith get copyWith => _$TimetableStateCopyWith @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is TimetableState&&const DeepCollectionEquality().equals(other.weekCache, weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is TimetableState&&const DeepCollectionEquality().equals(other.weekCache, weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.timegrid, timegrid) || other.timegrid == timegrid)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(weekCache),rooms,subjects,schoolHolidays,customEvents,startDate,endDate,dataVersion); +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(weekCache),rooms,subjects,schoolHolidays,timegrid,customEvents,startDate,endDate,dataVersion); @override String toString() { - return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)'; + return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, timegrid: $timegrid, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)'; } @@ -48,7 +48,7 @@ abstract mixin class $TimetableStateCopyWith<$Res> { factory $TimetableStateCopyWith(TimetableState value, $Res Function(TimetableState) _then) = _$TimetableStateCopyWithImpl; @useResult $Res call({ - Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion + Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion }); @@ -65,13 +65,14 @@ class _$TimetableStateCopyWithImpl<$Res> /// Create a copy of TimetableState /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? timegrid = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) { return _then(_self.copyWith( weekCache: null == weekCache ? _self.weekCache : weekCache // ignore: cast_nullable_to_non_nullable as Map,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable as GetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable as GetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable -as GetHolidaysResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable +as GetHolidaysResponse?,timegrid: freezed == timegrid ? _self.timegrid : timegrid // ignore: cast_nullable_to_non_nullable +as GetTimegridUnitsResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable as GetCustomTimetableEventResponse?,startDate: null == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable as DateTime,endDate: null == endDate ? _self.endDate : endDate // ignore: cast_nullable_to_non_nullable as DateTime,dataVersion: null == dataVersion ? _self.dataVersion : dataVersion // ignore: cast_nullable_to_non_nullable @@ -160,10 +161,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _TimetableState() when $default != null: -return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: +return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: return orElse(); } @@ -181,10 +182,10 @@ return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays, /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion) $default,) {final _that = this; switch (_that) { case _TimetableState(): -return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: +return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: throw StateError('Unexpected subclass'); } @@ -201,10 +202,10 @@ return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays, /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,) {final _that = this; switch (_that) { case _TimetableState() when $default != null: -return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: +return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: return null; } @@ -216,7 +217,7 @@ return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays, @JsonSerializable() class _TimetableState extends TimetableState { - const _TimetableState({final Map weekCache = const {}, this.rooms, this.subjects, this.schoolHolidays, this.customEvents, required this.startDate, required this.endDate, this.dataVersion = 0}): _weekCache = weekCache,super._(); + const _TimetableState({final Map weekCache = const {}, this.rooms, this.subjects, this.schoolHolidays, this.timegrid, this.customEvents, required this.startDate, required this.endDate, this.dataVersion = 0}): _weekCache = weekCache,super._(); factory _TimetableState.fromJson(Map json) => _$TimetableStateFromJson(json); final Map _weekCache; @@ -229,6 +230,7 @@ class _TimetableState extends TimetableState { @override final GetRoomsResponse? rooms; @override final GetSubjectsResponse? subjects; @override final GetHolidaysResponse? schoolHolidays; +@override final GetTimegridUnitsResponse? timegrid; @override final GetCustomTimetableEventResponse? customEvents; @override final DateTime startDate; @override final DateTime endDate; @@ -247,16 +249,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _TimetableState&&const DeepCollectionEquality().equals(other._weekCache, _weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TimetableState&&const DeepCollectionEquality().equals(other._weekCache, _weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.timegrid, timegrid) || other.timegrid == timegrid)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_weekCache),rooms,subjects,schoolHolidays,customEvents,startDate,endDate,dataVersion); +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_weekCache),rooms,subjects,schoolHolidays,timegrid,customEvents,startDate,endDate,dataVersion); @override String toString() { - return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)'; + return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, timegrid: $timegrid, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)'; } @@ -267,7 +269,7 @@ abstract mixin class _$TimetableStateCopyWith<$Res> implements $TimetableStateCo factory _$TimetableStateCopyWith(_TimetableState value, $Res Function(_TimetableState) _then) = __$TimetableStateCopyWithImpl; @override @useResult $Res call({ - Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion + Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion }); @@ -284,13 +286,14 @@ class __$TimetableStateCopyWithImpl<$Res> /// Create a copy of TimetableState /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? timegrid = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) { return _then(_TimetableState( weekCache: null == weekCache ? _self._weekCache : weekCache // ignore: cast_nullable_to_non_nullable as Map,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable as GetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable as GetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable -as GetHolidaysResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable +as GetHolidaysResponse?,timegrid: freezed == timegrid ? _self.timegrid : timegrid // ignore: cast_nullable_to_non_nullable +as GetTimegridUnitsResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable as GetCustomTimetableEventResponse?,startDate: null == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable as DateTime,endDate: null == endDate ? _self.endDate : endDate // ignore: cast_nullable_to_non_nullable as DateTime,dataVersion: null == dataVersion ? _self.dataVersion : dataVersion // ignore: cast_nullable_to_non_nullable diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.g.dart b/lib/state/app/modules/timetable/bloc/timetable_state.g.dart index 07960f5..367b428 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.g.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.g.dart @@ -29,6 +29,11 @@ _TimetableState _$TimetableStateFromJson(Map json) => : GetHolidaysResponse.fromJson( json['schoolHolidays'] as Map, ), + timegrid: json['timegrid'] == null + ? null + : GetTimegridUnitsResponse.fromJson( + json['timegrid'] as Map, + ), customEvents: json['customEvents'] == null ? null : GetCustomTimetableEventResponse.fromJson( @@ -45,6 +50,7 @@ Map _$TimetableStateToJson(_TimetableState instance) => 'rooms': instance.rooms, 'subjects': instance.subjects, 'schoolHolidays': instance.schoolHolidays, + 'timegrid': instance.timegrid, 'customEvents': instance.customEvents, 'startDate': instance.startDate.toIso8601String(), 'endDate': instance.endDate.toIso8601String(), diff --git a/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart b/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart index 261bb0d..5d394c0 100644 --- a/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart +++ b/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:intl/intl.dart'; import '../../../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart'; @@ -18,6 +16,8 @@ import '../../../../../api/webuntis/queries/getRooms/getRoomsCache.dart'; import '../../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; import '../../../../../api/webuntis/queries/getSubjects/getSubjectsCache.dart'; import '../../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; +import '../../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart'; +import '../../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart'; import '../../../../../api/webuntis/queries/getTimetable/getTimetableCache.dart'; import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; import '../../../../../model/accountData.dart'; @@ -25,55 +25,116 @@ import '../../../../../model/accountData.dart'; class TimetableDataProvider { static final DateFormat _dateFormat = DateFormat('yyyyMMdd'); - Future getWeek(DateTime startDate, DateTime endDate) { - final completer = Completer(); - GetTimetableCache( + Future getWeek( + DateTime startDate, + DateTime endDate, { + void Function(Object)? onError, + bool renew = false, + }) async { + GetTimetableResponse? latest; + Object? capturedError; + final cache = GetTimetableCache( startdate: int.parse(_dateFormat.format(startDate)), enddate: int.parse(_dateFormat.format(endDate)), - onUpdate: (data) { - if (!completer.isCompleted) completer.complete(data); - }, + renew: renew, + onUpdate: (data) => latest = data, onError: (e) { - if (!completer.isCompleted) completer.completeError(e); + capturedError = e; + onError?.call(e); }, ); - return completer.future; + await cache.ready; + if (latest != null) return latest!; + throw capturedError ?? Exception('No data and no error from getWeek'); } - Future getRooms() { - final completer = Completer(); - GetRoomsCache(onUpdate: (data) { - if (!completer.isCompleted) completer.complete(data); - }); - return completer.future; + Future getRooms({ + void Function(Object)? onError, + bool renew = false, + }) async { + GetRoomsResponse? latest; + Object? capturedError; + final cache = GetRoomsCache( + renew: renew, + onUpdate: (data) => latest = data, + onError: (e) { + capturedError = e; + onError?.call(e); + }, + ); + await cache.ready; + if (latest != null) return latest!; + throw capturedError ?? Exception('No data and no error from getRooms'); } - Future getSubjects() { - final completer = Completer(); - GetSubjectsCache(onUpdate: (data) { - if (!completer.isCompleted) completer.complete(data); - }); - return completer.future; + Future getSubjects({ + void Function(Object)? onError, + bool renew = false, + }) async { + GetSubjectsResponse? latest; + Object? capturedError; + final cache = GetSubjectsCache( + renew: renew, + onUpdate: (data) => latest = data, + onError: (e) { + capturedError = e; + onError?.call(e); + }, + ); + await cache.ready; + if (latest != null) return latest!; + throw capturedError ?? Exception('No data and no error from getSubjects'); } - Future getSchoolHolidays() { - final completer = Completer(); - GetHolidaysCache(onUpdate: (data) { - if (!completer.isCompleted) completer.complete(data); - }); - return completer.future; + Future getSchoolHolidays({ + void Function(Object)? onError, + bool renew = false, + }) async { + GetHolidaysResponse? latest; + Object? capturedError; + final cache = GetHolidaysCache( + renew: renew, + onUpdate: (data) => latest = data, + onError: (e) { + capturedError = e; + onError?.call(e); + }, + ); + await cache.ready; + if (latest != null) return latest!; + throw capturedError ?? Exception('No data and no error from getSchoolHolidays'); } - Future getCustomEvents({bool renew = false}) { - final completer = Completer(); - GetCustomTimetableEventCache( + Future getTimegrid({bool renew = false}) async { + GetTimegridUnitsResponse? latest; + Object? capturedError; + final cache = GetTimegridUnitsCache( + renew: renew, + onUpdate: (data) => latest = data, + ); + await cache.ready; + if (latest != null) return latest!; + throw capturedError ?? Exception('No data and no error from getTimegrid'); + } + + Future getCustomEvents({ + bool renew = false, + void Function(Object)? onError, + }) async { + GetCustomTimetableEventResponse? latest; + Object? capturedError; + final cache = GetCustomTimetableEventCache( GetCustomTimetableEventParams(AccountData().getUserSecret()), renew: renew, - onUpdate: (data) { - if (!completer.isCompleted) completer.complete(data); + onUpdate: (data) => latest = data, + onError: (e) { + capturedError = e; + onError?.call(e); }, ); - return completer.future; + await cache.ready; + if (latest != null) return latest!; + throw capturedError ?? Exception('No data and no error from getCustomEvents'); } Future addCustomEvent(CustomTimetableEvent event) => 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 b383134..58d6f9e 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 @@ -15,17 +15,28 @@ import 'custom_event_colors.dart'; class CustomEventEditDialog extends StatefulWidget { final CustomTimetableEvent? existingEvent; + final DateTime? initialStart; + final DateTime? initialEnd; - const CustomEventEditDialog({this.existingEvent, super.key}); + const CustomEventEditDialog({ + this.existingEvent, + this.initialStart, + this.initialEnd, + super.key, + }); @override State createState() => _CustomEventEditDialogState(); } class _CustomEventEditDialogState extends State { - late DateTime _date = widget.existingEvent?.startDate ?? DateTime.now(); - late TimeOfDay _startTime = widget.existingEvent?.startDate.toTimeOfDay() ?? const TimeOfDay(hour: 8, minute: 0); - late TimeOfDay _endTime = widget.existingEvent?.endDate.toTimeOfDay() ?? const TimeOfDay(hour: 9, minute: 30); + late DateTime _date = widget.existingEvent?.startDate ?? widget.initialStart ?? DateTime.now(); + late TimeOfDay _startTime = widget.existingEvent?.startDate.toTimeOfDay() ?? + widget.initialStart?.toTimeOfDay() ?? + const TimeOfDay(hour: 8, minute: 0); + late TimeOfDay _endTime = widget.existingEvent?.endDate.toTimeOfDay() ?? + widget.initialEnd?.toTimeOfDay() ?? + const TimeOfDay(hour: 9, minute: 30); late final TextEditingController _name = TextEditingController(text: widget.existingEvent?.title); late final TextEditingController _description = TextEditingController(text: widget.existingEvent?.description); late String _rrule = widget.existingEvent?.rrule ?? ''; @@ -167,13 +178,20 @@ class _CustomEventEditDialogState extends State { const Divider(), RRuleGenerator( config: RRuleGeneratorConfig( - headerEnabled: true, - weekdayBackgroundColor: Theme.of(context).colorScheme.secondary, - weekdaySelectedBackgroundColor: Theme.of(context).primaryColor, - weekdayColor: Colors.black, + selectDayStyle: RRuleSelectDayStyle( + dayStyle: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.secondary, + ), + dayTextStyle: const TextStyle(color: Colors.black), + selectedDayStyle: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).primaryColor, + ), + ), ), initialRRule: _rrule, - textDelegate: const GermanRRuleTextDelegate(), + locale: RRuleLocale.de_DE, onChange: (newValue) { log('Rule: $newValue'); setState(() => _rrule = newValue); diff --git a/lib/view/pages/timetable/data/calendar_layout.dart b/lib/view/pages/timetable/data/calendar_layout.dart new file mode 100644 index 0000000..d24729b --- /dev/null +++ b/lib/view/pages/timetable/data/calendar_layout.dart @@ -0,0 +1,8 @@ +const double kCalendarStartHour = 7.5; +const double kCalendarEndHour = 17.25; +const Duration kCalendarTimeInterval = Duration(minutes: 30); +const double kCalendarViewHeaderHeight = 60; + +/// Minimum pixels per hour. Below this, the grid scrolls vertically rather +/// than compressing further. +const double kCalendarMinPxPerHour = 56; diff --git a/lib/view/pages/timetable/data/lesson_color.dart b/lib/view/pages/timetable/data/lesson_color.dart index 18477a4..cb4ad80 100644 --- a/lib/view/pages/timetable/data/lesson_color.dart +++ b/lib/view/pages/timetable/data/lesson_color.dart @@ -3,12 +3,14 @@ import 'package:flutter/material.dart'; import 'lesson_status.dart'; class LessonColor { + static const Color regular = Color.fromARGB(255, 153, 51, 51); + static const Color ongoing = Color.fromARGB(255, 200, 51, 51); static const Color cancelled = Color(0xff000000); static const Color irregular = Color(0xff8F19B3); static const Color teacherChanged = Color(0xFF29639B); static const Color parseFallback = Color(0xff404040); - static Color forStatus(LessonStatus status, ColorScheme scheme) { + static Color forStatus(LessonStatus status) { switch (status) { case LessonStatus.cancelled: return cancelled; @@ -18,14 +20,9 @@ class LessonColor { return teacherChanged; case LessonStatus.past: case LessonStatus.regular: - return scheme.primary; + return regular; case LessonStatus.ongoing: - return Color.from( - alpha: scheme.primary.a, - red: 200 / 255, - green: scheme.primary.g, - blue: scheme.primary.b, - ); + return ongoing; } } } diff --git a/lib/view/pages/timetable/data/lesson_period_schedule.dart b/lib/view/pages/timetable/data/lesson_period_schedule.dart new file mode 100644 index 0000000..b163226 --- /dev/null +++ b/lib/view/pages/timetable/data/lesson_period_schedule.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +import '../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; + +class LessonPeriod { + final String name; + final TimeOfDay start; + final TimeOfDay end; + final bool isBreak; + + const LessonPeriod({ + required this.name, + required this.start, + required this.end, + this.isBreak = false, + }); + + Duration get duration => Duration( + minutes: (end.hour * 60 + end.minute) - (start.hour * 60 + start.minute), + ); + + int get _startMinutes => start.hour * 60 + start.minute; +} + +class LessonPeriodSchedule { + final List periods; + + const LessonPeriodSchedule(this.periods); + + static LessonPeriodSchedule? fromApi(GetTimegridUnitsResponse response) { + final canonical = response.result.firstWhere( + (d) => d.day == 1, + orElse: () => response.result.isNotEmpty ? response.result.first : GetTimegridUnitsResponseDay(0, []), + ); + if (canonical.timeUnits.isEmpty) return null; + + final periods = canonical.timeUnits + .map((u) => LessonPeriod( + name: u.name, + start: _fromHHMM(u.startTime), + end: _fromHHMM(u.endTime), + )) + .toList() + ..sort((a, b) => a._startMinutes.compareTo(b._startMinutes)); + + return LessonPeriodSchedule(periods); + } + + static LessonPeriodSchedule fallback() => const LessonPeriodSchedule([ + LessonPeriod(name: '0', start: TimeOfDay(hour: 7, minute: 10), end: TimeOfDay(hour: 7, minute: 53)), + LessonPeriod(name: '1', start: TimeOfDay(hour: 7, minute: 55), end: TimeOfDay(hour: 8, minute: 40)), + LessonPeriod(name: '2', start: TimeOfDay(hour: 8, minute: 40), end: TimeOfDay(hour: 9, minute: 25)), + LessonPeriod(name: '3', start: TimeOfDay(hour: 9, minute: 30), end: TimeOfDay(hour: 10, minute: 15)), + LessonPeriod(name: '4', start: TimeOfDay(hour: 10, minute: 35), end: TimeOfDay(hour: 11, minute: 20)), + LessonPeriod(name: '5', start: TimeOfDay(hour: 11, minute: 25), end: TimeOfDay(hour: 12, minute: 10)), + LessonPeriod(name: '6', start: TimeOfDay(hour: 12, minute: 15), end: TimeOfDay(hour: 13, minute: 0)), + LessonPeriod(name: '7', start: TimeOfDay(hour: 13, minute: 5), end: TimeOfDay(hour: 13, minute: 50)), + LessonPeriod(name: '8', start: TimeOfDay(hour: 14, minute: 5), end: TimeOfDay(hour: 14, minute: 50)), + LessonPeriod(name: '9', start: TimeOfDay(hour: 14, minute: 50), end: TimeOfDay(hour: 15, minute: 35)), + LessonPeriod(name: '10', start: TimeOfDay(hour: 15, minute: 40), end: TimeOfDay(hour: 16, minute: 25)), + LessonPeriod(name: '11', start: TimeOfDay(hour: 16, minute: 25), end: TimeOfDay(hour: 17, minute: 10)), + ]); + + static LessonPeriodSchedule fromState(TimetableState state) { + final fromApi = state.timegrid != null ? LessonPeriodSchedule.fromApi(state.timegrid!) : null; + return (fromApi ?? fallback()).withSyntheticBreaks(); + } + + LessonPeriodSchedule withSyntheticBreaks() { + final result = []; + for (var i = 0; i < periods.length; i++) { + final current = periods[i]; + result.add(current); + if (i + 1 >= periods.length) continue; + final next = periods[i + 1]; + final gapMinutes = next._startMinutes - (current.end.hour * 60 + current.end.minute); + if (gapMinutes >= 10) { + result.add(LessonPeriod( + name: 'Pause', + start: current.end, + end: next.start, + isBreak: true, + )); + } + } + return LessonPeriodSchedule(result); + } + + static TimeOfDay _fromHHMM(int hhmm) => TimeOfDay( + hour: hhmm ~/ 100, + minute: hhmm % 100, + ); +} diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index 8360c8c..868bc60 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -1,5 +1,4 @@ import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; @@ -20,7 +19,6 @@ class TimetableAppointmentFactory { final GetRoomsResponse rooms; final GetSubjectsResponse subjects; final TimetableSettings settings; - final ColorScheme colorScheme; final DateTime now; TimetableAppointmentFactory({ @@ -29,7 +27,6 @@ class TimetableAppointmentFactory { required this.rooms, required this.subjects, required this.settings, - required this.colorScheme, required this.now, }); @@ -54,7 +51,7 @@ class TimetableAppointmentFactory { subject: _subjectName(lesson), location: _locationLabel(lesson), notes: lesson.activityType, - color: LessonColor.forStatus(status, colorScheme), + color: LessonColor.forStatus(status), ); } catch (_) { return Appointment( diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index cfcac4a..5099577 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; @@ -12,12 +10,11 @@ import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import 'custom_events/custom_event_edit_dialog.dart'; import 'custom_events/custom_events_view.dart'; import 'data/arbitrary_appointment.dart'; +import 'data/lesson_period_schedule.dart'; import 'data/timetable_appointment_factory.dart'; import 'details/appointment_details_dispatcher.dart'; -import 'widgets/appointment_tile.dart'; -import 'widgets/lesson_appointment_source.dart'; +import 'widgets/custom_workweek_calendar.dart'; import 'widgets/special_regions_builder.dart'; -import 'widgets/time_region_tile.dart'; enum _CalendarAction { addEvent, viewEvents } @@ -29,32 +26,15 @@ class Timetable extends StatefulWidget { } class _TimetableState extends State { - final CalendarController _controller = CalendarController(); - late Timer _highlightTicker; + final GlobalKey _calendarKey = GlobalKey(); - LessonAppointmentSource? _cachedSource; + List? _cachedAppointments; int? _lastDataVersion; - @override - void initState() { - super.initState(); - _controller.displayDate = _initialDisplayDate(); - - _highlightTicker = Timer.periodic(const Duration(seconds: 30), (_) { - if (mounted) setState(() => _cachedSource = null); - }); - } - - @override - void dispose() { - _highlightTicker.cancel(); - super.dispose(); - } - DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2)); void _jumpToToday() { - _controller.displayDate = _initialDisplayDate(); + _calendarKey.currentState?.jumpToDate(_initialDisplayDate()); } void _onAction(_CalendarAction action) { @@ -70,24 +50,27 @@ class _TimetableState extends State { } } - LessonAppointmentSource _appointmentSource(TimetableState state) { - if (_cachedSource != null && _lastDataVersion == state.dataVersion) { - return _cachedSource!; + List _appointments(TimetableState state) { + if (_cachedAppointments != null && _lastDataVersion == state.dataVersion) { + return _cachedAppointments!; } _lastDataVersion = state.dataVersion; final settings = context.read(); - final appointments = TimetableAppointmentFactory( + return _cachedAppointments = TimetableAppointmentFactory( lessons: state.getAllKnownLessons().toList(), customEvents: state.customEvents?.events ?? const [], rooms: state.rooms!, subjects: state.subjects!, settings: settings.val().timetableSettings, - colorScheme: Theme.of(context).colorScheme, now: DateTime.now(), ).build(); + } - return _cachedSource = LessonAppointmentSource(appointments); + bool _isCrossedOut(Appointment appointment) { + final id = appointment.id; + if (id is WebuntisAppointment) return id.lesson.code == 'cancelled'; + return false; } @override @@ -126,54 +109,35 @@ class _TimetableState extends State { Widget _calendar(TimetableState state, TimetableBloc bloc) { if (!state.hasReferenceData) return const SizedBox.shrink(); - return SfCalendar( - timeZone: 'W. Europe Standard Time', - view: CalendarView.workWeek, - dataSource: _appointmentSource(state), - maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday), + final schedule = LessonPeriodSchedule.fromState(state); + final appointments = _appointments(state); + final regions = SpecialRegionsBuilder( + holidays: state.schoolHolidays!, + schedule: schedule, + colorScheme: Theme.of(context).colorScheme, + disabledColor: Theme.of(context).disabledColor, + ).build(); + + return CustomWorkWeekCalendar( + key: _calendarKey, + schedule: schedule, + appointments: appointments, + timeRegions: regions, + initialDate: _initialDisplayDate(), minDate: DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.sunday), - controller: _controller, - onViewChanged: (details) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - bloc.changeWeek(details.visibleDates.first, details.visibleDates.last); - }); - }, - onTap: (tap) { - if (tap.appointments == null || tap.appointments!.isEmpty) return; - AppointmentDetailsDispatcher.show(context, bloc, tap.appointments!.first); - }, - firstDayOfWeek: DateTime.monday, - specialRegions: SpecialRegionsBuilder( - holidays: state.schoolHolidays!, - colorScheme: Theme.of(context).colorScheme, - disabledColor: Theme.of(context).disabledColor, - ).build(), - timeSlotViewSettings: const TimeSlotViewSettings( - startHour: 7.5, - endHour: 16.5, - timeInterval: Duration(minutes: 30), - timeFormat: 'HH:mm', - dayFormat: 'EE', - timeIntervalHeight: 40, - ), - timeRegionBuilder: (_, details) => TimeRegionTile(details: details), - appointmentBuilder: (_, details) => AppointmentTile( - details: details, - crossedOut: _isCrossedOut(details), - ), - headerHeight: 0, - selectionDecoration: const BoxDecoration(), - allowAppointmentResize: false, - allowDragAndDrop: false, - allowViewNavigation: false, + maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday), + onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show(context, bloc, apt), + onWeekChanged: (start, end) => bloc.changeWeek(start, end), + isCrossedOut: _isCrossedOut, + onCreateEvent: _onCreateEventAt, ); } - bool _isCrossedOut(CalendarAppointmentDetails details) { - final appointment = details.appointments.first; - final id = appointment.id; - if (id is WebuntisAppointment) return id.lesson.code == 'cancelled'; - return false; + void _onCreateEventAt(DateTime start, DateTime end) { + showDialog( + context: context, + builder: (_) => CustomEventEditDialog(initialStart: start, initialEnd: end), + barrierDismissible: false, + ); } } diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart index 15fcea1..e34a5d5 100644 --- a/lib/view/pages/timetable/widgets/appointment_tile.dart +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -4,65 +4,68 @@ import 'package:syncfusion_flutter_calendar/calendar.dart'; import 'cross_painter.dart'; class AppointmentTile extends StatelessWidget { - final CalendarAppointmentDetails details; + final Appointment appointment; final bool crossedOut; - const AppointmentTile({super.key, required this.details, this.crossedOut = false}); + const AppointmentTile({super.key, required this.appointment, this.crossedOut = false}); @override Widget build(BuildContext context) { - final Appointment meeting = details.appointments.first; - final isPast = meeting.endTime.isBefore(DateTime.now()); - final color = meeting.color.withAlpha(isPast ? 100 : 255); + final isPast = appointment.endTime.isBefore(DateTime.now()); + final color = appointment.color.withAlpha(isPast ? 160 : 255); - return Stack( - children: [ - Container( - padding: const EdgeInsets.all(3), - height: details.bounds.height, - alignment: Alignment.topLeft, - decoration: BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: const BorderRadius.all(Radius.circular(5)), - color: color, - ), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FittedBox( - fit: BoxFit.fitWidth, - child: Text( - meeting.subject, - style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500), - maxLines: 1, - softWrap: false, - ), - ), - FittedBox( - fit: BoxFit.fitWidth, - child: Text( - meeting.location?.isNotEmpty == true ? meeting.location! : ' ', - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white, fontSize: 10), - ), - ), - ], - ), - ), - ), - if (crossedOut) + return Padding( + padding: const EdgeInsets.all(1), + child: Stack( + children: [ Positioned.fill( child: Container( + padding: const EdgeInsets.all(4), + alignment: Alignment.topLeft, decoration: BoxDecoration( - border: Border.all(width: 2, color: Colors.red.withAlpha(200)), - borderRadius: const BorderRadius.all(Radius.circular(5)), + shape: BoxShape.rectangle, + borderRadius: const BorderRadius.all(Radius.circular(7)), + color: color, + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FittedBox( + fit: BoxFit.fitWidth, + child: Text( + appointment.subject, + style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500), + maxLines: 1, + softWrap: false, + ), + ), + FittedBox( + fit: BoxFit.fitWidth, + child: Text( + appointment.location?.isNotEmpty == true ? appointment.location! : ' ', + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 10), + ), + ), + ], + ), ), - child: CustomPaint(painter: CrossPainter()), ), ), - ], + if (crossedOut) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + border: Border.all(width: 2, color: Colors.red.withAlpha(200)), + borderRadius: const BorderRadius.all(Radius.circular(7)), + ), + child: CustomPaint(painter: CrossPainter()), + ), + ), + ], + ), ); } } diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart new file mode 100644 index 0000000..18ed146 --- /dev/null +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -0,0 +1,761 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:rrule/rrule.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../data/calendar_layout.dart'; +import '../data/lesson_period_schedule.dart'; +import 'appointment_tile.dart'; +import 'time_region_tile.dart'; + +class CustomWorkWeekCalendar extends StatefulWidget { + final LessonPeriodSchedule schedule; + final List appointments; + final List timeRegions; + final DateTime initialDate; + final DateTime minDate; + final DateTime maxDate; + final void Function(Appointment appointment) onAppointmentTap; + final void Function(DateTime weekStart, DateTime weekEnd) onWeekChanged; + final bool Function(Appointment appointment) isCrossedOut; + final void Function(DateTime start, DateTime end)? onCreateEvent; + + const CustomWorkWeekCalendar({ + super.key, + required this.schedule, + required this.appointments, + required this.timeRegions, + required this.initialDate, + required this.minDate, + required this.maxDate, + required this.onAppointmentTap, + required this.onWeekChanged, + required this.isCrossedOut, + this.onCreateEvent, + }); + + @override + State createState() => CustomWorkWeekCalendarState(); +} + +class CustomWorkWeekCalendarState extends State { + static const double _rulerWidth = 50; + + late PageController _pageController; + late int _currentWeekIndex; + late DateTime _firstMonday; + late int _totalWeeks; + late Timer _ticker; + late ValueNotifier _nowNotifier; + DateTime _today = _dateOnly(DateTime.now()); + + @override + void initState() { + super.initState(); + _firstMonday = _mondayOf(widget.minDate); + final lastMonday = _mondayOf(widget.maxDate); + _totalWeeks = lastMonday.difference(_firstMonday).inDays ~/ 7 + 1; + _currentWeekIndex = _mondayOf(widget.initialDate).difference(_firstMonday).inDays ~/ 7; + _pageController = PageController(initialPage: _currentWeekIndex); + _nowNotifier = ValueNotifier(DateTime.now()); + + _ticker = Timer.periodic(const Duration(seconds: 30), (_) { + if (!mounted) return; + final now = DateTime.now(); + _nowNotifier.value = now; + final newToday = _dateOnly(now); + if (newToday != _today) setState(() => _today = newToday); + }); + } + + @override + void dispose() { + _pageController.dispose(); + _ticker.cancel(); + _nowNotifier.dispose(); + super.dispose(); + } + + static DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day); + + void jumpToDate(DateTime date) { + final target = _mondayOf(date).difference(_firstMonday).inDays ~/ 7; + if (target < 0 || target >= _totalWeeks) return; + _pageController.animateToPage( + target, + duration: const Duration(milliseconds: 380), + curve: Curves.easeInOutCubic, + ); + } + + static DateTime _mondayOf(DateTime d) { + final monday = d.subtract(Duration(days: d.weekday - 1)); + return DateTime(monday.year, monday.month, monday.day); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final visibleWeekStart = _firstMonday.add(Duration(days: _currentWeekIndex * 7)); + + return Column( + children: [ + SizedBox( + height: kCalendarViewHeaderHeight, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, -0.08), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ), + child: _DayHeaderStrip( + key: ValueKey(visibleWeekStart), + weekStart: visibleWeekStart, + today: _today, + rulerWidth: _rulerWidth, + ), + ), + ), + Container(height: 0.5, color: theme.dividerColor.withAlpha(110)), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final hours = kCalendarEndHour - kCalendarStartHour; + final fitPxPerHour = constraints.maxHeight / hours; + final pxPerHour = + fitPxPerHour < kCalendarMinPxPerHour ? kCalendarMinPxPerHour : fitPxPerHour; + final gridHeight = pxPerHour * hours; + + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: SizedBox( + height: gridHeight, + child: PageView.builder( + controller: _pageController, + itemCount: _totalWeeks, + onPageChanged: (index) { + setState(() => _currentWeekIndex = index); + final weekStart = _firstMonday.add(Duration(days: index * 7)); + widget.onWeekChanged(weekStart, weekStart.add(const Duration(days: 4))); + }, + itemBuilder: (_, weekIndex) { + final weekStart = _firstMonday.add(Duration(days: weekIndex * 7)); + return _WeekGrid( + weekStart: weekStart, + schedule: widget.schedule, + appointments: widget.appointments, + timeRegions: widget.timeRegions, + onAppointmentTap: widget.onAppointmentTap, + isCrossedOut: widget.isCrossedOut, + onCreateEvent: widget.onCreateEvent, + today: _today, + nowNotifier: _nowNotifier, + rulerWidth: _rulerWidth, + pxPerHour: pxPerHour, + ); + }, + ), + ), + ); + }, + ), + ), + ], + ); + } +} + +class _DayHeaderStrip extends StatelessWidget { + final DateTime weekStart; + final DateTime today; + final double rulerWidth; + + const _DayHeaderStrip({ + super.key, + required this.weekStart, + required this.today, + required this.rulerWidth, + }); + + @override + Widget build(BuildContext context) => Row( + children: [ + SizedBox(width: rulerWidth), + for (var d = 0; d < 5; d++) + Expanded( + child: _DayHeaderCell( + date: weekStart.add(Duration(days: d)), + today: today, + ), + ), + ], + ); +} + +class _DayHeaderCell extends StatelessWidget { + final DateTime date; + final DateTime today; + + const _DayHeaderCell({required this.date, required this.today}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isToday = _isSameDay(date, today); + final dayName = DateFormat('EE', Localizations.localeOf(context).toString()).format(date).toUpperCase(); + + final accent = theme.colorScheme.primary; + final onAccent = theme.colorScheme.onPrimary; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + dayName, + style: theme.textTheme.labelSmall?.copyWith( + color: isToday ? accent : theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + fontSize: 12, + height: 1.1, + ), + ), + const SizedBox(height: 2), + AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + width: 28, + height: 28, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isToday ? accent : Colors.transparent, + ), + alignment: Alignment.center, + child: Text( + '${date.day}', + style: theme.textTheme.titleSmall?.copyWith( + color: isToday ? onAccent : theme.colorScheme.onSurface, + fontWeight: isToday ? FontWeight.bold : FontWeight.normal, + height: 1.0, + ), + ), + ), + ], + ), + ); + } +} + +class _WeekGrid extends StatelessWidget { + final DateTime weekStart; + final LessonPeriodSchedule schedule; + final List appointments; + final List timeRegions; + final void Function(Appointment) onAppointmentTap; + final bool Function(Appointment) isCrossedOut; + final void Function(DateTime start, DateTime end)? onCreateEvent; + final DateTime today; + final ValueListenable nowNotifier; + final double rulerWidth; + final double pxPerHour; + + const _WeekGrid({ + required this.weekStart, + required this.schedule, + required this.appointments, + required this.timeRegions, + required this.onAppointmentTap, + required this.isCrossedOut, + required this.onCreateEvent, + required this.today, + required this.nowNotifier, + required this.rulerWidth, + required this.pxPerHour, + }); + + @override + Widget build(BuildContext context) { + final perDay = _expandAppointmentsForWeek(appointments, weekStart); + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _PeriodRuler( + schedule: schedule, + pxPerHour: pxPerHour, + width: rulerWidth, + ), + for (var d = 0; d < 5; d++) + Expanded( + child: _DayColumn( + date: weekStart.add(Duration(days: d)), + schedule: schedule, + appointments: perDay[d], + timeRegions: timeRegions, + pxPerHour: pxPerHour, + today: today, + nowNotifier: nowNotifier, + onAppointmentTap: onAppointmentTap, + isCrossedOut: isCrossedOut, + onCreateEvent: onCreateEvent, + ), + ), + ], + ); + } +} + +class _PeriodRuler extends StatelessWidget { + final LessonPeriodSchedule schedule; + final double pxPerHour; + final double width; + + const _PeriodRuler({ + required this.schedule, + required this.pxPerHour, + required this.width, + }); + + double _y(TimeOfDay t) => + (t.hour + t.minute / 60 - kCalendarStartHour) * pxPerHour; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + width: width, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (final period in schedule.periods) + Positioned( + top: _y(period.start), + height: (_y(period.end) - _y(period.start)).clamp(0, double.infinity), + left: 0, + right: 0, + child: _PeriodLabel(period: period, theme: theme), + ), + ], + ), + ); + } +} + +class _PeriodLabel extends StatelessWidget { + final LessonPeriod period; + final ThemeData theme; + + const _PeriodLabel({required this.period, required this.theme}); + + @override + Widget build(BuildContext context) { + final dividerColor = theme.dividerColor.withAlpha(110); + final secondaryTextColor = theme.colorScheme.onSurfaceVariant; + + if (period.isBreak) { + return Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: dividerColor, width: 0.5), + bottom: BorderSide(color: dividerColor, width: 0.5), + ), + ), + alignment: Alignment.center, + child: Icon(Icons.coffee_outlined, size: 12, color: secondaryTextColor.withAlpha(180)), + ); + } + + final timeStyle = theme.textTheme.labelSmall?.copyWith( + color: secondaryTextColor, + height: 1.0, + fontSize: 10, + ); + const tightTextHeight = TextHeightBehavior( + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, + ); + + return LayoutBuilder( + builder: (context, constraints) { + final showTimes = constraints.maxHeight >= 38; + return Container( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: dividerColor, width: 0.5)), + ), + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + if (showTimes) + Positioned( + top: 3, + left: 0, + right: 0, + child: Text( + _format(period.start), + style: timeStyle, + textAlign: TextAlign.center, + textHeightBehavior: tightTextHeight, + ), + ), + Text( + '${period.name}.', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w500, + height: 1.0, + ), + textAlign: TextAlign.center, + textHeightBehavior: tightTextHeight, + ), + if (showTimes) + Positioned( + bottom: 3, + left: 0, + right: 0, + child: Text( + _format(period.end), + style: timeStyle, + textAlign: TextAlign.center, + textHeightBehavior: tightTextHeight, + ), + ), + ], + ), + ); + }, + ); + } + + static String _format(TimeOfDay t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; +} + +class _DayColumn extends StatelessWidget { + final DateTime date; + final LessonPeriodSchedule schedule; + final List appointments; + final List timeRegions; + final double pxPerHour; + final DateTime today; + final ValueListenable nowNotifier; + final void Function(Appointment) onAppointmentTap; + final bool Function(Appointment) isCrossedOut; + final void Function(DateTime start, DateTime end)? onCreateEvent; + + const _DayColumn({ + required this.date, + required this.schedule, + required this.appointments, + required this.timeRegions, + required this.pxPerHour, + required this.today, + required this.nowNotifier, + required this.onAppointmentTap, + required this.isCrossedOut, + required this.onCreateEvent, + }); + + double _y(int hour, int minute) => + (hour + minute / 60 - kCalendarStartHour) * pxPerHour; + + double _yFromDate(DateTime t) => _y(t.hour, t.minute); + + /// Snaps an appointment edge to the nearest period boundary if the gap is small, + /// so small inter-period transitions (Wechselzeiten) appear as part of the lesson visually. + double _yForAppointmentEdge(DateTime t, {required bool isStart}) { + final tMin = t.hour * 60 + t.minute; + for (final period in schedule.periods) { + if (period.isBreak) continue; + final pStart = period.start.hour * 60 + period.start.minute; + final pEnd = period.end.hour * 60 + period.end.minute; + if (isStart) { + final delta = tMin - pStart; + if (delta >= 0 && delta < 5) { + return _y(period.start.hour, period.start.minute); + } + } else { + final delta = pEnd - tMin; + if (delta >= 0 && delta < 5) { + // Snap to the next non-break period's start when the gap is short + // (Wechselzeit). Skips into a break never extends the lesson. + final idx = schedule.periods.indexOf(period); + if (idx + 1 < schedule.periods.length) { + final next = schedule.periods[idx + 1]; + if (!next.isBreak) { + final nextStart = next.start.hour * 60 + next.start.minute; + if (nextStart - pEnd < 10) { + return _y(next.start.hour, next.start.minute); + } + } + } + } + } + } + return _yFromDate(t); + } + + /// Returns the lesson period (non-break) that the given y-offset falls into, + /// or the next upcoming non-break period if y falls inside a break or before + /// the first period. Returns null if y is past the last period of the day. + LessonPeriod? _periodAt(double y) { + final hoursDecimal = y / pxPerHour + kCalendarStartHour; + final tappedMinutes = (hoursDecimal * 60).round(); + + LessonPeriod? upcoming; + for (final p in schedule.periods) { + if (p.isBreak) continue; + final pStart = p.start.hour * 60 + p.start.minute; + final pEnd = p.end.hour * 60 + p.end.minute; + if (tappedMinutes >= pStart && tappedMinutes < pEnd) return p; + if (tappedMinutes < pStart) { + upcoming = p; + break; + } + } + return upcoming; + } + + bool _overlapsExistingAppointment(DateTime start, DateTime end, List dayAppts) { + for (final a in dayAppts) { + if (a.endTime.isAfter(start) && a.startTime.isBefore(end)) return true; + } + return false; + } + + void _handleLongPress(LongPressStartDetails details, List dayAppts) { + if (onCreateEvent == null) return; + final period = _periodAt(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); + if (_overlapsExistingAppointment(start, end, dayAppts)) return; + + HapticFeedback.mediumImpact(); + onCreateEvent!(start, end); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final dayAppointments = appointments; + final dayRegions = _expandRegionsForDay(timeRegions, date); + final isToday = _isSameDay(date, today); + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPressStart: (details) => _handleLongPress(details, dayAppointments), + child: Container( + decoration: BoxDecoration( + color: isToday ? theme.colorScheme.primary.withAlpha(14) : null, + border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)), + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + for (final period in schedule.periods) + Positioned( + top: _y(period.start.hour, period.start.minute), + left: 0, + right: 0, + child: Container( + height: 0.5, + color: theme.dividerColor.withAlpha(60), + ), + ), + for (final region in dayRegions) + Positioned( + top: _yFromDate(region.start), + height: + (_yFromDate(region.end) - _yFromDate(region.start)).clamp(0, double.infinity), + left: 0, + right: 0, + child: TimeRegionTile(region: region.region), + ), + for (final apt in dayAppointments) + Positioned( + top: _yForAppointmentEdge(apt.startTime, isStart: true), + height: (_yForAppointmentEdge(apt.endTime, isStart: false) - + _yForAppointmentEdge(apt.startTime, isStart: true)) + .clamp(0, double.infinity), + left: 1, + right: 1, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onAppointmentTap(apt), + child: AppointmentTile( + appointment: apt, + crossedOut: isCrossedOut(apt), + ), + ), + ), + if (isToday) + ValueListenableBuilder( + valueListenable: nowNotifier, + builder: (_, now, child) => + _CurrentTimeMarker(now: now, pxPerHour: pxPerHour, theme: theme), + ), + ], + ), + ), + ); + } +} + +class _CurrentTimeMarker extends StatelessWidget { + final DateTime now; + final double pxPerHour; + final ThemeData theme; + + const _CurrentTimeMarker({ + required this.now, + required this.pxPerHour, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + final y = (now.hour + now.minute / 60 + now.second / 3600 - kCalendarStartHour) * pxPerHour; + final maxY = (kCalendarEndHour - kCalendarStartHour) * pxPerHour; + if (y < 0 || y > maxY) return const SizedBox.shrink(); + + return AnimatedPositioned( + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + top: y - 1, + left: 0, + right: 0, + child: IgnorePointer( + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + height: 2, + color: theme.colorScheme.primary, + ), + Positioned( + top: -3, + left: -4, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: theme.colorScheme.primary, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _BoundRegion { + final TimeRegion region; + final DateTime start; + final DateTime end; + + _BoundRegion({required this.region, required this.start, required this.end}); +} + +List<_BoundRegion> _expandRegionsForDay(List regions, DateTime day) { + final result = <_BoundRegion>[]; + final dayStart = DateTime(day.year, day.month, day.day); + for (final region in regions) { + final isRecurringDaily = region.recurrenceRule != null && + region.recurrenceRule!.toUpperCase().contains('FREQ=DAILY'); + if (isRecurringDaily) { + final start = dayStart.add(Duration( + hours: region.startTime.hour, + minutes: region.startTime.minute, + )); + final end = dayStart.add(Duration( + hours: region.endTime.hour, + minutes: region.endTime.minute, + )); + result.add(_BoundRegion(region: region, start: start, end: end)); + } else if (_isSameDay(region.startTime, day)) { + result.add(_BoundRegion( + region: region, + start: region.startTime, + end: region.endTime, + )); + } + } + return result; +} + +bool _isSameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; + +/// Expands the given list of appointments across the visible 5-day work week, +/// resolving any RRULE-based recurrences into per-day synthetic instances. +/// Returns a list of length 5 (Monday..Friday); each entry holds the +/// appointments occurring on that day, with `startTime` and `endTime` shifted +/// to the actual occurrence date (preserving time-of-day and duration). The +/// original `id`, `subject`, `color`, `location` and `notes` are kept so taps +/// still resolve to the correct underlying event. +List> _expandAppointmentsForWeek( + List appointments, DateTime weekStart) { + final perDay = List>.generate(5, (_) => []); + final weekEnd = weekStart.add(const Duration(days: 5)); + final weekStartUtc = weekStart.toUtc(); + final weekEndUtc = weekEnd.toUtc(); + + for (final a in appointments) { + final rule = a.recurrenceRule; + if (rule == null || rule.isEmpty) { + final idx = a.startTime.difference(weekStart).inDays; + if (idx >= 0 && idx < 5) perDay[idx].add(a); + continue; + } + try { + final parsed = RecurrenceRule.fromString(rule); + final anchorUtc = a.startTime.toUtc(); + final duration = a.endTime.difference(a.startTime); + for (final occUtc in parsed.getInstances(start: anchorUtc)) { + if (!occUtc.isBefore(weekEndUtc)) break; + if (occUtc.isBefore(weekStartUtc)) continue; + final occLocal = occUtc.toLocal(); + final idx = DateTime(occLocal.year, occLocal.month, occLocal.day) + .difference(weekStart) + .inDays; + if (idx < 0 || idx >= 5) continue; + final newStart = DateTime(occLocal.year, occLocal.month, occLocal.day, + a.startTime.hour, a.startTime.minute); + perDay[idx].add(Appointment( + id: a.id, + startTime: newStart, + endTime: newStart.add(duration), + subject: a.subject, + color: a.color, + location: a.location, + notes: a.notes, + )); + } + } catch (_) { + // Malformed RRULE → behave as non-recurring (anchor day only). + final idx = a.startTime.difference(weekStart).inDays; + if (idx >= 0 && idx < 5) perDay[idx].add(a); + } + } + return perDay; +} diff --git a/lib/view/pages/timetable/widgets/lesson_appointment_source.dart b/lib/view/pages/timetable/widgets/lesson_appointment_source.dart deleted file mode 100644 index 9269184..0000000 --- a/lib/view/pages/timetable/widgets/lesson_appointment_source.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:syncfusion_flutter_calendar/calendar.dart'; - -class LessonAppointmentSource extends CalendarDataSource { - LessonAppointmentSource(List source) { - appointments = source; - } -} diff --git a/lib/view/pages/timetable/widgets/special_regions_builder.dart b/lib/view/pages/timetable/widgets/special_regions_builder.dart index 0ab0115..6587b93 100644 --- a/lib/view/pages/timetable/widgets/special_regions_builder.dart +++ b/lib/view/pages/timetable/widgets/special_regions_builder.dart @@ -3,32 +3,38 @@ import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; import '../../../../extensions/dateTime.dart'; +import '../data/calendar_layout.dart'; +import '../data/lesson_period_schedule.dart'; import '../data/webuntis_time.dart'; import 'time_region_tile.dart'; class SpecialRegionsBuilder { final GetHolidaysResponse holidays; + final LessonPeriodSchedule schedule; final ColorScheme colorScheme; final Color disabledColor; SpecialRegionsBuilder({ required this.holidays, + required this.schedule, required this.colorScheme, required this.disabledColor, }); List build() { final lastMonday = DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.monday); - final firstBreak = lastMonday.copyWith(hour: 10, minute: 15); - final secondBreak = lastMonday.copyWith(hour: 13, minute: 50); final holidayRegions = _buildHolidayRegions().toList(); bool isInHoliday(DateTime time) => holidayRegions.any((region) => region.startTime.isSameDay(time)); + final breakRegions = schedule.periods.where((p) => p.isBreak).map((p) { + final start = lastMonday.copyWith(hour: p.start.hour, minute: p.start.minute); + return _breakRegion(start, p.duration); + }).where((region) => !isInHoliday(region.startTime)); + return [ ...holidayRegions, - if (!isInHoliday(firstBreak)) _breakRegion(firstBreak, const Duration(minutes: 20)), - if (!isInHoliday(secondBreak)) _breakRegion(secondBreak, const Duration(minutes: 15)), + ...breakRegions, ]; } @@ -36,9 +42,13 @@ class SpecialRegionsBuilder { final startDay = WebuntisTime.parse(holiday.startDate, 0); final dayCount = WebuntisTime.parse(holiday.endDate, 0).difference(startDay).inDays; final days = List.generate(dayCount, (i) => startDay.add(Duration(days: i))); + final gridStartHour = kCalendarStartHour.floor(); + final gridStartMinute = ((kCalendarStartHour - gridStartHour) * 60).round(); + final gridEndHour = kCalendarEndHour.floor(); + final gridEndMinute = ((kCalendarEndHour - gridEndHour) * 60).round(); return days.map((day) => TimeRegion( - startTime: day.copyWith(hour: 7, minute: 55), - endTime: day.copyWith(hour: 16, minute: 30), + startTime: day.copyWith(hour: gridStartHour, minute: gridStartMinute), + endTime: day.copyWith(hour: gridEndHour, minute: gridEndMinute), text: '$kTimeRegionHolidayPrefix${holiday.name}', color: disabledColor.withAlpha(50), iconData: Icons.holiday_village_outlined, diff --git a/lib/view/pages/timetable/widgets/time_region_tile.dart b/lib/view/pages/timetable/widgets/time_region_tile.dart index 10c074b..9fa946c 100644 --- a/lib/view/pages/timetable/widgets/time_region_tile.dart +++ b/lib/view/pages/timetable/widgets/time_region_tile.dart @@ -5,20 +5,20 @@ const String kTimeRegionCenterIcon = 'centerIcon'; const String kTimeRegionHolidayPrefix = 'holiday:'; class TimeRegionTile extends StatelessWidget { - final TimeRegionDetails details; + final TimeRegion region; - const TimeRegionTile({super.key, required this.details}); + const TimeRegionTile({super.key, required this.region}); @override Widget build(BuildContext context) { - final text = details.region.text ?? ''; - final color = details.region.color; + final text = region.text ?? ''; + final color = region.color; if (text == kTimeRegionCenterIcon) { return Container( color: color, alignment: Alignment.center, - child: Icon(details.region.iconData, size: 17, color: Theme.of(context).primaryColor), + child: Icon(region.iconData, size: 17, color: Theme.of(context).colorScheme.primary), ); } @@ -50,6 +50,6 @@ class TimeRegionTile extends StatelessWidget { ); } - return const Placeholder(); + return const SizedBox.shrink(); } } diff --git a/lib/widget/userAvatar.dart b/lib/widget/userAvatar.dart index a9a5d92..4a9cb8b 100644 --- a/lib/widget/userAvatar.dart +++ b/lib/widget/userAvatar.dart @@ -1,45 +1,139 @@ -import 'package:cached_network_image/cached_network_image.dart'; +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:http/http.dart' as http; import '../model/accountData.dart'; import '../model/endpointData.dart'; -class UserAvatar extends StatelessWidget { +class UserAvatar extends StatefulWidget { final String id; final bool isGroup; final int size; const UserAvatar({required this.id, this.isGroup = false, this.size = 20, super.key}); @override - Widget build(BuildContext context) { - if(isGroup) { - return CircleAvatar( - foregroundImage: Image( - image: CachedNetworkImageProvider( - 'https://${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().full()}/ocs/v2.php/apps/spreed/api/v1/room/$id/avatar', - errorListener: (p0) {} - ) - ).image, - backgroundColor: Theme.of(context).primaryColor, - foregroundColor: Colors.white, - onForegroundImageError: (o, t) {}, - radius: size.toDouble(), - child: Icon(Icons.group, size: size.toDouble()), - ); - } else { - return CircleAvatar( - foregroundImage: Image( - image: CachedNetworkImageProvider( - 'https://${EndpointData().nextcloud().full()}/avatar/$id/$size', - errorListener: (p0) {} - ), - ).image, - backgroundColor: Theme.of(context).primaryColor, - foregroundColor: Colors.white, - onForegroundImageError: (o, t) {}, - radius: size.toDouble(), - child: Icon(Icons.person, size: size.toDouble()), - ); + State createState() => _UserAvatarState(); +} + +class _AvatarPayload { + final Uint8List bytes; + final bool isSvg; + _AvatarPayload(this.bytes, this.isSvg); +} + +final Map> _avatarCache = {}; + +class _UserAvatarState extends State { + late Future<_AvatarPayload?> _payload; + + @override + void initState() { + super.initState(); + _payload = _load(); + } + + @override + void didUpdateWidget(UserAvatar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.id != widget.id || + oldWidget.isGroup != widget.isGroup || + oldWidget.size != widget.size) { + _payload = _load(); } } + + String _url() { + final host = EndpointData().nextcloud().full(); + if (widget.isGroup) { + return 'https://$host/ocs/v2.php/apps/spreed/api/v1/room/${widget.id}/avatar'; + } + return 'https://$host/avatar/${widget.id}/${widget.size}'; + } + + Future<_AvatarPayload?> _load() { + final url = _url(); + return _avatarCache.putIfAbsent(url, () => _fetch(url)); + } + + Future<_AvatarPayload?> _fetch(String url) async { + try { + final auth = base64Encode(utf8.encode(AccountData().buildHttpAuthString())); + final response = await http.get( + Uri.parse(url), + headers: { + 'Authorization': 'Basic $auth', + 'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml', + }, + ); + if (response.statusCode != 200 || response.bodyBytes.isEmpty) return null; + + final contentType = response.headers['content-type']?.toLowerCase() ?? ''; + final bytes = response.bodyBytes; + final isSvg = contentType.contains('svg') || _looksLikeSvg(bytes); + return _AvatarPayload(bytes, isSvg); + } catch (_) { + return null; + } + } + + static bool _looksLikeSvg(Uint8List bytes) { + final head = utf8.decode( + bytes.sublist(0, bytes.length < 256 ? bytes.length : 256), + allowMalformed: true, + ).trimLeft(); + return head.startsWith('( + future: _payload, + builder: (context, snapshot) { + final payload = snapshot.data; + + Widget content; + if (payload == null) { + content = Icon( + widget.isGroup ? Icons.group : Icons.person, + size: radius, + color: Colors.white, + ); + } else if (payload.isSvg) { + content = SvgPicture.memory( + payload.bytes, + width: radius * 2, + height: radius * 2, + fit: BoxFit.cover, + ); + } else { + content = Image.memory( + payload.bytes, + width: radius * 2, + height: radius * 2, + fit: BoxFit.cover, + gaplessPlayback: true, + ); + } + + return CircleAvatar( + radius: radius, + backgroundColor: theme.primaryColor, + foregroundColor: Colors.white, + child: ClipOval( + child: SizedBox( + width: radius * 2, + height: radius * 2, + child: content, + ), + ), + ); + }, + ); + } } diff --git a/pubspec.yaml b/pubspec.yaml index aa90db4..a8be8b4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,6 +50,7 @@ dependencies: flutter_login: ^6.0.0 flutter_native_splash: ^2.4.4 flutter_split_view: ^0.1.2 + flutter_svg: ^2.0.10 freezed_annotation: ^3.1.0 http: ^1.3.0 hydrated_bloc: ^11.0.0 From bee5c02a4faf1289dcc0c7606255637cc502dab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Tue, 5 May 2026 16:05:07 +0200 Subject: [PATCH 03/23] marianum appointments --- lib/state/app/modules/app_modules.dart | 9 + .../bloc/marianum_dates_bloc.dart | 33 + .../bloc/marianum_dates_event.dart | 9 + .../bloc/marianum_dates_state.dart | 29 + .../bloc/marianum_dates_state.freezed.dart | 588 ++++++++++++++++++ .../bloc/marianum_dates_state.g.dart | 41 ++ .../marianum_dates_get_events.dart | 48 ++ .../repository/marianum_dates_repository.dart | 7 + .../view/marianum_dates_view.dart | 135 ++++ .../modules/settings/bloc/settings_cubit.dart | 17 +- .../custom_event_edit_dialog.dart | 64 +- lib/view/settings/defaultSettings.dart | 3 +- pubspec.yaml | 1 + 13 files changed, 971 insertions(+), 13 deletions(-) create mode 100644 lib/state/app/modules/marianumDates/bloc/marianum_dates_bloc.dart create mode 100644 lib/state/app/modules/marianumDates/bloc/marianum_dates_event.dart create mode 100644 lib/state/app/modules/marianumDates/bloc/marianum_dates_state.dart create mode 100644 lib/state/app/modules/marianumDates/bloc/marianum_dates_state.freezed.dart create mode 100644 lib/state/app/modules/marianumDates/bloc/marianum_dates_state.g.dart create mode 100644 lib/state/app/modules/marianumDates/dataProvider/marianum_dates_get_events.dart create mode 100644 lib/state/app/modules/marianumDates/repository/marianum_dates_repository.dart create mode 100644 lib/state/app/modules/marianumDates/view/marianum_dates_view.dart diff --git a/lib/state/app/modules/app_modules.dart b/lib/state/app/modules/app_modules.dart index 10b6b36..81bb355 100644 --- a/lib/state/app/modules/app_modules.dart +++ b/lib/state/app/modules/app_modules.dart @@ -15,6 +15,7 @@ import 'settings/bloc/settings_cubit.dart'; import '../infrastructure/loadableState/loadable_state.dart'; import 'gradeAverages/view/grade_averages_view.dart'; import 'holidays/view/holidays_view.dart'; +import 'marianumDates/view/marianum_dates_view.dart'; import 'marianumMessage/view/marianum_message_list_view.dart'; import 'package:badges/badges.dart' as badges; @@ -98,6 +99,13 @@ class AppModule { breakerArea: BreakerArea.more, create: HolidaysView.new, ), + Modules.marianumDates: AppModule( + Modules.marianumDates, + name: 'Marianum Termine', + icon: () => Icon(Icons.event_note), + breakerArea: BreakerArea.more, + create: MarianumDatesView.new, + ), }; if(!showFiltered) available.removeWhere((key, value) => settings.val().modulesSettings.hiddenModules.contains(key)); @@ -140,4 +148,5 @@ enum Modules { roomPlan, gradeAveragesCalculator, holidays, + marianumDates, } diff --git a/lib/state/app/modules/marianumDates/bloc/marianum_dates_bloc.dart b/lib/state/app/modules/marianumDates/bloc/marianum_dates_bloc.dart new file mode 100644 index 0000000..3f7961b --- /dev/null +++ b/lib/state/app/modules/marianumDates/bloc/marianum_dates_bloc.dart @@ -0,0 +1,33 @@ +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../repository/marianum_dates_repository.dart'; +import 'marianum_dates_event.dart'; +import 'marianum_dates_state.dart'; + +class MarianumDatesBloc extends LoadableHydratedBloc { + MarianumDatesBloc() { + 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() ?? []; + + @override + fromNothing() => const MarianumDatesState(showPastEvents: false, events: []); + @override + fromStorage(Map json) => MarianumDatesState.fromJson(json); + @override + Future gatherData() async { + final events = await repo.getEvents(); + add(DataGathered((state) => state.copyWith(events: events))); + } + @override + repository() => MarianumDatesRepository(); + @override + Map? toStorage(state) => state.toJson(); +} diff --git a/lib/state/app/modules/marianumDates/bloc/marianum_dates_event.dart b/lib/state/app/modules/marianumDates/bloc/marianum_dates_event.dart new file mode 100644 index 0000000..34b5b8d --- /dev/null +++ b/lib/state/app/modules/marianumDates/bloc/marianum_dates_event.dart @@ -0,0 +1,9 @@ +import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import 'marianum_dates_state.dart'; + +sealed class MarianumDatesEvent extends LoadableHydratedBlocEvent {} + +class SetPastEventsVisible extends MarianumDatesEvent { + final bool shouldBeVisible; + SetPastEventsVisible(this.shouldBeVisible); +} diff --git a/lib/state/app/modules/marianumDates/bloc/marianum_dates_state.dart b/lib/state/app/modules/marianumDates/bloc/marianum_dates_state.dart new file mode 100644 index 0000000..d3a7d14 --- /dev/null +++ b/lib/state/app/modules/marianumDates/bloc/marianum_dates_state.dart @@ -0,0 +1,29 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:flutter/foundation.dart'; + +part 'marianum_dates_state.freezed.dart'; +part 'marianum_dates_state.g.dart'; + +@freezed +abstract class MarianumDatesState with _$MarianumDatesState { + const factory MarianumDatesState({ + required bool showPastEvents, + required List events, + }) = _MarianumDatesState; + + factory MarianumDatesState.fromJson(Map json) => _$MarianumDatesStateFromJson(json); +} + +@freezed +abstract class MarianumDate with _$MarianumDate { + const factory MarianumDate({ + required String uid, + required String title, + required String? description, + required DateTime start, + required DateTime end, + required bool isAllDay, + }) = _MarianumDate; + + factory MarianumDate.fromJson(Map json) => _$MarianumDateFromJson(json); +} diff --git a/lib/state/app/modules/marianumDates/bloc/marianum_dates_state.freezed.dart b/lib/state/app/modules/marianumDates/bloc/marianum_dates_state.freezed.dart new file mode 100644 index 0000000..7ef98c8 --- /dev/null +++ b/lib/state/app/modules/marianumDates/bloc/marianum_dates_state.freezed.dart @@ -0,0 +1,588 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'marianum_dates_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$MarianumDatesState implements DiagnosticableTreeMixin { + + bool get showPastEvents; List get events; +/// Create a copy of MarianumDatesState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$MarianumDatesStateCopyWith get copyWith => _$MarianumDatesStateCopyWithImpl(this as MarianumDatesState, _$identity); + + /// Serializes this MarianumDatesState to a JSON map. + Map toJson(); + +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'MarianumDatesState')) + ..add(DiagnosticsProperty('showPastEvents', showPastEvents))..add(DiagnosticsProperty('events', events)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is MarianumDatesState&&(identical(other.showPastEvents, showPastEvents) || other.showPastEvents == showPastEvents)&&const DeepCollectionEquality().equals(other.events, events)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,showPastEvents,const DeepCollectionEquality().hash(events)); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'MarianumDatesState(showPastEvents: $showPastEvents, events: $events)'; +} + + +} + +/// @nodoc +abstract mixin class $MarianumDatesStateCopyWith<$Res> { + factory $MarianumDatesStateCopyWith(MarianumDatesState value, $Res Function(MarianumDatesState) _then) = _$MarianumDatesStateCopyWithImpl; +@useResult +$Res call({ + bool showPastEvents, List events +}); + + + + +} +/// @nodoc +class _$MarianumDatesStateCopyWithImpl<$Res> + implements $MarianumDatesStateCopyWith<$Res> { + _$MarianumDatesStateCopyWithImpl(this._self, this._then); + + final MarianumDatesState _self; + final $Res Function(MarianumDatesState) _then; + +/// Create a copy of MarianumDatesState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? showPastEvents = null,Object? events = null,}) { + return _then(_self.copyWith( +showPastEvents: null == showPastEvents ? _self.showPastEvents : showPastEvents // ignore: cast_nullable_to_non_nullable +as bool,events: null == events ? _self.events : events // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [MarianumDatesState]. +extension MarianumDatesStatePatterns on MarianumDatesState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _MarianumDatesState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _MarianumDatesState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _MarianumDatesState value) $default,){ +final _that = this; +switch (_that) { +case _MarianumDatesState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _MarianumDatesState value)? $default,){ +final _that = this; +switch (_that) { +case _MarianumDatesState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( bool showPastEvents, List events)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _MarianumDatesState() when $default != null: +return $default(_that.showPastEvents,_that.events);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( bool showPastEvents, List events) $default,) {final _that = this; +switch (_that) { +case _MarianumDatesState(): +return $default(_that.showPastEvents,_that.events);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool showPastEvents, List events)? $default,) {final _that = this; +switch (_that) { +case _MarianumDatesState() when $default != null: +return $default(_that.showPastEvents,_that.events);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _MarianumDatesState with DiagnosticableTreeMixin implements MarianumDatesState { + const _MarianumDatesState({required this.showPastEvents, required final List events}): _events = events; + factory _MarianumDatesState.fromJson(Map json) => _$MarianumDatesStateFromJson(json); + +@override final bool showPastEvents; + final List _events; +@override List get events { + if (_events is EqualUnmodifiableListView) return _events; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_events); +} + + +/// Create a copy of MarianumDatesState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$MarianumDatesStateCopyWith<_MarianumDatesState> get copyWith => __$MarianumDatesStateCopyWithImpl<_MarianumDatesState>(this, _$identity); + +@override +Map toJson() { + return _$MarianumDatesStateToJson(this, ); +} +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'MarianumDatesState')) + ..add(DiagnosticsProperty('showPastEvents', showPastEvents))..add(DiagnosticsProperty('events', events)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _MarianumDatesState&&(identical(other.showPastEvents, showPastEvents) || other.showPastEvents == showPastEvents)&&const DeepCollectionEquality().equals(other._events, _events)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,showPastEvents,const DeepCollectionEquality().hash(_events)); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'MarianumDatesState(showPastEvents: $showPastEvents, events: $events)'; +} + + +} + +/// @nodoc +abstract mixin class _$MarianumDatesStateCopyWith<$Res> implements $MarianumDatesStateCopyWith<$Res> { + factory _$MarianumDatesStateCopyWith(_MarianumDatesState value, $Res Function(_MarianumDatesState) _then) = __$MarianumDatesStateCopyWithImpl; +@override @useResult +$Res call({ + bool showPastEvents, List events +}); + + + + +} +/// @nodoc +class __$MarianumDatesStateCopyWithImpl<$Res> + implements _$MarianumDatesStateCopyWith<$Res> { + __$MarianumDatesStateCopyWithImpl(this._self, this._then); + + final _MarianumDatesState _self; + final $Res Function(_MarianumDatesState) _then; + +/// Create a copy of MarianumDatesState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? showPastEvents = null,Object? events = null,}) { + return _then(_MarianumDatesState( +showPastEvents: null == showPastEvents ? _self.showPastEvents : showPastEvents // ignore: cast_nullable_to_non_nullable +as bool,events: null == events ? _self._events : events // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + + +/// @nodoc +mixin _$MarianumDate implements DiagnosticableTreeMixin { + + String get uid; String get title; String? get description; DateTime get start; DateTime get end; bool get isAllDay; +/// Create a copy of MarianumDate +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$MarianumDateCopyWith get copyWith => _$MarianumDateCopyWithImpl(this as MarianumDate, _$identity); + + /// Serializes this MarianumDate to a JSON map. + Map toJson(); + +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'MarianumDate')) + ..add(DiagnosticsProperty('uid', uid))..add(DiagnosticsProperty('title', title))..add(DiagnosticsProperty('description', description))..add(DiagnosticsProperty('start', start))..add(DiagnosticsProperty('end', end))..add(DiagnosticsProperty('isAllDay', isAllDay)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is MarianumDate&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.start, start) || other.start == start)&&(identical(other.end, end) || other.end == end)&&(identical(other.isAllDay, isAllDay) || other.isAllDay == isAllDay)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,uid,title,description,start,end,isAllDay); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'MarianumDate(uid: $uid, title: $title, description: $description, start: $start, end: $end, isAllDay: $isAllDay)'; +} + + +} + +/// @nodoc +abstract mixin class $MarianumDateCopyWith<$Res> { + factory $MarianumDateCopyWith(MarianumDate value, $Res Function(MarianumDate) _then) = _$MarianumDateCopyWithImpl; +@useResult +$Res call({ + String uid, String title, String? description, DateTime start, DateTime end, bool isAllDay +}); + + + + +} +/// @nodoc +class _$MarianumDateCopyWithImpl<$Res> + implements $MarianumDateCopyWith<$Res> { + _$MarianumDateCopyWithImpl(this._self, this._then); + + final MarianumDate _self; + final $Res Function(MarianumDate) _then; + +/// Create a copy of MarianumDate +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? uid = null,Object? title = null,Object? description = freezed,Object? start = null,Object? end = null,Object? isAllDay = null,}) { + return _then(_self.copyWith( +uid: null == uid ? _self.uid : uid // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String?,start: null == start ? _self.start : start // ignore: cast_nullable_to_non_nullable +as DateTime,end: null == end ? _self.end : end // ignore: cast_nullable_to_non_nullable +as DateTime,isAllDay: null == isAllDay ? _self.isAllDay : isAllDay // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [MarianumDate]. +extension MarianumDatePatterns on MarianumDate { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _MarianumDate value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _MarianumDate() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _MarianumDate value) $default,){ +final _that = this; +switch (_that) { +case _MarianumDate(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _MarianumDate value)? $default,){ +final _that = this; +switch (_that) { +case _MarianumDate() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String uid, String title, String? description, DateTime start, DateTime end, bool isAllDay)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _MarianumDate() when $default != null: +return $default(_that.uid,_that.title,_that.description,_that.start,_that.end,_that.isAllDay);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String uid, String title, String? description, DateTime start, DateTime end, bool isAllDay) $default,) {final _that = this; +switch (_that) { +case _MarianumDate(): +return $default(_that.uid,_that.title,_that.description,_that.start,_that.end,_that.isAllDay);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String uid, String title, String? description, DateTime start, DateTime end, bool isAllDay)? $default,) {final _that = this; +switch (_that) { +case _MarianumDate() when $default != null: +return $default(_that.uid,_that.title,_that.description,_that.start,_that.end,_that.isAllDay);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _MarianumDate with DiagnosticableTreeMixin implements MarianumDate { + const _MarianumDate({required this.uid, required this.title, required this.description, required this.start, required this.end, required this.isAllDay}); + factory _MarianumDate.fromJson(Map json) => _$MarianumDateFromJson(json); + +@override final String uid; +@override final String title; +@override final String? description; +@override final DateTime start; +@override final DateTime end; +@override final bool isAllDay; + +/// Create a copy of MarianumDate +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$MarianumDateCopyWith<_MarianumDate> get copyWith => __$MarianumDateCopyWithImpl<_MarianumDate>(this, _$identity); + +@override +Map toJson() { + return _$MarianumDateToJson(this, ); +} +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'MarianumDate')) + ..add(DiagnosticsProperty('uid', uid))..add(DiagnosticsProperty('title', title))..add(DiagnosticsProperty('description', description))..add(DiagnosticsProperty('start', start))..add(DiagnosticsProperty('end', end))..add(DiagnosticsProperty('isAllDay', isAllDay)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _MarianumDate&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.start, start) || other.start == start)&&(identical(other.end, end) || other.end == end)&&(identical(other.isAllDay, isAllDay) || other.isAllDay == isAllDay)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,uid,title,description,start,end,isAllDay); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'MarianumDate(uid: $uid, title: $title, description: $description, start: $start, end: $end, isAllDay: $isAllDay)'; +} + + +} + +/// @nodoc +abstract mixin class _$MarianumDateCopyWith<$Res> implements $MarianumDateCopyWith<$Res> { + factory _$MarianumDateCopyWith(_MarianumDate value, $Res Function(_MarianumDate) _then) = __$MarianumDateCopyWithImpl; +@override @useResult +$Res call({ + String uid, String title, String? description, DateTime start, DateTime end, bool isAllDay +}); + + + + +} +/// @nodoc +class __$MarianumDateCopyWithImpl<$Res> + implements _$MarianumDateCopyWith<$Res> { + __$MarianumDateCopyWithImpl(this._self, this._then); + + final _MarianumDate _self; + final $Res Function(_MarianumDate) _then; + +/// Create a copy of MarianumDate +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? uid = null,Object? title = null,Object? description = freezed,Object? start = null,Object? end = null,Object? isAllDay = null,}) { + return _then(_MarianumDate( +uid: null == uid ? _self.uid : uid // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String?,start: null == start ? _self.start : start // ignore: cast_nullable_to_non_nullable +as DateTime,end: null == end ? _self.end : end // ignore: cast_nullable_to_non_nullable +as DateTime,isAllDay: null == isAllDay ? _self.isAllDay : isAllDay // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/lib/state/app/modules/marianumDates/bloc/marianum_dates_state.g.dart b/lib/state/app/modules/marianumDates/bloc/marianum_dates_state.g.dart new file mode 100644 index 0000000..e923443 --- /dev/null +++ b/lib/state/app/modules/marianumDates/bloc/marianum_dates_state.g.dart @@ -0,0 +1,41 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'marianum_dates_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_MarianumDatesState _$MarianumDatesStateFromJson(Map json) => + _MarianumDatesState( + showPastEvents: json['showPastEvents'] as bool, + events: (json['events'] as List) + .map((e) => MarianumDate.fromJson(e as Map)) + .toList(), + ); + +Map _$MarianumDatesStateToJson(_MarianumDatesState instance) => + { + 'showPastEvents': instance.showPastEvents, + 'events': instance.events, + }; + +_MarianumDate _$MarianumDateFromJson(Map json) => + _MarianumDate( + uid: json['uid'] as String, + title: json['title'] as String, + description: json['description'] as String?, + start: DateTime.parse(json['start'] as String), + end: DateTime.parse(json['end'] as String), + isAllDay: json['isAllDay'] as bool, + ); + +Map _$MarianumDateToJson(_MarianumDate instance) => + { + 'uid': instance.uid, + 'title': instance.title, + 'description': instance.description, + 'start': instance.start.toIso8601String(), + 'end': instance.end.toIso8601String(), + 'isAllDay': instance.isAllDay, + }; diff --git a/lib/state/app/modules/marianumDates/dataProvider/marianum_dates_get_events.dart b/lib/state/app/modules/marianumDates/dataProvider/marianum_dates_get_events.dart new file mode 100644 index 0000000..2ab5ecd --- /dev/null +++ b/lib/state/app/modules/marianumDates/dataProvider/marianum_dates_get_events.dart @@ -0,0 +1,48 @@ +import 'package:dio/dio.dart'; +import 'package:enough_icalendar/enough_icalendar.dart'; + +import '../bloc/marianum_dates_state.dart'; + +class MarianumDatesGetEvents { + static const String url = 'https://public-cal.marianumlan.de/cal_public/ad4c5da8-7466-9c72-89cb-8b8d9a5cf26c'; + + final Dio _dio = Dio(BaseOptions( + connectTimeout: const Duration(seconds: 10).inMilliseconds, + receiveTimeout: const Duration(seconds: 30).inMilliseconds, + )); + + Future> run() async { + final response = await _dio.get(url); + final body = response.data; + if (body == null || body.isEmpty) return []; + + final root = VComponent.parse(body); + final calendar = root is VCalendar ? root : null; + final source = calendar?.children ?? root.children; + + final events = source.whereType().map(_toMarianumDate).whereType().toList(); + events.sort((a, b) => a.start.compareTo(b.start)); + return events; + } + + static MarianumDate? _toMarianumDate(VEvent e) { + final start = e.start; + if (start == null) return null; + final end = e.end ?? start; + final isAllDay = _isAllDay(start, end); + return MarianumDate( + uid: e.uid, + title: e.summary ?? '', + description: e.description, + start: start, + end: end, + isAllDay: isAllDay, + ); + } + + static bool _isAllDay(DateTime start, DateTime end) { + final startMidnight = start.hour == 0 && start.minute == 0 && start.second == 0; + final endMidnight = end.hour == 0 && end.minute == 0 && end.second == 0; + return startMidnight && endMidnight && end.difference(start).inHours % 24 == 0; + } +} diff --git a/lib/state/app/modules/marianumDates/repository/marianum_dates_repository.dart b/lib/state/app/modules/marianumDates/repository/marianum_dates_repository.dart new file mode 100644 index 0000000..416b4d5 --- /dev/null +++ b/lib/state/app/modules/marianumDates/repository/marianum_dates_repository.dart @@ -0,0 +1,7 @@ +import '../../../infrastructure/repository/repository.dart'; +import '../bloc/marianum_dates_state.dart'; +import '../dataProvider/marianum_dates_get_events.dart'; + +class MarianumDatesRepository extends Repository { + Future> getEvents() => MarianumDatesGetEvents().run(); +} diff --git a/lib/state/app/modules/marianumDates/view/marianum_dates_view.dart b/lib/state/app/modules/marianumDates/view/marianum_dates_view.dart new file mode 100644 index 0000000..4f283d3 --- /dev/null +++ b/lib/state/app/modules/marianumDates/view/marianum_dates_view.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; + +import '../../../../../view/pages/timetable/custom_events/custom_event_edit_dialog.dart'; +import '../../../../../widget/animatedTime.dart'; +import '../../../../../widget/centeredLeading.dart'; +import '../../../../../widget/debug/debugTile.dart'; +import '../../../../../widget/list_view_util.dart'; +import '../../../infrastructure/loadableState/view/loadable_state_consumer.dart'; +import '../../../infrastructure/utilityWidgets/bloc_module.dart'; +import '../../../infrastructure/loadableState/loadable_state.dart'; +import '../bloc/marianum_dates_bloc.dart'; +import '../bloc/marianum_dates_event.dart'; +import '../bloc/marianum_dates_state.dart'; + +class MarianumDatesView extends StatelessWidget { + const MarianumDatesView({super.key}); + + @override + Widget build(BuildContext context) => BlocModule>( + create: (context) => MarianumDatesBloc(), + autoRebuild: true, + child: (context, bloc, state) => Scaffold( + appBar: AppBar( + title: const Text('Marianum Termine'), + actions: [ + PopupMenuButton( + initialValue: bloc.showPastEvents(), + icon: const Icon(Icons.history), + itemBuilder: (context) => [true, false].map((e) => PopupMenuItem( + value: e, + enabled: e != bloc.showPastEvents(), + child: Row( + children: [ + Icon(e ? Icons.history_outlined : Icons.history_toggle_off_outlined, color: Theme.of(context).colorScheme.onSurface), + const SizedBox(width: 15), + Text(e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen'), + ], + ), + )).toList(), + onSelected: (e) => bloc.add(SetPastEventsVisible(e)), + ), + ], + ), + body: LoadableStateConsumer( + child: (state, loading) => ListViewUtil.fromList(bloc.getEvents(), (event) => _MarianumDateTile(event: event)), + ), + ), + ); +} + +class _MarianumDateTile extends StatelessWidget { + final MarianumDate event; + const _MarianumDateTile({required this.event}); + + String _formatSubtitle() { + final start = Jiffy.parseFromDateTime(event.start); + final end = Jiffy.parseFromDateTime(event.end); + + if (event.isAllDay) { + // iCal end is exclusive for multi-day all-day events. The feed sets + // DTSTART == DTEND for single-day all-day events, so only subtract a + // day when end actually advances past start. + final inclusiveEnd = event.end.isAfter(event.start) ? end.subtract(days: 1) : end; + final sameAllDay = start.format(pattern: 'yyyy-MM-dd') == inclusiveEnd.format(pattern: 'yyyy-MM-dd'); + return sameAllDay + ? '${start.format(pattern: 'dd.MM.yyyy')} · Ganztägig' + : '${start.format(pattern: 'dd.MM.yyyy')} – ${inclusiveEnd.format(pattern: 'dd.MM.yyyy')} · Ganztägig'; + } + + final sameDay = start.format(pattern: 'yyyy-MM-dd') == end.format(pattern: 'yyyy-MM-dd'); + if (sameDay) { + if (event.start == event.end) { + return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')}'; + } + return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')} – ${end.format(pattern: 'HH:mm')}'; + } + return '${start.format(pattern: 'dd.MM.yyyy HH:mm')} – ${end.format(pattern: 'dd.MM.yyyy HH:mm')}'; + } + + @override + Widget build(BuildContext context) => ListTile( + leading: const CenteredLeading(Icon(Icons.event)), + title: Text(event.title.isEmpty ? '(ohne Titel)' : event.title), + subtitle: Text(_formatSubtitle()), + onTap: () => _showDetails(context), + trailing: IconButton( + icon: const Icon(Icons.add_circle_outline), + tooltip: 'In Stundenplan übernehmen', + onPressed: () => showDialog( + context: context, + builder: (_) => CustomEventEditDialog( + initialTitle: event.title, + initialDescription: event.description, + initialStart: event.start, + initialEnd: event.end, + ), + barrierDismissible: false, + ), + ), + ); + + void _showDetails(BuildContext context) { + showDialog( + context: context, + builder: (context) => SimpleDialog( + title: Text(event.title.isEmpty ? '(ohne Titel)' : event.title), + children: [ + ListTile( + leading: const CenteredLeading(Icon(Icons.date_range_outlined)), + title: Text(_formatSubtitle()), + ), + if (event.description != null && event.description!.trim().isNotEmpty) + ListTile( + leading: const CenteredLeading(Icon(Icons.notes_outlined)), + title: Text(event.description!.trim()), + ), + Visibility( + visible: !event.start.difference(DateTime.now()).isNegative, + replacement: ListTile( + leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)), + title: Text(Jiffy.parseFromDateTime(event.start).fromNow()), + ), + child: ListTile( + leading: const CenteredLeading(Icon(Icons.timer_outlined)), + title: AnimatedTime(callback: () => event.start.difference(DateTime.now())), + subtitle: Text(Jiffy.parseFromDateTime(event.start).fromNow()), + ), + ), + DebugTile(context).jsonData(event.toJson()), + ], + ), + ); + } +} diff --git a/lib/state/app/modules/settings/bloc/settings_cubit.dart b/lib/state/app/modules/settings/bloc/settings_cubit.dart index 19cbc2d..68ca12a 100644 --- a/lib/state/app/modules/settings/bloc/settings_cubit.dart +++ b/lib/state/app/modules/settings/bloc/settings_cubit.dart @@ -5,6 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../storage/base/settings.dart'; import '../../../../../view/settings/defaultSettings.dart'; +import '../../app_modules.dart'; class SettingsCubit extends HydratedCubit { static const _debounceTag = 'settings_persist'; @@ -36,16 +37,28 @@ class SettingsCubit extends HydratedCubit { @override Settings fromJson(Map json) { try { - return Settings.fromJson(json); + return _appendNewModules(Settings.fromJson(json)); } catch (_) { try { - return Settings.fromJson(_mergeSettings(json, DefaultSettings.get().toJson())); + return _appendNewModules(Settings.fromJson(_mergeSettings(json, DefaultSettings.get().toJson()))); } catch (_) { return DefaultSettings.get(); } } } + // Modules added in newer app versions won't appear in a previously persisted + // moduleOrder. Append any enum value that is neither ordered nor hidden so it + // becomes visible in the "Mehr" menu without forcing a full settings reset. + Settings _appendNewModules(Settings s) { + final order = s.modulesSettings.moduleOrder; + final hidden = s.modulesSettings.hiddenModules; + final missing = Modules.values.where((m) => !order.contains(m) && !hidden.contains(m)); + if (missing.isEmpty) return s; + s.modulesSettings.moduleOrder = [...order, ...missing]; + return s; + } + @override Map? toJson(Settings state) => state.toJson(); 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 58d6f9e..f886131 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 @@ -17,11 +17,15 @@ class CustomEventEditDialog extends StatefulWidget { final CustomTimetableEvent? existingEvent; final DateTime? initialStart; final DateTime? initialEnd; + final String? initialTitle; + final String? initialDescription; const CustomEventEditDialog({ this.existingEvent, this.initialStart, this.initialEnd, + this.initialTitle, + this.initialDescription, super.key, }); @@ -30,15 +34,21 @@ class CustomEventEditDialog extends StatefulWidget { } class _CustomEventEditDialogState extends State { + // Visible window of the timetable / time picker (matches `_pickTimeRange`'s + // `disabledTime`). Pre-filled times from outside this window are clamped in. + static const TimeOfDay _windowStart = TimeOfDay(hour: 8, minute: 0); + static const TimeOfDay _windowEnd = TimeOfDay(hour: 16, minute: 30); + static const int _minDurationMinutes = 15; + late DateTime _date = widget.existingEvent?.startDate ?? widget.initialStart ?? DateTime.now(); - late TimeOfDay _startTime = widget.existingEvent?.startDate.toTimeOfDay() ?? - widget.initialStart?.toTimeOfDay() ?? - const TimeOfDay(hour: 8, minute: 0); - late TimeOfDay _endTime = widget.existingEvent?.endDate.toTimeOfDay() ?? - widget.initialEnd?.toTimeOfDay() ?? - const TimeOfDay(hour: 9, minute: 30); - late final TextEditingController _name = TextEditingController(text: widget.existingEvent?.title); - late final TextEditingController _description = TextEditingController(text: widget.existingEvent?.description); + late TimeOfDay _startTime; + late TimeOfDay _endTime; + late final TextEditingController _name = TextEditingController( + text: widget.existingEvent?.title ?? widget.initialTitle, + ); + late final TextEditingController _description = TextEditingController( + text: widget.existingEvent?.description ?? widget.initialDescription, + ); late String _rrule = widget.existingEvent?.rrule ?? ''; late CustomTimetableColors _color = CustomTimetableColors.values.firstWhere( (e) => e.name == widget.existingEvent?.color, @@ -47,6 +57,37 @@ class _CustomEventEditDialogState extends State { bool get _isEditing => widget.existingEvent != null; + @override + void initState() { + super.initState(); + if (_isEditing) { + _startTime = widget.existingEvent!.startDate.toTimeOfDay(); + _endTime = widget.existingEvent!.endDate.toTimeOfDay(); + return; + } + final rawStart = widget.initialStart?.toTimeOfDay() ?? _windowStart; + final rawEnd = widget.initialEnd?.toTimeOfDay() ?? const TimeOfDay(hour: 9, minute: 30); + final clamped = _clampToVisibleWindow(rawStart, rawEnd); + _startTime = clamped.$1; + _endTime = clamped.$2; + } + + static (TimeOfDay, TimeOfDay) _clampToVisibleWindow(TimeOfDay rawStart, TimeOfDay rawEnd) { + int toMin(TimeOfDay t) => t.hour * 60 + t.minute; + TimeOfDay fromMin(int m) => TimeOfDay(hour: m ~/ 60, minute: m % 60); + + final windowStart = toMin(_windowStart); + final windowEnd = toMin(_windowEnd); + var start = toMin(rawStart).clamp(windowStart, windowEnd - _minDurationMinutes); + var end = toMin(rawEnd); + if (end < start + _minDurationMinutes) end = start + _minDurationMinutes; + if (end > windowEnd) { + end = windowEnd; + start = end - _minDurationMinutes; + } + return (fromMin(start), fromMin(end)); + } + bool _validate() => _name.text.isNotEmpty; void _save() { @@ -79,11 +120,14 @@ class _CustomEventEditDialogState extends State { } Future _pickDate() async { + final now = DateTime.now(); + final defaultFirst = now.subtract(const Duration(days: 30)); + final defaultLast = now.add(const Duration(days: 365)); final picked = await showDatePicker( context: context, initialDate: _date, - firstDate: DateTime.now().subtract(const Duration(days: 30)), - lastDate: DateTime.now().add(const Duration(days: 30)), + firstDate: _date.isBefore(defaultFirst) ? _date : defaultFirst, + lastDate: _date.isAfter(defaultLast) ? _date : defaultLast, ); if (picked != null && picked != _date) setState(() => _date = picked); } diff --git a/lib/view/settings/defaultSettings.dart b/lib/view/settings/defaultSettings.dart index 9348a6d..3e2fac8 100644 --- a/lib/view/settings/defaultSettings.dart +++ b/lib/view/settings/defaultSettings.dart @@ -27,7 +27,8 @@ class DefaultSettings { Modules.marianumMessage, Modules.roomPlan, Modules.gradeAveragesCalculator, - Modules.holidays + Modules.holidays, + Modules.marianumDates, ], hiddenModules: [], ), diff --git a/pubspec.yaml b/pubspec.yaml index a8be8b4..776b411 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -84,6 +84,7 @@ dependencies: time_range_picker: ^2.3.0 url_launcher: ^6.3.1 uuid: ^4.5.1 + enough_icalendar: ^0.17.0 dev_dependencies: flutter_launcher_icons: ^0.14.3 From db9c3386f180948886c1b38775a96ad3ad158dae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Tue, 5 May 2026 21:07:48 +0200 Subject: [PATCH 04/23] better loading indicators for timetables, talk and files --- lib/api/apiError.dart | 2 +- .../autocomplete/autocompleteApi.dart | 3 +- .../files-sharing/fileSharingApi.dart | 3 +- .../marianumcloud/talk/chat/getChatCache.dart | 10 +- .../marianumcloud/talk/room/getRoomCache.dart | 11 +- lib/api/marianumcloud/talk/talkApi.dart | 3 +- .../webdav/queries/listFiles/listFiles.dart | 15 +- .../queries/listFiles/listFilesCache.dart | 14 +- lib/api/marianumcloud/webdav/webdavApi.dart | 6 +- lib/api/requestCache.dart | 23 ++- lib/model/accountData.dart | 11 +- .../view/loadable_state_consumer.dart | 45 +++- .../view/loadable_state_error_bar.dart | 85 ++++---- .../app/modules/chat/bloc/chat_bloc.dart | 48 +++-- .../chat/dataProvider/chat_data_provider.dart | 24 ++- .../modules/chatList/bloc/chat_list_bloc.dart | 41 +++- .../dataProvider/chat_list_data_provider.dart | 22 +- .../app/modules/files/bloc/files_bloc.dart | 36 +++- .../dataProvider/files_data_provider.dart | 29 ++- lib/storage/general/modulesSettings.g.dart | 1 + lib/view/pages/files/fileElement.dart | 5 +- lib/view/pages/files/files.dart | 4 +- lib/view/pages/talk/chatView.dart | 195 +++++++++--------- .../pages/talk/components/chatMessage.dart | 3 +- lib/widget/userAvatar.dart | 3 +- 25 files changed, 439 insertions(+), 203 deletions(-) diff --git a/lib/api/apiError.dart b/lib/api/apiError.dart index 42fe5c2..9a56012 100644 --- a/lib/api/apiError.dart +++ b/lib/api/apiError.dart @@ -1,4 +1,4 @@ -class ApiError { +class ApiError implements Exception { String message; ApiError(this.message); diff --git a/lib/api/marianumcloud/autocomplete/autocompleteApi.dart b/lib/api/marianumcloud/autocomplete/autocompleteApi.dart index f11b91c..883168b 100644 --- a/lib/api/marianumcloud/autocomplete/autocompleteApi.dart +++ b/lib/api/marianumcloud/autocomplete/autocompleteApi.dart @@ -20,8 +20,9 @@ class AutocompleteApi { var headers = {}; headers.putIfAbsent('Accept', () => 'application/json'); headers.putIfAbsent('OCS-APIRequest', () => 'true'); + headers.putIfAbsent('Authorization', AccountData().getBasicAuthHeader); - var endpoint = Uri.https('${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().domain}', '${EndpointData().nextcloud().path}/ocs/v2.php/core/autocomplete/get', getParameters); + var endpoint = Uri.https(EndpointData().nextcloud().domain, '${EndpointData().nextcloud().path}/ocs/v2.php/core/autocomplete/get', getParameters); var response = await http.get(endpoint, headers: headers); if(response.statusCode != HttpStatus.ok) throw Exception('Api call failed with ${response.statusCode}: ${response.body}'); diff --git a/lib/api/marianumcloud/files-sharing/fileSharingApi.dart b/lib/api/marianumcloud/files-sharing/fileSharingApi.dart index 42d5dd8..a2b8317 100644 --- a/lib/api/marianumcloud/files-sharing/fileSharingApi.dart +++ b/lib/api/marianumcloud/files-sharing/fileSharingApi.dart @@ -11,8 +11,9 @@ class FileSharingApi { var headers = {}; headers.putIfAbsent('Accept', () => 'application/json'); headers.putIfAbsent('OCS-APIRequest', () => 'true'); + headers.putIfAbsent('Authorization', AccountData().getBasicAuthHeader); - var endpoint = Uri.https('${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().domain}', '${EndpointData().nextcloud().path}/ocs/v2.php/apps/files_sharing/api/v1/shares', query.toJson().map((key, value) => MapEntry(key, value.toString()))); + var endpoint = Uri.https(EndpointData().nextcloud().domain, '${EndpointData().nextcloud().path}/ocs/v2.php/apps/files_sharing/api/v1/shares', query.toJson().map((key, value) => MapEntry(key, value.toString()))); var response = await http.post(endpoint, headers: headers); if(response.statusCode != HttpStatus.ok) { diff --git a/lib/api/marianumcloud/talk/chat/getChatCache.dart b/lib/api/marianumcloud/talk/chat/getChatCache.dart index 60de7c1..f564a48 100644 --- a/lib/api/marianumcloud/talk/chat/getChatCache.dart +++ b/lib/api/marianumcloud/talk/chat/getChatCache.dart @@ -8,7 +8,15 @@ import 'getChatResponse.dart'; class GetChatCache extends RequestCache { String chatToken; - GetChatCache({required void Function(GetChatResponse) onUpdate, required this.chatToken}) : super(RequestCache.cacheNothing, onUpdate) { + GetChatCache({ + required void Function(GetChatResponse) onUpdate, + void Function(Exception)? onError, + required this.chatToken, + }) : super( + RequestCache.cacheNothing, + onUpdate, + onError: onError ?? RequestCache.ignore, + ) { start('nc-chat-$chatToken'); } diff --git a/lib/api/marianumcloud/talk/room/getRoomCache.dart b/lib/api/marianumcloud/talk/room/getRoomCache.dart index 54f8578..d632b9e 100644 --- a/lib/api/marianumcloud/talk/room/getRoomCache.dart +++ b/lib/api/marianumcloud/talk/room/getRoomCache.dart @@ -7,7 +7,16 @@ import 'getRoomParams.dart'; import 'getRoomResponse.dart'; class GetRoomCache extends RequestCache { - GetRoomCache({void Function(GetRoomResponse)? onUpdate, bool? renew}) : super(RequestCache.cacheMinute, onUpdate, renew: renew) { + GetRoomCache({ + void Function(GetRoomResponse)? onUpdate, + void Function(Exception)? onError, + bool? renew, + }) : super( + RequestCache.cacheMinute, + onUpdate, + onError: onError ?? RequestCache.ignore, + renew: renew, + ) { start('nc-rooms'); } diff --git a/lib/api/marianumcloud/talk/talkApi.dart b/lib/api/marianumcloud/talk/talkApi.dart index e79340f..a72b5f5 100644 --- a/lib/api/marianumcloud/talk/talkApi.dart +++ b/lib/api/marianumcloud/talk/talkApi.dart @@ -34,11 +34,12 @@ abstract class TalkApi extends ApiRequest { getParameters?.update(key, (value) => value.toString()); }); - var endpoint = Uri.https('${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().domain}', '${EndpointData().nextcloud().path}/ocs/v2.php/apps/spreed/api/$path', getParameters); + var endpoint = Uri.https(EndpointData().nextcloud().domain, '${EndpointData().nextcloud().path}/ocs/v2.php/apps/spreed/api/$path', getParameters); headers ??= {}; headers?.putIfAbsent('Accept', () => 'application/json'); headers?.putIfAbsent('OCS-APIRequest', () => 'true'); + headers?.putIfAbsent('Authorization', AccountData().getBasicAuthHeader); http.Response? data; diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFiles.dart b/lib/api/marianumcloud/webdav/queries/listFiles/listFiles.dart index 438204d..8582989 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFiles.dart +++ b/lib/api/marianumcloud/webdav/queries/listFiles/listFiles.dart @@ -11,9 +11,22 @@ class ListFiles extends WebdavApi { ListFiles(this.params) : super(params); + // The Nextcloud root listing is significantly slower than subdirectories on + // our instance, so it gets a much longer ceiling. Subfolders fall back to a + // tighter timeout to keep the UI responsive. + static const Duration _rootTimeout = Duration(minutes: 3); + static const Duration _subfolderTimeout = Duration(seconds: 30); + + bool get _isRoot { + final p = params.path.replaceAll('/', '').trim(); + return p.isEmpty; + } + @override Future run() async { - var davFiles = (await (await WebdavApi.webdav).propfind(PathUri.parse(params.path))).toWebDavFiles(); + final webdav = await WebdavApi.webdav; + final timeout = _isRoot ? _rootTimeout : _subfolderTimeout; + final davFiles = (await webdav.propfind(PathUri.parse(params.path)).timeout(timeout)).toWebDavFiles(); var files = davFiles.map(CacheableFile.fromDavFile).toSet(); // webdav handles subdirectories wrong, this is a fix diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart b/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart index 2a3e2fc..ac7d5b3 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart +++ b/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart @@ -9,7 +9,19 @@ import 'listFilesResponse.dart'; class ListFilesCache extends RequestCache { String path; - ListFilesCache({required void Function(ListFilesResponse) onUpdate, required this.path}) : super(RequestCache.cacheNothing, onUpdate) { + ListFilesCache({ + required void Function(ListFilesResponse) onUpdate, + void Function(ListFilesResponse)? onCacheData, + void Function(ListFilesResponse)? onNetworkData, + void Function(Exception)? onError, + required this.path, + }) : super( + RequestCache.cacheNothing, + onUpdate, + onError: onError ?? RequestCache.ignore, + onCacheData: onCacheData, + onNetworkData: onNetworkData, + ) { var bytes = utf8.encode('MarianumMobile-$path'); var cacheName = md5.convert(bytes).toString(); start('wd-folder-$cacheName'); diff --git a/lib/api/marianumcloud/webdav/webdavApi.dart b/lib/api/marianumcloud/webdav/webdavApi.dart index cfa7159..1baf291 100644 --- a/lib/api/marianumcloud/webdav/webdavApi.dart +++ b/lib/api/marianumcloud/webdav/webdavApi.dart @@ -15,9 +15,11 @@ abstract class WebdavApi extends ApiRequest { Future run(); static Future webdav = establishWebdavConnection(); - static Future webdavConnectString = buildWebdavConnectString(); static Future establishWebdavConnection() async => NextcloudClient(Uri.parse('https://${EndpointData().nextcloud().full()}'), password: AccountData().getPassword(), loginName: AccountData().getUsername()).webdav; - static Future buildWebdavConnectString() async => 'https://${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().full()}/remote.php/dav/files/${AccountData().getUsername()}/'; + /// Builds the WebDAV download URL without embedded credentials. Callers must + /// authenticate via the [AccountData.authHeaders] header instead. + static String buildWebdavUrl() => + 'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${AccountData().getUsername()}/'; } diff --git a/lib/api/requestCache.dart b/lib/api/requestCache.dart index 2588a22..acfe990 100644 --- a/lib/api/requestCache.dart +++ b/lib/api/requestCache.dart @@ -15,6 +15,15 @@ abstract class RequestCache { int maxCacheTime; void Function(T)? onUpdate; + + /// Called only when [start] finds a cached payload in localstore. Use this + /// (instead of [onUpdate]) when callers need to distinguish stale-but-fast + /// cache hits from authoritative network responses. + void Function(T)? onCacheData; + + /// Called only when [start] receives a fresh payload from the network. + void Function(T)? onNetworkData; + void Function(Exception) onError; bool? renew; @@ -26,7 +35,14 @@ abstract class RequestCache { /// attempt have settled. Future get ready => _ready.future; - RequestCache(this.maxCacheTime, this.onUpdate, {this.onError = ignore, this.renew = false}); + RequestCache( + this.maxCacheTime, + this.onUpdate, { + this.onError = ignore, + this.renew = false, + this.onCacheData, + this.onNetworkData, + }); static void ignore(Exception e) {} @@ -34,7 +50,9 @@ abstract class RequestCache { try { final tableData = await Localstore.instance.collection(collection).doc(document).get(); if (tableData != null) { - onUpdate?.call(onLocalData(tableData['json'])); + final cached = onLocalData(tableData['json']); + onUpdate?.call(cached); + onCacheData?.call(cached); } if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < (tableData?['lastupdate'] ?? 0)) { @@ -44,6 +62,7 @@ abstract class RequestCache { try { final newValue = await onLoad(); onUpdate?.call(newValue); + onNetworkData?.call(newValue); Localstore.instance.collection(collection).doc(document).set({ 'json': jsonEncode(newValue), 'lastupdate': DateTime.now().millisecondsSinceEpoch, diff --git a/lib/model/accountData.dart b/lib/model/accountData.dart index 03e2f53..66db8c8 100644 --- a/lib/model/accountData.dart +++ b/lib/model/accountData.dart @@ -98,8 +98,15 @@ class AccountData { bool isPopulated() => _username != null && _password != null; - String buildHttpAuthString() { + /// Returns the value for an HTTP `Authorization` header using HTTP Basic. + /// Prefer this over embedding credentials in URLs — error logs and crash + /// reports often capture the URL but not headers. + String getBasicAuthHeader() { if (!isPopulated()) throw Exception('AccountData (e.g. username or password) is not initialized!'); - return '$_username:$_password'; + return 'Basic ${base64Encode(utf8.encode('$_username:$_password'))}'; } + + /// Convenience wrapper around [getBasicAuthHeader] returning a single-entry + /// header map ready to merge into HTTP request headers. + Map authHeaders() => {'Authorization': getBasicAuthHeader()}; } diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart b/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart index 4b5b557..ce45fcd 100644 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart +++ b/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart @@ -17,7 +17,21 @@ class LoadableStateConsumer onLoad!(loadedData)); } + final typedData = loadedData is TState ? loadedData : null; + final hasContent = typedData != null && (isReady?.call(typedData) ?? loadableState.showContent()); + final hasError = loadableState.error != null; + final isLoading = loadableState.isLoading; + + final showPrimaryLoading = isLoading && !hasContent; + final showBackgroundLoading = isLoading && hasContent; + final showError = hasError && !hasContent; + final showErrorBar = hasError && hasContent; + var childWidget = ConditionalWrapper( condition: loadableState.reFetch != null, wrapper: (child) => RefreshIndicator( @@ -48,8 +72,8 @@ class LoadableStateConsumer AnimatedSize( - duration: animationDuration, - child: AnimatedSwitcher( - duration: animationDuration, - transitionBuilder: (Widget child, Animation animation) => SlideTransition( - position: Tween( - begin: const Offset(0.0, -1.0), - end: Offset.zero, - ).animate(animation), - child: child, - ), - child: Visibility( - key: Key(visible.hashCode.toString()), - visible: visible, - replacement: const SizedBox(width: double.infinity), - child: Builder( - builder: (context) { - var bloc = context.watch(); - return InkWell( - onTap: () { - if(!bloc.isConnected()) return; - InfoDialog.show(context, 'Exception: ${message.toString()}'); - }, - child: Container( - height: 20, - decoration: BoxDecoration( - color: bloc.connectionColor(context), + Widget build(BuildContext context) { + final bloc = context.watch(); + final isOfflineWithCache = hasContent && bloc.connectivityStatusKnown() && !bloc.isConnected(); + final shouldShow = visible || isOfflineWithCache; + + return AnimatedSize( + duration: animationDuration, + child: AnimatedSwitcher( + duration: animationDuration, + transitionBuilder: (Widget child, Animation animation) => SlideTransition( + position: Tween( + begin: const Offset(0.0, -1.0), + end: Offset.zero, + ).animate(animation), + child: child, + ), + child: Visibility( + 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; + InfoDialog.show(context, 'Exception: ${message.toString()}'); + }, + child: Container( + height: 20, + decoration: BoxDecoration( + color: bloc.connectionColor(context), + ), + child: LoadableStateErrorBarText(lastUpdated: lastUpdated), ), - child: LoadableStateErrorBarText(lastUpdated: lastUpdated), - ), - ); - }, - ) - ) - ), - ); + ); + }, + ) + ) + ), + ); + } } class LoadableStateErrorBarText extends StatefulWidget { diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index e89d7a9..c3a45ca 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -1,3 +1,5 @@ +import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; +import '../../../infrastructure/loadableState/loading_error.dart'; import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; import '../repository/chat_repository.dart'; @@ -22,8 +24,11 @@ class ChatBloc extends LoadableHydratedBloc gatherData() async { final token = innerState?.currentToken ?? ''; - if (token.isEmpty) return; - _loadChat(token); + if (token.isEmpty) { + add(DataGathered((s) => s)); + return; + } + await _loadChat(token); } void setToken(String token) { @@ -32,6 +37,7 @@ class ChatBloc extends LoadableHydratedBloc s.copyWith(currentToken: token, chatResponse: null))); + add(RefetchStarted()); _loadChat(token); } @@ -41,19 +47,37 @@ class ChatBloc extends LoadableHydratedBloc()); + _loadChat(token); } - void _loadChat(String token) { + Future _loadChat(String token) async { final requestStart = DateTime.now(); _lastTokenSet = requestStart; - repo.data.getChat( - token: token, - onUpdate: (data) { - if (_lastTokenSet.isAfter(requestStart)) return; - if ((innerState?.currentToken ?? '') != token) return; - add(DataGathered((s) => s.copyWith(chatResponse: data))); - }, - ); + + Object? capturedError; + GetChatResponse? response; + try { + response = await repo.data.getChat( + token: token, + onError: (e) => capturedError = e, + ); + } catch (e) { + capturedError = e; + } + + if (_lastTokenSet.isAfter(requestStart)) return; + if ((innerState?.currentToken ?? '') != token) return; + + if (response != null) { + add(DataGathered((s) => s.copyWith(chatResponse: response))); + } + if (capturedError != null) { + add(Error(LoadingError( + message: capturedError.toString(), + allowRetry: true, + ))); + } } } diff --git a/lib/state/app/modules/chat/dataProvider/chat_data_provider.dart b/lib/state/app/modules/chat/dataProvider/chat_data_provider.dart index dab8899..25bdccc 100644 --- a/lib/state/app/modules/chat/dataProvider/chat_data_provider.dart +++ b/lib/state/app/modules/chat/dataProvider/chat_data_provider.dart @@ -2,10 +2,26 @@ import '../../../../../api/marianumcloud/talk/chat/getChatCache.dart'; import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; class ChatDataProvider { - void getChat({ + Future getChat({ required String token, - required void Function(GetChatResponse data) onUpdate, - }) { - GetChatCache(chatToken: token, onUpdate: onUpdate); + void Function(GetChatResponse data)? onUpdate, + void Function(Object)? onError, + }) async { + GetChatResponse? latest; + Object? capturedError; + final cache = GetChatCache( + chatToken: token, + onUpdate: (data) { + latest = data; + onUpdate?.call(data); + }, + onError: (e) { + capturedError = e; + onError?.call(e); + }, + ); + await cache.ready; + if (latest != null) return latest!; + throw capturedError ?? Exception('No data and no error from getChat'); } } diff --git a/lib/state/app/modules/chatList/bloc/chat_list_bloc.dart b/lib/state/app/modules/chatList/bloc/chat_list_bloc.dart index 2080fae..e4be8fa 100644 --- a/lib/state/app/modules/chatList/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chatList/bloc/chat_list_bloc.dart @@ -1,5 +1,6 @@ import 'package:flutter_app_badge/flutter_app_badge.dart'; +import '../../../infrastructure/loadableState/loading_error.dart'; import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; import '../repository/chat_list_repository.dart'; @@ -7,6 +8,14 @@ import 'chat_list_event.dart'; import 'chat_list_state.dart'; class ChatListBloc extends LoadableHydratedBloc { + bool _forceRenew = false; + + @override + void retry() { + _forceRenew = true; + super.retry(); + } + @override ChatListRepository repository() => ChatListRepository(); @@ -21,15 +30,39 @@ class ChatListBloc extends LoadableHydratedBloc gatherData() async { - final rooms = await repo.data.getRooms(); + final renew = _forceRenew; + _forceRenew = false; + + Object? capturedError; + final rooms = await repo.data.getRooms( + renew: renew, + onError: (e) => capturedError = e, + ); add(DataGathered((s) => s.copyWith(rooms: rooms))); _updateAppBadge(rooms); + + if (capturedError != null) throw capturedError!; } Future refresh({bool renew = true}) async { - final rooms = await repo.data.getRooms(renew: renew); - add(DataGathered((s) => s.copyWith(rooms: rooms))); - _updateAppBadge(rooms); + add(RefetchStarted()); + Object? capturedError; + try { + final rooms = await repo.data.getRooms( + renew: renew, + onError: (e) => capturedError = e, + ); + add(DataGathered((s) => s.copyWith(rooms: rooms))); + _updateAppBadge(rooms); + } catch (e) { + capturedError = e; + } + if (capturedError != null) { + add(Error(LoadingError( + message: capturedError.toString(), + allowRetry: true, + ))); + } } Future createDirectChat(String invite) async { diff --git a/lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart b/lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart index d7bf80e..3bf5c62 100644 --- a/lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart +++ b/lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart @@ -1,20 +1,26 @@ -import 'dart:async'; - import '../../../../../api/marianumcloud/talk/room/getRoomCache.dart'; import '../../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; import '../../../../../api/marianumcloud/talk/createRoom/createRoom.dart'; import '../../../../../api/marianumcloud/talk/createRoom/createRoomParams.dart'; class ChatListDataProvider { - Future getRooms({bool renew = false}) { - final completer = Completer(); - GetRoomCache( + Future getRooms({ + void Function(Object)? onError, + bool renew = false, + }) async { + GetRoomResponse? latest; + Object? capturedError; + final cache = GetRoomCache( renew: renew, - onUpdate: (data) { - if (!completer.isCompleted) completer.complete(data); + onUpdate: (data) => latest = data, + onError: (e) { + capturedError = e; + onError?.call(e); }, ); - return completer.future; + await cache.ready; + if (latest != null) return latest!; + throw capturedError ?? Exception('No data and no error from getRooms'); } Future createDirectRoom(String invite) => diff --git a/lib/state/app/modules/files/bloc/files_bloc.dart b/lib/state/app/modules/files/bloc/files_bloc.dart index 98eff6e..3c6406b 100644 --- a/lib/state/app/modules/files/bloc/files_bloc.dart +++ b/lib/state/app/modules/files/bloc/files_bloc.dart @@ -1,3 +1,5 @@ +import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart'; +import '../../../infrastructure/loadableState/loading_error.dart'; import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; import '../repository/files_repository.dart'; @@ -28,12 +30,14 @@ class FilesBloc extends LoadableHydratedBloc refresh() async { + add(RefetchStarted()); final path = innerState?.currentPath ?? initialPath; await _query(path); } Future setPath(List path) async { add(Emit((s) => s.copyWith(currentPath: path, listing: null))); + add(RefetchStarted()); await _query(path); } @@ -45,8 +49,34 @@ class FilesBloc extends LoadableHydratedBloc _query(List path) async { final pathString = path.isEmpty ? '/' : path.join('/'); - final listing = await repo.data.listFiles(pathString); - listing.files.removeWhere((file) => file.name.isEmpty || file.name == path.lastOrNull); - add(DataGathered((s) => s.copyWith(listing: listing))); + + Object? capturedError; + ListFilesResponse? listing; + try { + listing = await repo.data.listFiles( + pathString, + onCacheData: (cached) { + // 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); + add(Emit((s) => s.copyWith(listing: cached))); + }, + onError: (e) => capturedError = e, + ); + } catch (e) { + capturedError = e; + } + + if (listing != null) { + listing.files.removeWhere((file) => file.name.isEmpty || file.name == path.lastOrNull); + add(DataGathered((s) => s.copyWith(listing: listing))); + } + if (capturedError != null) { + add(Error(LoadingError( + message: capturedError.toString(), + allowRetry: true, + ))); + } } } diff --git a/lib/state/app/modules/files/dataProvider/files_data_provider.dart b/lib/state/app/modules/files/dataProvider/files_data_provider.dart index dda4716..8b1fef4 100644 --- a/lib/state/app/modules/files/dataProvider/files_data_provider.dart +++ b/lib/state/app/modules/files/dataProvider/files_data_provider.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:nextcloud/nextcloud.dart'; import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart'; @@ -7,15 +5,30 @@ import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesRespo import '../../../../../api/marianumcloud/webdav/webdavApi.dart'; class FilesDataProvider { - Future listFiles(String path) { - final completer = Completer(); - ListFilesCache( + /// Lists files at [path]. Cached payload is delivered via [onCacheData] as + /// soon as it is read from disk, so callers can render stale data while the + /// network call is still pending. The Future itself resolves once both the + /// cache lookup and the network attempt have settled, throwing if no payload + /// could be obtained at all. + Future listFiles( + String path, { + void Function(ListFilesResponse)? onCacheData, + void Function(Object)? onError, + }) async { + ListFilesResponse? latest; + Object? capturedError; + final cache = ListFilesCache( path: path, - onUpdate: (data) { - if (!completer.isCompleted) completer.complete(data); + onUpdate: (data) => latest = data, + onCacheData: onCacheData, + onError: (e) { + capturedError = e; + onError?.call(e); }, ); - return completer.future; + await cache.ready; + if (latest != null) return latest!; + throw capturedError ?? Exception('No data and no error from listFiles'); } Future createFolder(String fullPath) async { diff --git a/lib/storage/general/modulesSettings.g.dart b/lib/storage/general/modulesSettings.g.dart index c5a7262..dbc7318 100644 --- a/lib/storage/general/modulesSettings.g.dart +++ b/lib/storage/general/modulesSettings.g.dart @@ -33,4 +33,5 @@ const _$ModulesEnumMap = { Modules.roomPlan: 'roomPlan', Modules.gradeAveragesCalculator: 'gradeAveragesCalculator', Modules.holidays: 'holidays', + Modules.marianumDates: 'marianumDates', }; diff --git a/lib/view/pages/files/fileElement.dart b/lib/view/pages/files/fileElement.dart index cd8b772..6743547 100644 --- a/lib/view/pages/files/fileElement.dart +++ b/lib/view/pages/files/fileElement.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:dio/dio.dart'; import 'package:filesize/filesize.dart'; import 'package:flowder/flowder.dart'; import 'package:flutter/foundation.dart'; @@ -12,6 +13,7 @@ import 'package:path_provider/path_provider.dart'; import '../../../api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart'; import '../../../api/marianumcloud/webdav/webdavApi.dart'; +import '../../../model/accountData.dart'; import '../../../model/endpointData.dart'; import '../../../widget/centeredLeading.dart'; import '../../../widget/confirmDialog.dart'; @@ -41,6 +43,7 @@ class FileElement extends StatefulWidget { file: File(local), progress: ProgressImplementation(), deleteOnCancel: true, + client: Dio(BaseOptions(headers: AccountData().authHeaders())), onDone: () { //Future result = OpenFile.open(local); // TODO legacy - refactor: remove onDone parameter Navigator.of(context).push(MaterialPageRoute(builder: (context) => FileViewer(path: local))); @@ -52,7 +55,7 @@ class FileElement extends StatefulWidget { ); return await Flowder.download( - '${await WebdavApi.webdavConnectString}$encodedPath', + '${WebdavApi.buildWebdavUrl()}$encodedPath', options, ); } diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index 83420bc..257006c 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -164,9 +164,9 @@ class _FilesViewState extends State<_FilesView> { child: const Icon(Icons.add), ), body: LoadableStateConsumer( + isReady: (state) => state.listing != null, child: (state, _) { - final listing = state.listing; - if (listing == null) return const SizedBox.shrink(); + final listing = state.listing!; if (listing.files.isEmpty) { return const PlaceholderView(icon: Icons.folder_off_rounded, text: 'Der Ordner ist leer'); } diff --git a/lib/view/pages/talk/chatView.dart b/lib/view/pages/talk/chatView.dart index 63bf7cc..e570405 100644 --- a/lib/view/pages/talk/chatView.dart +++ b/lib/view/pages/talk/chatView.dart @@ -4,11 +4,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../api/marianumcloud/talk/chat/getChatResponse.dart'; import '../../../api/marianumcloud/talk/room/getRoomResponse.dart'; import '../../../extensions/dateTime.dart'; +import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; import '../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../state/app/modules/chat/bloc/chat_state.dart'; import '../../../theming/appTheme.dart'; import '../../../widget/clickableAppBar.dart'; -import '../../../widget/loadingSpinner.dart'; import '../../../widget/userAvatar.dart'; import 'chatDetails/chatInfo.dart'; import 'components/chatBubble.dart'; @@ -33,108 +33,103 @@ class _ChatViewState extends State { context.read().setToken(widget.room.token); } + List _buildMessages(GetChatResponse response) { + final messages = []; + var lastDate = DateTime.now(); + for (final element in response.sortByTimestamp()) { + final elementDate = DateTime.fromMillisecondsSinceEpoch(element.timestamp * 1000); + + if (element.systemMessage.contains('reaction')) continue; + if (element.systemMessage.contains('poll_voted')) continue; + final commonRead = int.parse(response.headers?['x-chat-last-common-read'] ?? '0'); + + if (!elementDate.isSameDay(lastDate)) { + lastDate = elementDate; + messages.add(ChatBubble( + context: context, + isSender: false, + bubbleData: GetChatResponseObject.getDateDummy(element.timestamp), + chatData: widget.room, + refetch: ({bool renew = false}) => _refresh(), + )); + } + + messages.add(ChatBubble( + context: context, + isSender: element.actorId == widget.selfId && + element.messageType == GetRoomResponseObjectMessageType.comment, + bubbleData: element, + chatData: widget.room, + refetch: ({bool renew = false}) => _refresh(), + isRead: element.id <= commonRead, + selfId: widget.selfId, + )); + } + + if (response.data.length >= 200) { + messages.insert(0, ChatBubble( + context: context, + isSender: false, + bubbleData: GetChatResponseObject.getTextDummy( + 'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. ' + 'Um ältere Nachrichten abzurufen verwende die Webversion unter https://cloud.marianum-fulda.de', + ), + chatData: widget.room, + refetch: ({bool renew = false}) => _refresh(), + )); + } + + return messages; + } + @override - Widget build(BuildContext context) => BlocBuilder( - builder: (context, _) { - final state = context.watch().state.data ?? const ChatState(); - final response = state.chatResponse; - final isLoading = response == null; - - final messages = []; - - if (response != null) { - var lastDate = DateTime.now(); - for (final element in response.sortByTimestamp()) { - final elementDate = DateTime.fromMillisecondsSinceEpoch(element.timestamp * 1000); - - if (element.systemMessage.contains('reaction')) continue; - if (element.systemMessage.contains('poll_voted')) continue; - final commonRead = int.parse(response.headers?['x-chat-last-common-read'] ?? '0'); - - if (!elementDate.isSameDay(lastDate)) { - lastDate = elementDate; - messages.add(ChatBubble( - context: context, - isSender: false, - bubbleData: GetChatResponseObject.getDateDummy(element.timestamp), - chatData: widget.room, - refetch: ({bool renew = false}) => _refresh(), - )); - } - - messages.add(ChatBubble( - context: context, - isSender: element.actorId == widget.selfId && - element.messageType == GetRoomResponseObjectMessageType.comment, - bubbleData: element, - chatData: widget.room, - refetch: ({bool renew = false}) => _refresh(), - isRead: element.id <= commonRead, - selfId: widget.selfId, - )); - } - - if (response.data.length >= 200) { - messages.insert(0, ChatBubble( - context: context, - isSender: false, - bubbleData: GetChatResponseObject.getTextDummy( - 'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. ' - 'Um ältere Nachrichten abzurufen verwende die Webversion unter https://cloud.marianum-fulda.de', - ), - chatData: widget.room, - refetch: ({bool renew = false}) => _refresh(), - )); - } - } - - return Scaffold( - backgroundColor: const Color(0xffefeae2), - appBar: ClickableAppBar( - onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)), - appBar: AppBar( - title: Row( - children: [ - widget.avatar, - const SizedBox(width: 10), - Expanded( - child: Text(widget.room.displayName, overflow: TextOverflow.ellipsis, maxLines: 1), - ), - ], - ), + Widget build(BuildContext context) => Scaffold( + backgroundColor: const Color(0xffefeae2), + appBar: ClickableAppBar( + onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)), + appBar: AppBar( + title: Row( + children: [ + widget.avatar, + const SizedBox(width: 10), + Expanded( + child: Text(widget.room.displayName, overflow: TextOverflow.ellipsis, maxLines: 1), + ), + ], ), ), - body: Container( - decoration: BoxDecoration( - image: DecorationImage( - image: const AssetImage('assets/background/chat.png'), - scale: 1.5, - opacity: 1, - repeat: ImageRepeat.repeat, - invertColors: AppTheme.isDarkMode(context), - ), + ), + body: Container( + decoration: BoxDecoration( + image: DecorationImage( + image: const AssetImage('assets/background/chat.png'), + scale: 1.5, + opacity: 1, + repeat: ImageRepeat.repeat, + invertColors: AppTheme.isDarkMode(context), ), - child: isLoading - ? const LoadingSpinner() - : Column( - children: [ - Expanded( - child: ListView( - reverse: true, - controller: _listController, - children: messages.reversed.toList(), - ), - ), - Container( - color: Theme.of(context).colorScheme.surface, - child: TalkNavigator.isSecondaryVisible(context) - ? ChatTextfield(widget.room.token, selfId: widget.selfId) - : SafeArea(child: ChatTextfield(widget.room.token, selfId: widget.selfId)), - ), - ], - ), ), - ); - }, - ); + child: Column( + children: [ + Expanded( + child: LoadableStateConsumer( + isReady: (state) => + state.chatResponse != null && state.currentToken == widget.room.token, + child: (state, _) => ListView( + reverse: true, + controller: _listController, + children: _buildMessages(state.chatResponse!).reversed.toList(), + ), + ), + ), + Container( + color: Theme.of(context).colorScheme.surface, + child: TalkNavigator.isSecondaryVisible(context) + ? ChatTextfield(widget.room.token, selfId: widget.selfId) + : SafeArea(child: ChatTextfield(widget.room.token, selfId: widget.selfId)), + ), + ], + ), + ), + ); } diff --git a/lib/view/pages/talk/components/chatMessage.dart b/lib/view/pages/talk/components/chatMessage.dart index 468805f..f545fa4 100644 --- a/lib/view/pages/talk/components/chatMessage.dart +++ b/lib/view/pages/talk/components/chatMessage.dart @@ -63,7 +63,8 @@ class ChatMessage { fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, errorListener: (value) {}, - imageUrl: 'https://${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1', + httpHeaders: AccountData().authHeaders(), + imageUrl: '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/widget/userAvatar.dart b/lib/widget/userAvatar.dart index 4a9cb8b..bab843b 100644 --- a/lib/widget/userAvatar.dart +++ b/lib/widget/userAvatar.dart @@ -60,11 +60,10 @@ class _UserAvatarState extends State { Future<_AvatarPayload?> _fetch(String url) async { try { - final auth = base64Encode(utf8.encode(AccountData().buildHttpAuthString())); final response = await http.get( Uri.parse(url), headers: { - 'Authorization': 'Basic $auth', + 'Authorization': AccountData().getBasicAuthHeader(), 'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml', }, ); From 4f796dac2eea3fa7073150500ca2cf768e5c708a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Tue, 5 May 2026 21:44:23 +0200 Subject: [PATCH 05/23] folder restructuring --- .../autocomplete/autocompleteApi.dart | 4 +- .../files-sharing/fileSharingApi.dart | 4 +- lib/api/marianumcloud/talk/talkApi.dart | 4 +- lib/api/marianumcloud/webdav/webdavApi.dart | 4 +- .../userIndex/update/updateUserindex.dart | 2 +- .../queries/authenticate/authenticate.dart | 2 +- lib/api/webuntis/webuntisApi.dart | 2 +- lib/app.dart | 10 +- .../{dateTime.dart => date_time.dart} | 0 ...enderNotNull.dart => render_not_null.dart} | 0 .../{timeOfDay.dart => time_of_day.dart} | 0 lib/main.dart | 8 +- .../{accountData.dart => account_data.dart} | 0 .../{dataCleaner.dart => data_cleaner.dart} | 0 .../{endpointData.dart => endpoint_data.dart} | 2 +- ...ller.dart => notification_controller.dart} | 16 +- ...Service.dart => notification_service.dart} | 0 ...tionTasks.dart => notification_tasks.dart} | 16 +- ...notifyUpdater.dart => notify_updater.dart} | 4 +- lib/routing/app_routes.dart | 202 +++++++++++ .../view/loadable_state_error_bar.dart | 2 +- lib/state/app/modules/app_modules.dart | 23 +- .../modules/settings/bloc/settings_cubit.dart | 2 +- .../dataProvider/timetable_data_provider.dart | 2 +- .../timetable/timetable_name_mode.dart | 2 +- lib/theming/{appTheme.dart => app_theme.dart} | 2 +- ...{darkAppTheme.dart => dark_app_theme.dart} | 0 ...ightAppTheme.dart => light_app_theme.dart} | 0 lib/utils/{FileSaver.dart => file_saver.dart} | 0 lib/utils/{UrlOpener.dart => url_opener.dart} | 0 lib/view/login/login.dart | 2 +- lib/view/pages/files/fileUploadDialog.dart | 0 lib/view/pages/files/files.dart | 8 +- ...adDialog.dart => files_upload_dialog.dart} | 4 +- .../file_element.dart} | 29 +- .../grade_averages_list_view.dart | 4 +- .../grade_averages}/grade_averages_view.dart | 8 +- .../pages/holidays}/holidays_view.dart | 22 +- .../marianum_dates}/marianum_dates_view.dart | 22 +- .../marianum_message_list_view.dart | 14 +- .../marianum_message_view.dart | 4 +- ...edbackDialog.dart => feedback_dialog.dart} | 8 +- ...View.dart => app_share_platform_view.dart} | 0 .../{qrShareView.dart => qr_share_view.dart} | 2 +- ...log.dart => select_share_type_dialog.dart} | 10 +- lib/view/pages/overhang.dart | 27 +- .../settings/data/default_settings.dart} | 24 +- .../settings/sections/about_section.dart | 135 ++++++++ .../settings/sections/account_section.dart | 41 +++ .../settings/sections/appearance_section.dart | 36 ++ .../settings/sections/dev_tools_section.dart} | 23 +- .../settings/sections/files_section.dart | 33 ++ .../pages/settings/sections/talk_section.dart | 72 ++++ .../settings/sections/timetable_section.dart | 57 ++++ lib/view/pages/settings/settings.dart | 37 ++ .../settings/widgets/privacy_info.dart} | 4 +- lib/view/pages/talk/chatList.dart | 142 -------- lib/view/pages/talk/chat_list.dart | 180 ++++++++++ .../talk/{chatView.dart => chat_view.dart} | 16 +- .../chat_bubble_styles.dart} | 2 +- .../chat_message.dart} | 6 +- .../chatInfo.dart => details/chat_info.dart} | 10 +- .../message_reactions.dart} | 16 +- .../participants_list_view.dart} | 4 +- .../talk/{joinChat.dart => join_chat.dart} | 4 +- .../{searchChat.dart => search_chat.dart} | 2 +- ...talkNavigator.dart => talk_navigator.dart} | 0 .../answer_reference.dart} | 2 +- .../chat_bubble.dart} | 202 +---------- .../widgets/chat_message_options_dialog.dart | 202 +++++++++++ .../chat_textfield.dart} | 8 +- .../chatTile.dart => widgets/chat_tile.dart} | 12 +- .../poll_options_list.dart} | 2 +- .../split_view_placeholder.dart} | 2 +- .../custom_events/custom_event_colors.dart | 2 +- .../custom_event_edit_dialog.dart | 6 +- .../custom_events/custom_events_view.dart | 4 +- .../timetable/details/custom_event_sheet.dart | 4 +- .../details/delete_custom_event.dart | 2 +- .../details/webuntis_lesson_sheet.dart | 9 +- lib/view/pages/timetable/timetable.dart | 6 +- .../widgets/special_regions_builder.dart | 2 +- lib/view/settings/settings.dart | 318 ------------------ .../{animatedTime.dart => animated_time.dart} | 0 lib/widget/breaker/breaker.dart | 2 +- ...eredLeading.dart => centered_leading.dart} | 0 ...ableAppBar.dart => clickable_app_bar.dart} | 0 ...confirmDialog.dart => confirm_dialog.dart} | 0 .../debug/{cacheView.dart => cache_view.dart} | 4 +- .../debug/{debugTile.dart => debug_tile.dart} | 4 +- .../{jsonViewer.dart => json_viewer.dart} | 0 ...downDisplay.dart => dropdown_display.dart} | 0 lib/widget/{filePick.dart => file_pick.dart} | 0 .../{fileViewer.dart => file_viewer.dart} | 13 +- ...cusBehaviour.dart => focus_behaviour.dart} | 0 .../{infoDialog.dart => info_dialog.dart} | 0 ...w.dart => large_profile_picture_view.dart} | 2 +- ...adingSpinner.dart => loading_spinner.dart} | 0 ...eholderView.dart => placeholder_view.dart} | 0 ...Origin.dart => share_position_origin.dart} | 0 ...dDialog.dart => unimplemented_dialog.dart} | 0 .../{userAvatar.dart => user_avatar.dart} | 4 +- 102 files changed, 1254 insertions(+), 879 deletions(-) rename lib/extensions/{dateTime.dart => date_time.dart} (100%) rename lib/extensions/{renderNotNull.dart => render_not_null.dart} (100%) rename lib/extensions/{timeOfDay.dart => time_of_day.dart} (100%) rename lib/model/{accountData.dart => account_data.dart} (100%) rename lib/model/{dataCleaner.dart => data_cleaner.dart} (100%) rename lib/model/{endpointData.dart => endpoint_data.dart} (97%) rename lib/notification/{notificationController.dart => notification_controller.dart} (83%) rename lib/notification/{notificationService.dart => notification_service.dart} (100%) rename lib/notification/{notificationTasks.dart => notification_tasks.dart} (57%) rename lib/notification/{notifyUpdater.dart => notify_updater.dart} (95%) create mode 100644 lib/routing/app_routes.dart rename lib/theming/{appTheme.dart => app_theme.dart} (93%) rename lib/theming/{darkAppTheme.dart => dark_app_theme.dart} (100%) rename lib/theming/{lightAppTheme.dart => light_app_theme.dart} (100%) rename lib/utils/{FileSaver.dart => file_saver.dart} (100%) rename lib/utils/{UrlOpener.dart => url_opener.dart} (100%) delete mode 100644 lib/view/pages/files/fileUploadDialog.dart rename lib/view/pages/files/{filesUploadDialog.dart => files_upload_dialog.dart} (99%) rename lib/view/pages/files/{fileElement.dart => widgets/file_element.dart} (86%) rename lib/{state/app/modules/gradeAverages/view => view/pages/grade_averages}/grade_averages_list_view.dart (93%) rename lib/{state/app/modules/gradeAverages/view => view/pages/grade_averages}/grade_averages_view.dart (93%) rename lib/{state/app/modules/holidays/view => view/pages/holidays}/holidays_view.dart (87%) rename lib/{state/app/modules/marianumDates/view => view/pages/marianum_dates}/marianum_dates_view.dart (86%) rename lib/{state/app/modules/marianumMessage/view => view/pages/marianum_message}/marianum_message_list_view.dart (69%) rename lib/{state/app/modules/marianumMessage/view => view/pages/marianum_message}/marianum_message_view.dart (92%) rename lib/view/pages/more/feedback/{feedbackDialog.dart => feedback_dialog.dart} (97%) rename lib/view/pages/more/share/{appSharePlatformView.dart => app_share_platform_view.dart} (100%) rename lib/view/pages/more/share/{qrShareView.dart => qr_share_view.dart} (97%) rename lib/view/pages/more/share/{selectShareTypeDialog.dart => select_share_type_dialog.dart} (84%) rename lib/view/{settings/defaultSettings.dart => pages/settings/data/default_settings.dart} (68%) create mode 100644 lib/view/pages/settings/sections/about_section.dart create mode 100644 lib/view/pages/settings/sections/account_section.dart create mode 100644 lib/view/pages/settings/sections/appearance_section.dart rename lib/view/{settings/devToolsSettings.dart => pages/settings/sections/dev_tools_section.dart} (89%) create mode 100644 lib/view/pages/settings/sections/files_section.dart create mode 100644 lib/view/pages/settings/sections/talk_section.dart create mode 100644 lib/view/pages/settings/sections/timetable_section.dart create mode 100644 lib/view/pages/settings/settings.dart rename lib/view/{settings/privacyInfo.dart => pages/settings/widgets/privacy_info.dart} (90%) delete mode 100644 lib/view/pages/talk/chatList.dart create mode 100644 lib/view/pages/talk/chat_list.dart rename lib/view/pages/talk/{chatView.dart => chat_view.dart} (93%) rename lib/view/pages/talk/{components/chatBubbleStyles.dart => data/chat_bubble_styles.dart} (97%) rename lib/view/pages/talk/{components/chatMessage.dart => data/chat_message.dart} (95%) rename lib/view/pages/talk/{chatDetails/chatInfo.dart => details/chat_info.dart} (91%) rename lib/view/pages/talk/{messageReactions.dart => details/message_reactions.dart} (85%) rename lib/view/pages/talk/{chatDetails/participants/participantsListView.dart => details/participants_list_view.dart} (91%) rename lib/view/pages/talk/{joinChat.dart => join_chat.dart} (96%) rename lib/view/pages/talk/{searchChat.dart => search_chat.dart} (96%) rename lib/view/pages/talk/{talkNavigator.dart => talk_navigator.dart} (100%) rename lib/view/pages/talk/{components/answerReference.dart => widgets/answer_reference.dart} (98%) rename lib/view/pages/talk/{components/chatBubble.dart => widgets/chat_bubble.dart} (60%) create mode 100644 lib/view/pages/talk/widgets/chat_message_options_dialog.dart rename lib/view/pages/talk/{components/chatTextfield.dart => widgets/chat_textfield.dart} (98%) rename lib/view/pages/talk/{components/chatTile.dart => widgets/chat_tile.dart} (96%) rename lib/view/pages/talk/{components/pollOptionsList.dart => widgets/poll_options_list.dart} (98%) rename lib/view/pages/talk/{components/splitViewPlaceholder.dart => widgets/split_view_placeholder.dart} (94%) delete mode 100644 lib/view/settings/settings.dart rename lib/widget/{animatedTime.dart => animated_time.dart} (100%) rename lib/widget/{centeredLeading.dart => centered_leading.dart} (100%) rename lib/widget/{clickableAppBar.dart => clickable_app_bar.dart} (100%) rename lib/widget/{confirmDialog.dart => confirm_dialog.dart} (100%) rename lib/widget/debug/{cacheView.dart => cache_view.dart} (97%) rename lib/widget/debug/{debugTile.dart => debug_tile.dart} (94%) rename lib/widget/debug/{jsonViewer.dart => json_viewer.dart} (100%) rename lib/widget/{dropdownDisplay.dart => dropdown_display.dart} (100%) rename lib/widget/{filePick.dart => file_pick.dart} (100%) rename lib/widget/{fileViewer.dart => file_viewer.dart} (94%) rename lib/widget/{focusBehaviour.dart => focus_behaviour.dart} (100%) rename lib/widget/{infoDialog.dart => info_dialog.dart} (100%) rename lib/widget/{largeProfilePictureView.dart => large_profile_picture_view.dart} (94%) rename lib/widget/{loadingSpinner.dart => loading_spinner.dart} (100%) rename lib/widget/{placeholderView.dart => placeholder_view.dart} (100%) rename lib/widget/{sharePositionOrigin.dart => share_position_origin.dart} (100%) rename lib/widget/{unimplementedDialog.dart => unimplemented_dialog.dart} (100%) rename lib/widget/{userAvatar.dart => user_avatar.dart} (97%) diff --git a/lib/api/marianumcloud/autocomplete/autocompleteApi.dart b/lib/api/marianumcloud/autocomplete/autocompleteApi.dart index 883168b..8c67694 100644 --- a/lib/api/marianumcloud/autocomplete/autocompleteApi.dart +++ b/lib/api/marianumcloud/autocomplete/autocompleteApi.dart @@ -3,8 +3,8 @@ import 'dart:io'; import 'package:http/http.dart' as http; -import '../../../model/accountData.dart'; -import '../../../model/endpointData.dart'; +import '../../../model/account_data.dart'; +import '../../../model/endpoint_data.dart'; import 'autocompleteResponse.dart'; class AutocompleteApi { diff --git a/lib/api/marianumcloud/files-sharing/fileSharingApi.dart b/lib/api/marianumcloud/files-sharing/fileSharingApi.dart index a2b8317..ab1b180 100644 --- a/lib/api/marianumcloud/files-sharing/fileSharingApi.dart +++ b/lib/api/marianumcloud/files-sharing/fileSharingApi.dart @@ -2,8 +2,8 @@ import 'dart:io'; import 'package:http/http.dart' as http; -import '../../../model/accountData.dart'; -import '../../../model/endpointData.dart'; +import '../../../model/account_data.dart'; +import '../../../model/endpoint_data.dart'; import 'fileSharingApiParams.dart'; class FileSharingApi { diff --git a/lib/api/marianumcloud/talk/talkApi.dart b/lib/api/marianumcloud/talk/talkApi.dart index a72b5f5..5f6c31e 100644 --- a/lib/api/marianumcloud/talk/talkApi.dart +++ b/lib/api/marianumcloud/talk/talkApi.dart @@ -2,8 +2,8 @@ import 'dart:developer'; import 'package:http/http.dart' as http; -import '../../../model/accountData.dart'; -import '../../../model/endpointData.dart'; +import '../../../model/account_data.dart'; +import '../../../model/endpoint_data.dart'; import '../../apiError.dart'; import '../../apiParams.dart'; import '../../apiRequest.dart'; diff --git a/lib/api/marianumcloud/webdav/webdavApi.dart b/lib/api/marianumcloud/webdav/webdavApi.dart index 1baf291..5049153 100644 --- a/lib/api/marianumcloud/webdav/webdavApi.dart +++ b/lib/api/marianumcloud/webdav/webdavApi.dart @@ -1,7 +1,7 @@ import 'package:nextcloud/nextcloud.dart'; -import '../../../model/accountData.dart'; -import '../../../model/endpointData.dart'; +import '../../../model/account_data.dart'; +import '../../../model/endpoint_data.dart'; import '../../apiRequest.dart'; import '../../apiResponse.dart'; diff --git a/lib/api/mhsl/server/userIndex/update/updateUserindex.dart b/lib/api/mhsl/server/userIndex/update/updateUserindex.dart index 9ac1b8c..c020fe3 100644 --- a/lib/api/mhsl/server/userIndex/update/updateUserindex.dart +++ b/lib/api/mhsl/server/userIndex/update/updateUserindex.dart @@ -6,7 +6,7 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; -import '../../../../../model/accountData.dart'; +import '../../../../../model/account_data.dart'; import '../../../mhslApi.dart'; import 'updateUserIndexParams.dart'; diff --git a/lib/api/webuntis/queries/authenticate/authenticate.dart b/lib/api/webuntis/queries/authenticate/authenticate.dart index 483278e..5f49dc2 100644 --- a/lib/api/webuntis/queries/authenticate/authenticate.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:convert'; -import '../../../../model/accountData.dart'; +import '../../../../model/account_data.dart'; import '../../webuntisApi.dart'; import 'authenticateParams.dart'; import 'authenticateResponse.dart'; diff --git a/lib/api/webuntis/webuntisApi.dart b/lib/api/webuntis/webuntisApi.dart index 6b48e01..9008949 100644 --- a/lib/api/webuntis/webuntisApi.dart +++ b/lib/api/webuntis/webuntisApi.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import '../../model/endpointData.dart'; +import '../../model/endpoint_data.dart'; import '../apiParams.dart'; import '../apiRequest.dart'; import '../apiResponse.dart'; diff --git a/lib/app.dart b/lib/app.dart index 86b2650..e483fb6 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -11,10 +11,10 @@ import 'api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; import 'api/mhsl/server/userIndex/update/updateUserindex.dart'; import 'main.dart'; import 'widget/breaker/breaker.dart'; -import 'model/dataCleaner.dart'; -import 'notification/notificationController.dart'; -import 'notification/notificationTasks.dart'; -import 'notification/notifyUpdater.dart'; +import 'model/data_cleaner.dart'; +import 'notification/notification_controller.dart'; +import 'notification/notification_tasks.dart'; +import 'notification/notify_updater.dart'; import 'state/app/modules/app_modules.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; import 'state/app/modules/chatList/bloc/chat_list_bloc.dart'; @@ -106,7 +106,7 @@ class _AppState extends State with WidgetsBindingObserver { controller: Main.bottomNavigator, navBarOverlap: const NavBarOverlap.none(), backgroundColor: Theme.of(context).colorScheme.primary, - handleAndroidBackButtonPress: false, + handleAndroidBackButtonPress: true, screenTransitionAnimation: const ScreenTransitionAnimation( curve: Curves.easeOutQuad, duration: Duration(milliseconds: 200), diff --git a/lib/extensions/dateTime.dart b/lib/extensions/date_time.dart similarity index 100% rename from lib/extensions/dateTime.dart rename to lib/extensions/date_time.dart diff --git a/lib/extensions/renderNotNull.dart b/lib/extensions/render_not_null.dart similarity index 100% rename from lib/extensions/renderNotNull.dart rename to lib/extensions/render_not_null.dart diff --git a/lib/extensions/timeOfDay.dart b/lib/extensions/time_of_day.dart similarity index 100% rename from lib/extensions/timeOfDay.dart rename to lib/extensions/time_of_day.dart diff --git a/lib/main.dart b/lib/main.dart index 5c3d278..307af7b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,7 +19,7 @@ import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; import 'app.dart'; import 'firebase_options.dart'; -import 'model/accountData.dart'; +import 'model/account_data.dart'; import 'widget/breaker/breaker.dart'; import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_state.dart'; @@ -29,10 +29,10 @@ import 'state/app/modules/chatList/bloc/chat_list_bloc.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; import 'storage/base/settings.dart'; -import 'theming/darkAppTheme.dart'; -import 'theming/lightAppTheme.dart'; +import 'theming/dark_app_theme.dart'; +import 'theming/light_app_theme.dart'; import 'view/login/login.dart'; -import 'widget/placeholderView.dart'; +import 'widget/placeholder_view.dart'; Future main() async { log('MarianumMobile started'); diff --git a/lib/model/accountData.dart b/lib/model/account_data.dart similarity index 100% rename from lib/model/accountData.dart rename to lib/model/account_data.dart diff --git a/lib/model/dataCleaner.dart b/lib/model/data_cleaner.dart similarity index 100% rename from lib/model/dataCleaner.dart rename to lib/model/data_cleaner.dart diff --git a/lib/model/endpointData.dart b/lib/model/endpoint_data.dart similarity index 97% rename from lib/model/endpointData.dart rename to lib/model/endpoint_data.dart index d5d7893..6bdd4f4 100644 --- a/lib/model/endpointData.dart +++ b/lib/model/endpoint_data.dart @@ -1,5 +1,5 @@ -import 'accountData.dart'; +import 'account_data.dart'; enum EndpointMode { live, diff --git a/lib/notification/notificationController.dart b/lib/notification/notification_controller.dart similarity index 83% rename from lib/notification/notificationController.dart rename to lib/notification/notification_controller.dart index babe229..0a1631f 100644 --- a/lib/notification/notificationController.dart +++ b/lib/notification/notification_controller.dart @@ -1,9 +1,9 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; -import '../widget/debug/debugTile.dart'; -import '../widget/debug/jsonViewer.dart'; -import 'notificationTasks.dart'; +import '../widget/debug/debug_tile.dart'; +import '../widget/debug/json_viewer.dart'; +import 'notification_tasks.dart'; class NotificationController { @pragma('vm:entry-point') @@ -44,7 +44,7 @@ class NotificationController { } static Future onAppOpenedByNotification(RemoteMessage message, BuildContext context) async { - NotificationTasks.navigateToTalk(context); + NotificationTasks.navigateToTalk(context, chatToken: _extractChatToken(message)); NotificationTasks.updateProviders(context); DebugTile(context).run(() { @@ -60,4 +60,12 @@ class NotificationController { )); }); } + + static String? _extractChatToken(RemoteMessage message) { + for (final key in const ['chatToken', 'token', 'roomToken']) { + final value = message.data[key]; + if (value is String && value.isNotEmpty) return value; + } + return null; + } } diff --git a/lib/notification/notificationService.dart b/lib/notification/notification_service.dart similarity index 100% rename from lib/notification/notificationService.dart rename to lib/notification/notification_service.dart diff --git a/lib/notification/notificationTasks.dart b/lib/notification/notification_tasks.dart similarity index 57% rename from lib/notification/notificationTasks.dart rename to lib/notification/notification_tasks.dart index e858f08..e5e43ff 100644 --- a/lib/notification/notificationTasks.dart +++ b/lib/notification/notification_tasks.dart @@ -3,8 +3,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_app_badge/flutter_app_badge.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../main.dart'; -import '../state/app/modules/app_modules.dart'; +import '../routing/app_routes.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart'; import '../state/app/modules/chatList/bloc/chat_list_bloc.dart'; @@ -18,9 +17,14 @@ class NotificationTasks { context.read().refresh(); } - static void navigateToTalk(BuildContext context) { - final talkTab = AppModule.getBottomBarModules(context).map((e) => e.module).toList().indexOf(Modules.talk); - if (talkTab == -1) return; - Main.bottomNavigator.jumpToTab(talkTab); + /// Switches to the Talk tab. If [chatToken] is provided, also schedules + /// the matching chat to be opened automatically once the chat list view + /// resolves the token (handled inside [ChatList]). + static void navigateToTalk(BuildContext context, {String? chatToken}) { + if (chatToken != null && chatToken.isNotEmpty) { + AppRoutes.openChatByToken(context, chatToken); + } else { + AppRoutes.goToTalkTab(context); + } } } diff --git a/lib/notification/notifyUpdater.dart b/lib/notification/notify_updater.dart similarity index 95% rename from lib/notification/notifyUpdater.dart rename to lib/notification/notify_updater.dart index dd9527a..584b9f7 100644 --- a/lib/notification/notifyUpdater.dart +++ b/lib/notification/notify_updater.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; import '../api/mhsl/notify/register/notifyRegister.dart'; import '../api/mhsl/notify/register/notifyRegisterParams.dart'; -import '../model/accountData.dart'; +import '../model/account_data.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart'; -import '../widget/confirmDialog.dart'; +import '../widget/confirm_dialog.dart'; class NotifyUpdater { static ConfirmDialog enableAfterDisclaimer(SettingsCubit settings) => ConfirmDialog( diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart new file mode 100644 index 0000000..94b85d9 --- /dev/null +++ b/lib/routing/app_routes.dart @@ -0,0 +1,202 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; + +import '../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../main.dart'; +import '../model/account_data.dart'; +import '../state/app/modules/app_modules.dart'; +import '../state/app/modules/chatList/bloc/chat_list_bloc.dart'; +import '../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../state/app/modules/marianumMessage/bloc/marianum_message_state.dart'; +import '../view/pages/files/files.dart'; +import '../view/pages/marianum_message/marianum_message_view.dart'; +import '../view/pages/more/feedback/feedback_dialog.dart'; +import '../view/pages/more/roomplan/roomplan.dart'; +import '../view/pages/more/share/qr_share_view.dart'; +import '../view/pages/talk/chat_view.dart'; +import '../view/pages/talk/details/message_reactions.dart'; +import '../view/pages/talk/talk_navigator.dart'; +import '../view/pages/timetable/custom_events/custom_events_view.dart'; +import '../view/pages/settings/settings.dart'; +import '../widget/debug/cache_view.dart'; +import '../widget/file_viewer.dart'; +import '../widget/user_avatar.dart'; + +/// Single entry point for full-page navigations across the app. +/// +/// Every full-page push in modules should go through one of these methods. +/// Dialogs (`showDialog`), bottom sheets (`showStickyFlexibleBottomSheet`, +/// `showAppointmentBottomSheet`), and `Navigator.pop` for closing those +/// remain unchanged and live at the call sites. +class AppRoutes { + AppRoutes._(); + + /// Token of a chat that should be auto-opened in the Talk tab once + /// the chat list view picks it up. Set by [openChatByToken] (e.g. from + /// a tapped notification) and consumed by the `ChatList` widget. + static final ValueNotifier pendingChatToken = ValueNotifier(null); + + // -- Files -------------------------------------------------------------- + + static void openFolder(BuildContext context, List path) { + pushScreen(context, withNavBar: false, screen: Files(path: path)); + } + + static void openFileViewer(BuildContext context, String localPath, {bool openExternal = false}) { + pushScreen( + context, + withNavBar: false, + screen: FileViewer(path: localPath, openExternal: openExternal), + ); + } + + // -- Timetable ---------------------------------------------------------- + + static void openCustomEvents(BuildContext context) { + pushScreen(context, withNavBar: false, screen: const CustomEventsView()); + } + + // -- Marianum Message --------------------------------------------------- + + static void openMarianumMessage(BuildContext context, String basePath, MarianumMessage message) { + pushScreen( + context, + withNavBar: false, + screen: MessageView(basePath: basePath, message: message), + ); + } + + // -- Sharing / Settings / Feedback / DevTools --------------------------- + + static void openQrShare(BuildContext context) { + pushScreen(context, withNavBar: false, screen: const QrShareView()); + } + + static void openSettings(BuildContext context) { + pushScreen(context, withNavBar: false, screen: const Settings()); + } + + static void openFeedback(BuildContext context) { + pushScreen(context, withNavBar: false, screen: const FeedbackDialog()); + } + + static void openCacheView(BuildContext context) { + pushScreen(context, withNavBar: false, screen: const CacheView()); + } + + static void openRoomplan(BuildContext context) { + pushScreen(context, withNavBar: false, screen: const Roomplan()); + } + + // -- Talk --------------------------------------------------------------- + + static void openMessageReactions(BuildContext context, String token, int messageId) { + pushScreen( + context, + withNavBar: false, + screen: MessageReactions(token: token, messageId: messageId), + ); + } + + /// Opens a chat from a known [GetRoomResponseObject]. Delegates to + /// [TalkNavigator.pushSplitView] so tablet split-view behaviour stays intact. + static void openChatView( + BuildContext context, { + required GetRoomResponseObject room, + required String selfId, + required UserAvatar avatar, + bool overrideToSingleSubScreen = true, + }) { + TalkNavigator.pushSplitView( + context, + ChatView(room: room, selfId: selfId, avatar: avatar), + overrideToSingleSubScreen: overrideToSingleSubScreen, + ); + context.read().setToken(room.token); + } + + /// Schedules a chat to be opened in the Talk tab. Use this when only the + /// token is known (e.g. from a tapped notification) — the actual push + /// happens inside the `ChatList` widget once the room is available. + static void openChatByToken(BuildContext context, String token) { + pendingChatToken.value = token; + goToTalkTab(context); + try { + context.read().refresh(); + } catch (e) { + if (kDebugMode) debugPrint('openChatByToken: ChatListBloc refresh failed: $e'); + } + } + + /// Resolves a pending chat token (set via [openChatByToken]) using the + /// [ChatListBloc]'s current rooms and the active [AccountData] credentials. + /// Returns `null` if the token cannot yet be matched (e.g. the room is + /// still being loaded). Callers should keep listening to [pendingChatToken] + /// and the bloc state and retry when either changes. + static ResolvedPendingChat? resolvePendingChat(BuildContext context) { + final token = pendingChatToken.value; + if (token == null) return null; + if (!AccountData().isPopulated()) return null; + + final rooms = context.read().state.data?.rooms; + final room = _findRoomByToken(rooms, token); + if (room == null) return null; + + final isGroup = room.type != GetRoomResponseObjectConversationType.oneToOne; + final avatar = UserAvatar(id: isGroup ? room.token : room.name, isGroup: isGroup); + return ResolvedPendingChat( + room: room, + selfId: AccountData().getUsername(), + avatar: avatar, + ); + } + + 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; + } + + // -- Module / Tab navigation ------------------------------------------- + + /// Opens an [AppModule]'s root view as a full screen push (used by the + /// "Mehr" tab list). Modules that live in the bottom bar are reached via + /// [goToTab] instead. + static void openModule(BuildContext context, AppModule module) { + pushScreen(context, withNavBar: false, screen: module.create()); + } + + /// Switches the bottom navigation to the given [module] if it is currently + /// in the bottom bar. Returns `true` if the jump happened. + static bool goToTab(BuildContext context, Modules module) { + final index = AppModule.getBottomBarModules(context) + .map((e) => e.module) + .toList() + .indexOf(module); + if (index == -1) return false; + Main.bottomNavigator.jumpToTab(index); + return true; + } + + /// Convenience wrapper for the Talk tab — preserved for the notification + /// handler API which only knows about Talk. + static void goToTalkTab(BuildContext context) { + goToTab(context, Modules.talk); + } +} + +class ResolvedPendingChat { + final GetRoomResponseObject room; + final String selfId; + final UserAvatar avatar; + + const ResolvedPendingChat({ + required this.room, + required this.selfId, + required this.avatar, + }); +} diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_error_bar.dart b/lib/state/app/infrastructure/loadableState/view/loadable_state_error_bar.dart index ba158b7..169d489 100644 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_error_bar.dart +++ b/lib/state/app/infrastructure/loadableState/view/loadable_state_error_bar.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../../../widget/infoDialog.dart'; +import '../../../../../widget/info_dialog.dart'; import '../bloc/loadable_state_bloc.dart'; class LoadableStateErrorBar extends StatelessWidget { diff --git a/lib/state/app/modules/app_modules.dart b/lib/state/app/modules/app_modules.dart index 81bb355..b93c698 100644 --- a/lib/state/app/modules/app_modules.dart +++ b/lib/state/app/modules/app_modules.dart @@ -2,23 +2,24 @@ import 'package:flutter/material.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:badges/badges.dart' as badges; + import '../../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; -import '../../../widget/breaker/breaker.dart'; +import '../../../routing/app_routes.dart'; import '../../../view/pages/files/files.dart'; +import '../../../view/pages/grade_averages/grade_averages_view.dart'; +import '../../../view/pages/holidays/holidays_view.dart'; +import '../../../view/pages/marianum_dates/marianum_dates_view.dart'; +import '../../../view/pages/marianum_message/marianum_message_list_view.dart'; import '../../../view/pages/more/roomplan/roomplan.dart'; -import '../../../view/pages/talk/chatList.dart'; +import '../../../view/pages/talk/chat_list.dart'; import '../../../view/pages/timetable/timetable.dart'; -import '../../../widget/centeredLeading.dart'; +import '../../../widget/breaker/breaker.dart'; +import '../../../widget/centered_leading.dart'; +import '../infrastructure/loadableState/loadable_state.dart'; import 'chatList/bloc/chat_list_bloc.dart'; import 'chatList/bloc/chat_list_state.dart'; import 'settings/bloc/settings_cubit.dart'; -import '../infrastructure/loadableState/loadable_state.dart'; -import 'gradeAverages/view/grade_averages_view.dart'; -import 'holidays/view/holidays_view.dart'; -import 'marianumDates/view/marianum_dates_view.dart'; -import 'marianumMessage/view/marianum_message_list_view.dart'; - -import 'package:badges/badges.dart' as badges; class AppModule { Modules module; @@ -120,7 +121,7 @@ class AppModule { key: key, leading: CenteredLeading(icon()), title: Text(name), - onTap: isReorder ? null : () => pushScreen(context, withNavBar: false, screen: create()), + onTap: isReorder ? null : () => AppRoutes.openModule(context, this), trailing: isReorder ? Row(mainAxisSize: MainAxisSize.min, children: [ IconButton(onPressed: onVisibleChange, icon: Icon(isVisible ? Icons.visibility_outlined : Icons.visibility_off_outlined)), diff --git a/lib/state/app/modules/settings/bloc/settings_cubit.dart b/lib/state/app/modules/settings/bloc/settings_cubit.dart index 68ca12a..0fde846 100644 --- a/lib/state/app/modules/settings/bloc/settings_cubit.dart +++ b/lib/state/app/modules/settings/bloc/settings_cubit.dart @@ -4,7 +4,7 @@ import 'package:easy_debounce/easy_debounce.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../storage/base/settings.dart'; -import '../../../../../view/settings/defaultSettings.dart'; +import '../../../../../view/pages/settings/data/default_settings.dart'; import '../../app_modules.dart'; class SettingsCubit extends HydratedCubit { diff --git a/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart b/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart index 5d394c0..8e4766b 100644 --- a/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart +++ b/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart @@ -20,7 +20,7 @@ import '../../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsCac import '../../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart'; import '../../../../../api/webuntis/queries/getTimetable/getTimetableCache.dart'; import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; -import '../../../../../model/accountData.dart'; +import '../../../../../model/account_data.dart'; class TimetableDataProvider { static final DateFormat _dateFormat = DateFormat('yyyyMMdd'); diff --git a/lib/storage/timetable/timetable_name_mode.dart b/lib/storage/timetable/timetable_name_mode.dart index d6aeeb2..36e0f7e 100644 --- a/lib/storage/timetable/timetable_name_mode.dart +++ b/lib/storage/timetable/timetable_name_mode.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../../widget/dropdownDisplay.dart'; +import '../../widget/dropdown_display.dart'; enum TimetableNameMode { name, longName, alternateName } diff --git a/lib/theming/appTheme.dart b/lib/theming/app_theme.dart similarity index 93% rename from lib/theming/appTheme.dart rename to lib/theming/app_theme.dart index c122cc2..cf831c0 100644 --- a/lib/theming/appTheme.dart +++ b/lib/theming/app_theme.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../widget/dropdownDisplay.dart'; +import '../widget/dropdown_display.dart'; class AppTheme { static DropdownDisplay getDisplayOptions(ThemeMode theme) { diff --git a/lib/theming/darkAppTheme.dart b/lib/theming/dark_app_theme.dart similarity index 100% rename from lib/theming/darkAppTheme.dart rename to lib/theming/dark_app_theme.dart diff --git a/lib/theming/lightAppTheme.dart b/lib/theming/light_app_theme.dart similarity index 100% rename from lib/theming/lightAppTheme.dart rename to lib/theming/light_app_theme.dart diff --git a/lib/utils/FileSaver.dart b/lib/utils/file_saver.dart similarity index 100% rename from lib/utils/FileSaver.dart rename to lib/utils/file_saver.dart diff --git a/lib/utils/UrlOpener.dart b/lib/utils/url_opener.dart similarity index 100% rename from lib/utils/UrlOpener.dart rename to lib/utils/url_opener.dart diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index 167b06f..73df505 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -7,7 +7,7 @@ import 'package:flutter_login/flutter_login.dart'; import '../../api/marianumcloud/talk/room/getRoom.dart'; import '../../api/marianumcloud/talk/room/getRoomParams.dart'; -import '../../model/accountData.dart'; +import '../../model/account_data.dart'; import '../../state/app/modules/account/bloc/account_bloc.dart'; import '../../state/app/modules/account/bloc/account_state.dart'; diff --git a/lib/view/pages/files/fileUploadDialog.dart b/lib/view/pages/files/fileUploadDialog.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index 257006c..9f98cea 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -12,10 +12,10 @@ import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; import '../../../state/app/modules/files/bloc/files_bloc.dart'; import '../../../state/app/modules/files/bloc/files_state.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; -import '../../../widget/filePick.dart'; -import '../../../widget/placeholderView.dart'; -import 'fileElement.dart'; -import 'filesUploadDialog.dart'; +import '../../../widget/file_pick.dart'; +import '../../../widget/placeholder_view.dart'; +import 'widgets/file_element.dart'; +import 'files_upload_dialog.dart'; class BetterSortOption { String displayName; diff --git a/lib/view/pages/files/filesUploadDialog.dart b/lib/view/pages/files/files_upload_dialog.dart similarity index 99% rename from lib/view/pages/files/filesUploadDialog.dart rename to lib/view/pages/files/files_upload_dialog.dart index bc0112f..d4d53fd 100644 --- a/lib/view/pages/files/filesUploadDialog.dart +++ b/lib/view/pages/files/files_upload_dialog.dart @@ -6,8 +6,8 @@ import 'package:nextcloud/nextcloud.dart'; import 'package:uuid/uuid.dart'; import '../../../api/marianumcloud/webdav/webdavApi.dart'; -import '../../../widget/confirmDialog.dart'; -import '../../../widget/focusBehaviour.dart'; +import '../../../widget/confirm_dialog.dart'; +import '../../../widget/focus_behaviour.dart'; class FilesUploadDialog extends StatefulWidget { final List filePaths; diff --git a/lib/view/pages/files/fileElement.dart b/lib/view/pages/files/widgets/file_element.dart similarity index 86% rename from lib/view/pages/files/fileElement.dart rename to lib/view/pages/files/widgets/file_element.dart index 6743547..dc2c626 100644 --- a/lib/view/pages/files/fileElement.dart +++ b/lib/view/pages/files/widgets/file_element.dart @@ -7,19 +7,18 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; import 'package:open_filex/open_filex.dart'; -import '../../../widget/infoDialog.dart'; +import '../../../../widget/info_dialog.dart'; import 'package:nextcloud/nextcloud.dart'; import 'package:path_provider/path_provider.dart'; -import '../../../api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart'; -import '../../../api/marianumcloud/webdav/webdavApi.dart'; -import '../../../model/accountData.dart'; -import '../../../model/endpointData.dart'; -import '../../../widget/centeredLeading.dart'; -import '../../../widget/confirmDialog.dart'; -import '../../../widget/fileViewer.dart'; -import '../../../widget/unimplementedDialog.dart'; -import 'files.dart'; +import '../../../../api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart'; +import '../../../../api/marianumcloud/webdav/webdavApi.dart'; +import '../../../../model/account_data.dart'; +import '../../../../model/endpoint_data.dart'; +import '../../../../routing/app_routes.dart'; +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/confirm_dialog.dart'; +import '../../../../widget/unimplemented_dialog.dart'; class FileElement extends StatefulWidget { final CacheableFile file; @@ -45,12 +44,8 @@ class FileElement extends StatefulWidget { deleteOnCancel: true, client: Dio(BaseOptions(headers: AccountData().authHeaders())), onDone: () { - //Future result = OpenFile.open(local); // TODO legacy - refactor: remove onDone parameter - Navigator.of(context).push(MaterialPageRoute(builder: (context) => FileViewer(path: local))); + AppRoutes.openFileViewer(context, local); onDone(OpenResult(message: 'File viewer opened', type: ResultType.done)); - // result.then((value) => { - // onDone(value) - // }); }, ); @@ -101,9 +96,7 @@ class _FileElementState extends State { trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null), onTap: () { if(widget.file.isDirectory) { - Navigator.of(context).push(MaterialPageRoute( - builder: (context) => Files(path: widget.path.toList()..add(widget.file.name)), - )); + AppRoutes.openFolder(context, widget.path.toList()..add(widget.file.name)); } else { if(EndpointData().getEndpointMode() == EndpointMode.stage) { InfoDialog.show(context, 'Virtuelle Dateien im Staging Prozess können nicht heruntergeladen werden!'); diff --git a/lib/state/app/modules/gradeAverages/view/grade_averages_list_view.dart b/lib/view/pages/grade_averages/grade_averages_list_view.dart similarity index 93% rename from lib/state/app/modules/gradeAverages/view/grade_averages_list_view.dart rename to lib/view/pages/grade_averages/grade_averages_list_view.dart index 3366a9c..d4152b2 100644 --- a/lib/state/app/modules/gradeAverages/view/grade_averages_list_view.dart +++ b/lib/view/pages/grade_averages/grade_averages_list_view.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../bloc/grade_averages_bloc.dart'; -import '../bloc/grade_averages_event.dart'; +import '../../../state/app/modules/gradeAverages/bloc/grade_averages_bloc.dart'; +import '../../../state/app/modules/gradeAverages/bloc/grade_averages_event.dart'; class GradeAveragesListView extends StatelessWidget { const GradeAveragesListView({super.key}); diff --git a/lib/state/app/modules/gradeAverages/view/grade_averages_view.dart b/lib/view/pages/grade_averages/grade_averages_view.dart similarity index 93% rename from lib/state/app/modules/gradeAverages/view/grade_averages_view.dart rename to lib/view/pages/grade_averages/grade_averages_view.dart index 03d396a..1a49e0f 100644 --- a/lib/state/app/modules/gradeAverages/view/grade_averages_view.dart +++ b/lib/view/pages/grade_averages/grade_averages_view.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../../../widget/confirmDialog.dart'; -import '../bloc/grade_averages_bloc.dart'; -import '../bloc/grade_averages_event.dart'; -import '../bloc/grade_averages_state.dart'; +import '../../../state/app/modules/gradeAverages/bloc/grade_averages_bloc.dart'; +import '../../../state/app/modules/gradeAverages/bloc/grade_averages_event.dart'; +import '../../../state/app/modules/gradeAverages/bloc/grade_averages_state.dart'; +import '../../../widget/confirm_dialog.dart'; import 'grade_averages_list_view.dart'; class GradeAveragesView extends StatelessWidget { diff --git a/lib/state/app/modules/holidays/view/holidays_view.dart b/lib/view/pages/holidays/holidays_view.dart similarity index 87% rename from lib/state/app/modules/holidays/view/holidays_view.dart rename to lib/view/pages/holidays/holidays_view.dart index 41be1f3..6e9515c 100644 --- a/lib/state/app/modules/holidays/view/holidays_view.dart +++ b/lib/view/pages/holidays/holidays_view.dart @@ -1,17 +1,17 @@ import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; -import '../../../../../widget/animatedTime.dart'; -import '../../../../../widget/list_view_util.dart'; -import '../../../../../widget/centeredLeading.dart'; -import '../../../../../widget/debug/debugTile.dart'; -import '../../../../../widget/string_extensions.dart'; -import '../../../infrastructure/loadableState/loadable_state.dart'; -import '../../../infrastructure/loadableState/view/loadable_state_consumer.dart'; -import '../../../infrastructure/utilityWidgets/bloc_module.dart'; -import '../bloc/holidays_bloc.dart'; -import '../bloc/holidays_event.dart'; -import '../bloc/holidays_state.dart'; +import '../../../state/app/infrastructure/loadableState/loadable_state.dart'; +import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; +import '../../../state/app/modules/holidays/bloc/holidays_bloc.dart'; +import '../../../state/app/modules/holidays/bloc/holidays_event.dart'; +import '../../../state/app/modules/holidays/bloc/holidays_state.dart'; +import '../../../widget/animated_time.dart'; +import '../../../widget/centered_leading.dart'; +import '../../../widget/debug/debug_tile.dart'; +import '../../../widget/list_view_util.dart'; +import '../../../widget/string_extensions.dart'; class HolidaysView extends StatelessWidget { const HolidaysView({super.key}); diff --git a/lib/state/app/modules/marianumDates/view/marianum_dates_view.dart b/lib/view/pages/marianum_dates/marianum_dates_view.dart similarity index 86% rename from lib/state/app/modules/marianumDates/view/marianum_dates_view.dart rename to lib/view/pages/marianum_dates/marianum_dates_view.dart index 4f283d3..081d9e8 100644 --- a/lib/state/app/modules/marianumDates/view/marianum_dates_view.dart +++ b/lib/view/pages/marianum_dates/marianum_dates_view.dart @@ -1,17 +1,17 @@ import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; -import '../../../../../view/pages/timetable/custom_events/custom_event_edit_dialog.dart'; -import '../../../../../widget/animatedTime.dart'; -import '../../../../../widget/centeredLeading.dart'; -import '../../../../../widget/debug/debugTile.dart'; -import '../../../../../widget/list_view_util.dart'; -import '../../../infrastructure/loadableState/view/loadable_state_consumer.dart'; -import '../../../infrastructure/utilityWidgets/bloc_module.dart'; -import '../../../infrastructure/loadableState/loadable_state.dart'; -import '../bloc/marianum_dates_bloc.dart'; -import '../bloc/marianum_dates_event.dart'; -import '../bloc/marianum_dates_state.dart'; +import '../../../state/app/infrastructure/loadableState/loadable_state.dart'; +import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; +import '../../../state/app/modules/marianumDates/bloc/marianum_dates_bloc.dart'; +import '../../../state/app/modules/marianumDates/bloc/marianum_dates_event.dart'; +import '../../../state/app/modules/marianumDates/bloc/marianum_dates_state.dart'; +import '../../../widget/animated_time.dart'; +import '../../../widget/centered_leading.dart'; +import '../../../widget/debug/debug_tile.dart'; +import '../../../widget/list_view_util.dart'; +import '../timetable/custom_events/custom_event_edit_dialog.dart'; class MarianumDatesView extends StatelessWidget { const MarianumDatesView({super.key}); diff --git a/lib/state/app/modules/marianumMessage/view/marianum_message_list_view.dart b/lib/view/pages/marianum_message/marianum_message_list_view.dart similarity index 69% rename from lib/state/app/modules/marianumMessage/view/marianum_message_list_view.dart rename to lib/view/pages/marianum_message/marianum_message_list_view.dart index 1957886..7164219 100644 --- a/lib/state/app/modules/marianumMessage/view/marianum_message_list_view.dart +++ b/lib/view/pages/marianum_message/marianum_message_list_view.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; -import 'marianum_message_view.dart'; -import '../../../infrastructure/loadableState/loadable_state.dart'; -import '../../../infrastructure/loadableState/view/loadable_state_consumer.dart'; -import '../../../infrastructure/utilityWidgets/bloc_module.dart'; -import '../bloc/marianum_message_bloc.dart'; -import '../bloc/marianum_message_state.dart'; +import '../../../routing/app_routes.dart'; +import '../../../state/app/infrastructure/loadableState/loadable_state.dart'; +import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; +import '../../../state/app/modules/marianumMessage/bloc/marianum_message_bloc.dart'; +import '../../../state/app/modules/marianumMessage/bloc/marianum_message_state.dart'; class MarianumMessageListView extends StatelessWidget { const MarianumMessageListView({super.key}); @@ -31,7 +31,7 @@ class MarianumMessageListView extends StatelessWidget { subtitle: Text('vom ${message.date}'), trailing: const Icon(Icons.arrow_right), onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => MessageView(basePath: state.messageList.base, message: message))); + AppRoutes.openMarianumMessage(context, state.messageList.base, message); }, ); } diff --git a/lib/state/app/modules/marianumMessage/view/marianum_message_view.dart b/lib/view/pages/marianum_message/marianum_message_view.dart similarity index 92% rename from lib/state/app/modules/marianumMessage/view/marianum_message_view.dart rename to lib/view/pages/marianum_message/marianum_message_view.dart index ae8fd36..f95712a 100644 --- a/lib/state/app/modules/marianumMessage/view/marianum_message_view.dart +++ b/lib/view/pages/marianum_message/marianum_message_view.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../bloc/marianum_message_state.dart'; -import '../../../../../widget/confirmDialog.dart'; +import '../../../state/app/modules/marianumMessage/bloc/marianum_message_state.dart'; +import '../../../widget/confirm_dialog.dart'; class MessageView extends StatefulWidget { final String basePath; diff --git a/lib/view/pages/more/feedback/feedbackDialog.dart b/lib/view/pages/more/feedback/feedback_dialog.dart similarity index 97% rename from lib/view/pages/more/feedback/feedbackDialog.dart rename to lib/view/pages/more/feedback/feedback_dialog.dart index 1b532f2..373883c 100644 --- a/lib/view/pages/more/feedback/feedbackDialog.dart +++ b/lib/view/pages/more/feedback/feedback_dialog.dart @@ -11,11 +11,11 @@ import 'package:badges/badges.dart' as badges; import '../../../../api/mhsl/server/feedback/addFeedback.dart'; import '../../../../api/mhsl/server/feedback/addFeedbackParams.dart'; -import '../../../../model/accountData.dart'; +import '../../../../model/account_data.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; -import '../../../../widget/filePick.dart'; -import '../../../../widget/focusBehaviour.dart'; -import '../../../../widget/infoDialog.dart'; +import '../../../../widget/file_pick.dart'; +import '../../../../widget/focus_behaviour.dart'; +import '../../../../widget/info_dialog.dart'; class FeedbackDialog extends StatefulWidget { const FeedbackDialog({super.key}); diff --git a/lib/view/pages/more/share/appSharePlatformView.dart b/lib/view/pages/more/share/app_share_platform_view.dart similarity index 100% rename from lib/view/pages/more/share/appSharePlatformView.dart rename to lib/view/pages/more/share/app_share_platform_view.dart diff --git a/lib/view/pages/more/share/qrShareView.dart b/lib/view/pages/more/share/qr_share_view.dart similarity index 97% rename from lib/view/pages/more/share/qrShareView.dart rename to lib/view/pages/more/share/qr_share_view.dart index 8aba4c8..ec66db1 100644 --- a/lib/view/pages/more/share/qrShareView.dart +++ b/lib/view/pages/more/share/qr_share_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:screen_brightness/screen_brightness.dart'; -import 'appSharePlatformView.dart'; +import 'app_share_platform_view.dart'; class QrShareView extends StatefulWidget { const QrShareView({super.key}); diff --git a/lib/view/pages/more/share/selectShareTypeDialog.dart b/lib/view/pages/more/share/select_share_type_dialog.dart similarity index 84% rename from lib/view/pages/more/share/selectShareTypeDialog.dart rename to lib/view/pages/more/share/select_share_type_dialog.dart index 001c86b..50c9491 100644 --- a/lib/view/pages/more/share/selectShareTypeDialog.dart +++ b/lib/view/pages/more/share/select_share_type_dialog.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:share_plus/share_plus.dart'; -import '../../../../widget/sharePositionOrigin.dart'; -import 'qrShareView.dart'; +import '../../../../widget/share_position_origin.dart'; + +enum ShareTargetType { qr } class SelectShareTypeDialog extends StatelessWidget { const SelectShareTypeDialog({super.key}); @@ -14,15 +15,14 @@ class SelectShareTypeDialog extends StatelessWidget { leading: const Icon(Icons.qr_code_2_outlined), title: const Text('Per QR-Code'), trailing: const Icon(Icons.arrow_right), - onTap: () { - Navigator.of(context).push(MaterialPageRoute(builder: (context) => const QrShareView())); - }, + onTap: () => Navigator.of(context).pop(ShareTargetType.qr), ), ListTile( leading: const Icon(Icons.link_outlined), title: const Text('Per Link teilen'), trailing: const Icon(Icons.arrow_right), onTap: () { + Navigator.of(context).pop(); SharePlus.instance.share(ShareParams( sharePositionOrigin: SharePositionOrigin.get(context), subject: 'App Teilen', diff --git a/lib/view/pages/overhang.dart b/lib/view/pages/overhang.dart index 074e3e9..c43412f 100644 --- a/lib/view/pages/overhang.dart +++ b/lib/view/pages/overhang.dart @@ -4,18 +4,16 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:in_app_review/in_app_review.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../extensions/renderNotNull.dart'; -import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; +import '../../extensions/render_not_null.dart'; +import '../../routing/app_routes.dart'; import '../../state/app/modules/app_modules.dart'; import '../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../storage/base/settings.dart' as model; -import '../../widget/centeredLeading.dart'; -import '../../widget/infoDialog.dart'; -import '../settings/defaultSettings.dart'; -import '../settings/settings.dart'; -import 'more/feedback/feedbackDialog.dart'; -import 'more/share/selectShareTypeDialog.dart'; +import '../../widget/centered_leading.dart'; +import '../../widget/info_dialog.dart'; +import 'settings/data/default_settings.dart'; +import 'more/share/select_share_type_dialog.dart'; class Overhang extends StatefulWidget { const Overhang({super.key}); @@ -41,7 +39,7 @@ class _OverhangState extends State { icon: Icon(Icons.undo_outlined) ), IconButton(onPressed: () => setState(() => editMode = !editMode), icon: Icon(Icons.edit_note_outlined), color: editMode ? Theme.of(context).primaryColor : null), - IconButton(onPressed: editMode ? null : () => pushScreen(context, screen: const Settings(), withNavBar: false), icon: const Icon(Icons.settings)), + IconButton(onPressed: editMode ? null : () => AppRoutes.openSettings(context), icon: const Icon(Icons.settings)), ], ), body: editMode ? _sorting() : _overhang(), @@ -92,7 +90,14 @@ class _OverhangState extends State { title: const Text('Teile die App'), subtitle: const Text('Mit Freunden und deiner Klasse teilen'), trailing: const Icon(Icons.arrow_right), - onTap: () => showDialog(context: context, builder: (context) => const SelectShareTypeDialog()) + onTap: () async { + final result = await showDialog( + context: context, + builder: (_) => const SelectShareTypeDialog(), + ); + if (!mounted || result != ShareTargetType.qr) return; + if (context.mounted) AppRoutes.openQrShare(context); + }, ), FutureBuilder( future: InAppReview.instance.isAvailable(), @@ -130,7 +135,7 @@ class _OverhangState extends State { title: const Text('Du hast eine Idee?'), subtitle: const Text('Fehler und Verbessungsvorschläge'), trailing: const Icon(Icons.arrow_right), - onTap: () => pushScreen(context, withNavBar: false, screen: const FeedbackDialog()), + onTap: () => AppRoutes.openFeedback(context), ), ], ); diff --git a/lib/view/settings/defaultSettings.dart b/lib/view/pages/settings/data/default_settings.dart similarity index 68% rename from lib/view/settings/defaultSettings.dart rename to lib/view/pages/settings/data/default_settings.dart index 3e2fac8..18088d0 100644 --- a/lib/view/settings/defaultSettings.dart +++ b/lib/view/pages/settings/data/default_settings.dart @@ -2,18 +2,18 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import '../../state/app/modules/app_modules.dart'; -import '../../storage/base/settings.dart'; -import '../../storage/devTools/devToolsSettings.dart'; -import '../../storage/file/fileSettings.dart'; -import '../../storage/fileView/fileViewSettings.dart'; -import '../../storage/general/modulesSettings.dart'; -import '../../storage/holidays/holidaysSettings.dart'; -import '../../storage/notification/notificationSettings.dart'; -import '../../storage/talk/talkSettings.dart'; -import '../../storage/timetable/timetableSettings.dart'; -import '../pages/files/files.dart'; -import '../../storage/timetable/timetable_name_mode.dart'; +import '../../../../state/app/modules/app_modules.dart'; +import '../../../../storage/base/settings.dart'; +import '../../../../storage/devTools/devToolsSettings.dart'; +import '../../../../storage/file/fileSettings.dart'; +import '../../../../storage/fileView/fileViewSettings.dart'; +import '../../../../storage/general/modulesSettings.dart'; +import '../../../../storage/holidays/holidaysSettings.dart'; +import '../../../../storage/notification/notificationSettings.dart'; +import '../../../../storage/talk/talkSettings.dart'; +import '../../../../storage/timetable/timetable_name_mode.dart'; +import '../../../../storage/timetable/timetableSettings.dart'; +import '../../files/files.dart'; class DefaultSettings { static Settings get() => Settings( diff --git a/lib/view/pages/settings/sections/about_section.dart b/lib/view/pages/settings/sections/about_section.dart new file mode 100644 index 0000000..0784a6d --- /dev/null +++ b/lib/view/pages/settings/sections/about_section.dart @@ -0,0 +1,135 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/confirm_dialog.dart'; +import '../data/default_settings.dart'; +import '../widgets/privacy_info.dart'; +import 'dev_tools_section.dart'; + +class AboutSection extends StatelessWidget { + const AboutSection({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.read(); + return Column( + children: [ + ListTile( + leading: const Icon(Icons.live_help_outlined), + title: const Text('Informationen und Lizenzen'), + onTap: () => _showAppInfo(context), + trailing: const Icon(Icons.arrow_right), + ), + ListTile( + leading: const Icon(Icons.policy_outlined), + title: const Text('Impressum & Datenschutz'), + onTap: () => _showPrivacyDialog(context), + trailing: const Icon(Icons.arrow_right), + ), + const Divider(), + ListTile( + leading: const CenteredLeading(Icon(Icons.code)), + title: const Text('Quellcode MarianumMobile/Client'), + subtitle: const Text('GNU GPL v3'), + onTap: () => ConfirmDialog.openBrowser(context, 'https://mhsl.eu/gitea/MarianumMobile/Client'), + ), + ListTile( + leading: const Icon(Icons.developer_mode_outlined), + title: const Text('Entwicklermodus'), + trailing: Checkbox( + value: settings.val().devToolsEnabled, + onChanged: (state) => _toggleDeveloperMode(context, settings, state), + ), + ), + Visibility( + visible: settings.val().devToolsEnabled, + child: DevToolsSection(settings: settings), + ), + ], + ); + } + + Future _showAppInfo(BuildContext context) async { + final appInfo = await PackageInfo.fromPlatform(); + if (!context.mounted) return; + showAboutDialog( + context: context, + applicationIcon: const Icon(Icons.apps), + applicationName: 'MarianumMobile', + applicationVersion: '${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}', + applicationLegalese: 'Dies ist ein Inoffizieller Nextcloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n' + 'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n' + "${kReleaseMode ? "Production" : "Development"} build\n" + 'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller', + ); + } + + void _showPrivacyDialog(BuildContext context) => showDialog( + context: context, + builder: (context) => SimpleDialog( + children: [ + ListTile( + leading: const CenteredLeading(Icon(Icons.school_outlined)), + title: const Text('Infos zum Marianum Fulda'), + subtitle: const Text('Für Talk-Chats und Dateien'), + trailing: const Icon(Icons.arrow_right), + onTap: () => PrivacyInfo( + providerText: 'Marianum', + imprintUrl: 'https://www.marianum-fulda.de/impressum', + privacyUrl: 'https://www.marianum-fulda.de/datenschutz', + ).showPopup(context), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.date_range_outlined)), + title: const Text('Infos zu Web-/ Untis'), + subtitle: const Text('Für den Stundenplan'), + trailing: const Icon(Icons.arrow_right), + onTap: () => PrivacyInfo( + providerText: 'Untis', + imprintUrl: 'https://www.untis.at/impressum', + privacyUrl: 'https://www.untis.at/datenschutz-wu-apps', + ).showPopup(context), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.send_time_extension_outlined)), + title: const Text('Infos zu mhsl'), + subtitle: const Text('Für Countdowns, Marianum Message und mehr'), + trailing: const Icon(Icons.arrow_right), + onTap: () => PrivacyInfo( + providerText: 'mhsl', + imprintUrl: 'https://mhsl.eu/id.html', + privacyUrl: 'https://mhsl.eu/datenschutz.html', + ).showPopup(context), + ), + ], + ), + ); + + void _toggleDeveloperMode(BuildContext context, SettingsCubit settings, bool? state) { + void apply() { + final enabled = state ?? false; + settings.val(write: true).devToolsEnabled = enabled; + if (!enabled) settings.val(write: true).devToolsSettings = DefaultSettings.get().devToolsSettings; + } + + if (!state!) { + apply(); + return; + } + + ConfirmDialog( + title: 'Entwicklermodus', + content: 'Die Entwickleransicht bietet erweiterte Funktionen, die für den üblichen Gebrauch nicht benötigt werden.\n\n' + 'Die Verwendung der Tools kann darüber hinaus bei falscher Verwendung zu Fehlern führen.\n\n' + 'Aktivieren auf eigene Verantwortung.', + confirmButton: 'Ja, ich verstehe das Risiko', + cancelButton: 'Nein, zurück zur App', + onConfirm: apply, + ).asDialog(context); + } +} diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart new file mode 100644 index 0000000..b7bf522 --- /dev/null +++ b/lib/view/pages/settings/sections/account_section.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../../model/account_data.dart'; +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/confirm_dialog.dart'; +import '../../../../widget/debug/cache_view.dart'; + +class AccountSection extends StatelessWidget { + const AccountSection({super.key}); + + @override + Widget build(BuildContext context) => ListTile( + leading: const CenteredLeading(Icon(Icons.logout_outlined)), + title: const Text('Konto abmelden'), + subtitle: Text('Angemeldet als ${AccountData().getUsername()}'), + onTap: () => _showLogoutDialog(context), + ); + + void _showLogoutDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => ConfirmDialog( + title: 'Abmelden?', + content: 'Möchtest du dich wirklich abmelden?', + confirmButton: 'Abmelden', + onConfirm: () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); + PaintingBinding.instance.imageCache.clear(); + if (!context.mounted) return; + context.read().reset(); + const CacheView().clear(); + AccountData().removeData(context: context); + }, + ), + ); + } +} diff --git a/lib/view/pages/settings/sections/appearance_section.dart b/lib/view/pages/settings/sections/appearance_section.dart new file mode 100644 index 0000000..f5df162 --- /dev/null +++ b/lib/view/pages/settings/sections/appearance_section.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../theming/app_theme.dart'; + +class AppearanceSection extends StatelessWidget { + const AppearanceSection({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.read(); + return ListTile( + leading: const Icon(Icons.dark_mode_outlined), + title: const Text('Farbgebung'), + trailing: DropdownButton( + value: settings.val().appTheme, + icon: const Icon(Icons.arrow_drop_down), + items: ThemeMode.values + .map((e) => DropdownMenuItem( + value: e, + enabled: e != settings.val().appTheme, + child: Row( + children: [ + Icon(AppTheme.getDisplayOptions(e).icon), + const SizedBox(width: 10), + Text(AppTheme.getDisplayOptions(e).displayName), + ], + ), + )) + .toList(), + onChanged: (e) => settings.val(write: true).appTheme = e!, + ), + ); + } +} diff --git a/lib/view/settings/devToolsSettings.dart b/lib/view/pages/settings/sections/dev_tools_section.dart similarity index 89% rename from lib/view/settings/devToolsSettings.dart rename to lib/view/pages/settings/sections/dev_tools_section.dart index 147861f..619392c 100644 --- a/lib/view/settings/devToolsSettings.dart +++ b/lib/view/pages/settings/sections/dev_tools_section.dart @@ -4,21 +4,22 @@ import 'package:flutter/material.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../state/app/modules/settings/bloc/settings_cubit.dart'; -import '../../widget/centeredLeading.dart'; -import '../../widget/confirmDialog.dart'; -import '../../widget/debug/cacheView.dart'; -import '../../widget/debug/jsonViewer.dart'; +import '../../../../routing/app_routes.dart'; +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/confirm_dialog.dart'; +import '../../../../widget/debug/cache_view.dart'; +import '../../../../widget/debug/json_viewer.dart'; -class DevToolsSettings extends StatefulWidget { +class DevToolsSection extends StatefulWidget { final SettingsCubit settings; - const DevToolsSettings({required this.settings, super.key}); + const DevToolsSection({required this.settings, super.key}); @override - State createState() => _DevToolsSettingsState(); + State createState() => _DevToolsSectionState(); } -class _DevToolsSettingsState extends State { +class _DevToolsSectionState extends State { @override Widget build(BuildContext context) => Column( children: [ @@ -96,9 +97,7 @@ class _DevToolsSettingsState extends State { future: const CacheView().totalSize(), builder: (context, snapshot) => Text("etwa ${snapshot.hasError ? "?" : snapshot.hasData ? filesize(snapshot.data) : "..."}\nLange tippen um zu löschen"), ), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const CacheView())); - }, + onTap: () => AppRoutes.openCacheView(context), onLongPress: () { ConfirmDialog( title: 'App-Cache löschen', diff --git a/lib/view/pages/settings/sections/files_section.dart b/lib/view/pages/settings/sections/files_section.dart new file mode 100644 index 0000000..2035648 --- /dev/null +++ b/lib/view/pages/settings/sections/files_section.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; + +class FilesSection extends StatelessWidget { + const FilesSection({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.read(); + return Column( + children: [ + ListTile( + leading: const Icon(Icons.drive_folder_upload_outlined), + title: const Text('Ordner in Dateien nach oben sortieren'), + trailing: Checkbox( + value: settings.val().fileSettings.sortFoldersToTop, + onChanged: (e) => settings.val(write: true).fileSettings.sortFoldersToTop = e!, + ), + ), + ListTile( + leading: const Icon(Icons.open_in_new_outlined), + title: const Text('Dateien immer mit Systemdialog öffnen'), + trailing: Checkbox( + value: settings.val().fileViewSettings.alwaysOpenExternally, + onChanged: (e) => settings.val(write: true).fileViewSettings.alwaysOpenExternally = e!, + ), + ), + ], + ); + } +} diff --git a/lib/view/pages/settings/sections/talk_section.dart b/lib/view/pages/settings/sections/talk_section.dart new file mode 100644 index 0000000..1133c11 --- /dev/null +++ b/lib/view/pages/settings/sections/talk_section.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../notification/notify_updater.dart'; +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../widget/centered_leading.dart'; + +class TalkSection extends StatelessWidget { + const TalkSection({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.read(); + final talkSettings = settings.val().talkSettings; + final notificationSettings = settings.val().notificationSettings; + return Column( + children: [ + ListTile( + leading: const Icon(Icons.star_border), + title: const Text('Favoriten im Talk nach oben sortieren'), + trailing: Checkbox( + value: talkSettings.sortFavoritesToTop, + onChanged: (e) => settings.val(write: true).talkSettings.sortFavoritesToTop = e!, + ), + ), + ListTile( + leading: const Icon(Icons.mark_email_unread_outlined), + title: const Text('Ungelesene Chats nach oben sortieren'), + trailing: Checkbox( + value: talkSettings.sortUnreadToTop, + onChanged: (e) => settings.val(write: true).talkSettings.sortUnreadToTop = e!, + ), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.notifications_active_outlined)), + title: const Text('Push-Benachrichtigungen aktivieren'), + subtitle: const Text('Lange tippen für mehr Informationen'), + trailing: Checkbox( + value: notificationSettings.enabled, + onChanged: (e) { + if (e!) { + NotifyUpdater.enableAfterDisclaimer(settings).asDialog(context); + } else { + settings.val(write: true).notificationSettings.enabled = e; + } + }, + ), + onLongPress: () => _showInfoDialog(context), + ), + ], + ); + } + + void _showInfoDialog(BuildContext context) => showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Info über Push'), + content: const SingleChildScrollView( + child: Text( + "Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n" + 'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n' + 'Der extene Server verwendet die Zugangsdaten um sich maschinell in Nextcloud Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n' + 'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n' + 'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!', + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Zurück')), + ], + ), + ); +} diff --git a/lib/view/pages/settings/sections/timetable_section.dart b/lib/view/pages/settings/sections/timetable_section.dart new file mode 100644 index 0000000..a755eb5 --- /dev/null +++ b/lib/view/pages/settings/sections/timetable_section.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../../storage/timetable/timetable_name_mode.dart'; + +class TimetableSection extends StatelessWidget { + const TimetableSection({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.read(); + final timetableBloc = context.read(); + final timetableSettings = settings.val().timetableSettings; + return Column( + children: [ + ListTile( + leading: const Icon(Icons.abc_outlined), + title: const Text('Fachbezeichnung'), + trailing: DropdownButton( + value: timetableSettings.timetableNameMode, + icon: const Icon(Icons.arrow_drop_down), + items: TimetableNameMode.values + .map((e) => DropdownMenuItem( + value: e, + enabled: e != timetableSettings.timetableNameMode, + child: Row( + children: [ + Icon(TimetableNameModes.getDisplayOptions(e).icon), + const SizedBox(width: 10), + Text(TimetableNameModes.getDisplayOptions(e).displayName), + ], + ), + )) + .toList(), + onChanged: (value) { + settings.val(write: true).timetableSettings.timetableNameMode = value!; + timetableBloc.refresh(); + }, + ), + ), + ListTile( + leading: const Icon(Icons.calendar_view_day_outlined), + title: const Text('Doppelstunden zusammenhängend anzeigen'), + trailing: Checkbox( + value: timetableSettings.connectDoubleLessons, + onChanged: (e) { + settings.val(write: true).timetableSettings.connectDoubleLessons = e!; + timetableBloc.refresh(); + }, + ), + ), + ], + ); + } +} diff --git a/lib/view/pages/settings/settings.dart b/lib/view/pages/settings/settings.dart new file mode 100644 index 0000000..b584bd4 --- /dev/null +++ b/lib/view/pages/settings/settings.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../storage/base/settings.dart' as model; +import 'sections/about_section.dart'; +import 'sections/account_section.dart'; +import 'sections/appearance_section.dart'; +import 'sections/files_section.dart'; +import 'sections/talk_section.dart'; +import 'sections/timetable_section.dart'; + +class Settings extends StatelessWidget { + const Settings({super.key}); + + @override + Widget build(BuildContext context) => BlocBuilder( + builder: (context, _) => Scaffold( + appBar: AppBar(title: const Text('Einstellungen')), + body: ListView( + children: const [ + AccountSection(), + Divider(), + AppearanceSection(), + Divider(), + TimetableSection(), + Divider(), + TalkSection(), + Divider(), + FilesSection(), + Divider(), + AboutSection(), + ], + ), + ), + ); +} diff --git a/lib/view/settings/privacyInfo.dart b/lib/view/pages/settings/widgets/privacy_info.dart similarity index 90% rename from lib/view/settings/privacyInfo.dart rename to lib/view/pages/settings/widgets/privacy_info.dart index ceb9ea5..13ba668 100644 --- a/lib/view/settings/privacyInfo.dart +++ b/lib/view/pages/settings/widgets/privacy_info.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import '../../widget/centeredLeading.dart'; -import '../../widget/confirmDialog.dart'; +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/confirm_dialog.dart'; class PrivacyInfo { String providerText; diff --git a/lib/view/pages/talk/chatList.dart b/lib/view/pages/talk/chatList.dart deleted file mode 100644 index df0f4bc..0000000 --- a/lib/view/pages/talk/chatList.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_split_view/flutter_split_view.dart'; - -import '../../../state/app/infrastructure/loadableState/loadable_state.dart'; -import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; -import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; -import '../../../state/app/modules/chatList/bloc/chat_list_bloc.dart'; -import '../../../state/app/modules/chatList/bloc/chat_list_state.dart'; -import '../../../notification/notifyUpdater.dart'; -import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; -import '../../../widget/confirmDialog.dart'; -import 'components/chatTile.dart'; -import 'components/splitViewPlaceholder.dart'; -import 'joinChat.dart'; -import 'searchChat.dart'; - -class ChatList extends StatelessWidget { - const ChatList({super.key}); - - @override - Widget build(BuildContext context) => BlocModule>( - create: (_) => ChatListBloc(), - child: (context, bloc, _) => const _ChatListView(), - ); -} - -class _ChatListView extends StatefulWidget { - const _ChatListView(); - - @override - State<_ChatListView> createState() => _ChatListViewState(); -} - -class _ChatListViewState extends State<_ChatListView> { - late final SettingsCubit _settings; - - @override - void initState() { - super.initState(); - _settings = context.read(); - - WidgetsBinding.instance.addPostFrameCallback((_) => _maybeAskForNotificationPermission()); - } - - void _maybeAskForNotificationPermission() { - final notificationSettings = _settings.val().notificationSettings; - if (notificationSettings.enabled || notificationSettings.askUsageDismissed) return; - - _settings.val(write: true).notificationSettings.askUsageDismissed = true; - ConfirmDialog( - icon: Icons.notifications_active_outlined, - title: 'Benachrichtigungen aktivieren', - content: 'Auf wunsch kannst du Push-Benachrichtigungen aktivieren. Deine Einstellungen kannst du jederzeit ändern.', - confirmButton: 'Weiter', - onConfirm: () { - FirebaseMessaging.instance.requestPermission(provisional: false).then((value) { - if (!mounted) return; - switch (value.authorizationStatus) { - case AuthorizationStatus.authorized: - NotifyUpdater.enableAfterDisclaimer(_settings).asDialog(context); - break; - case AuthorizationStatus.denied: - showDialog( - context: context, - builder: (_) => const AlertDialog( - content: Text('Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.'), - ), - ); - break; - default: - break; - } - }); - }, - ).asDialog(context); - } - - @override - Widget build(BuildContext context) { - final bloc = context.read(); - return SplitView.material( - placeholder: const SplitViewPlaceholder(), - breakpoint: 1000, - child: Scaffold( - appBar: AppBar( - title: const Text('Talk'), - actions: [ - IconButton( - icon: const Icon(Icons.search), - onPressed: () { - final rooms = bloc.state.data?.rooms; - if (rooms == null) return; - showSearch(context: context, delegate: SearchChat(rooms.data.toList())); - }, - ), - ], - ), - floatingActionButton: FloatingActionButton( - heroTag: 'createChat', - backgroundColor: Theme.of(context).primaryColor, - onPressed: () { - showSearch(context: context, delegate: JoinChat()).then((username) { - if (username == null || !context.mounted) return; - ConfirmDialog( - title: 'Chat starten', - content: "Möchtest du einen Chat mit Nutzer '$username' starten?", - confirmButton: 'Chat starten', - onConfirm: () { - bloc.createDirectChat(username); - }, - ).asDialog(context); - }); - }, - child: const Icon(Icons.add_comment_outlined), - ), - body: LoadableStateConsumer( - child: (state, _) { - final rooms = state.rooms; - if (rooms == null) return const SizedBox.shrink(); - - final talkSettings = context.watch().val().talkSettings; - final sorted = rooms.sortBy( - lastActivity: true, - favoritesToTop: talkSettings.sortFavoritesToTop, - unreadToTop: talkSettings.sortUnreadToTop, - ); - - return ListView( - padding: EdgeInsets.zero, - children: sorted.map((room) { - final hasDraft = _settings.val().talkSettings.drafts.containsKey(room.token); - return ChatTile(data: room, hasDraft: hasDraft); - }).toList(), - ); - }, - ), - ), - ); - } -} diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart new file mode 100644 index 0000000..04c53d2 --- /dev/null +++ b/lib/view/pages/talk/chat_list.dart @@ -0,0 +1,180 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_split_view/flutter_split_view.dart'; + +import '../../../routing/app_routes.dart'; +import '../../../state/app/infrastructure/loadableState/loadable_state.dart'; +import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; +import '../../../state/app/modules/chatList/bloc/chat_list_bloc.dart'; +import '../../../state/app/modules/chatList/bloc/chat_list_state.dart'; +import '../../../notification/notify_updater.dart'; +import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../widget/confirm_dialog.dart'; +import 'widgets/chat_tile.dart'; +import 'widgets/split_view_placeholder.dart'; +import 'join_chat.dart'; +import 'search_chat.dart'; + +class ChatList extends StatelessWidget { + const ChatList({super.key}); + + @override + Widget build(BuildContext context) => BlocModule>( + create: (_) => ChatListBloc(), + child: (context, bloc, _) => const _ChatListView(), + ); +} + +class _ChatListView extends StatefulWidget { + const _ChatListView(); + + @override + State<_ChatListView> createState() => _ChatListViewState(); +} + +class _ChatListViewState extends State<_ChatListView> { + late final SettingsCubit _settings; + + @override + void initState() { + super.initState(); + _settings = context.read(); + + AppRoutes.pendingChatToken.addListener(_maybeOpenPendingChat); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _maybeAskForNotificationPermission(); + _maybeOpenPendingChat(); + }); + } + + @override + void dispose() { + AppRoutes.pendingChatToken.removeListener(_maybeOpenPendingChat); + super.dispose(); + } + + void _maybeOpenPendingChat() { + if (!mounted) return; + final resolved = AppRoutes.resolvePendingChat(context); + if (resolved == null) return; + AppRoutes.pendingChatToken.value = null; + + // Replace any chat already pushed on top of the chat list so a freshly + // tapped notification doesn't stack indefinitely on previous chats. + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.popUntil((route) => route.isFirst); + } + + AppRoutes.openChatView( + context, + room: resolved.room, + selfId: resolved.selfId, + avatar: resolved.avatar, + overrideToSingleSubScreen: true, + ); + } + + void _maybeAskForNotificationPermission() { + final notificationSettings = _settings.val().notificationSettings; + if (notificationSettings.enabled || notificationSettings.askUsageDismissed) return; + + _settings.val(write: true).notificationSettings.askUsageDismissed = true; + ConfirmDialog( + icon: Icons.notifications_active_outlined, + title: 'Benachrichtigungen aktivieren', + content: 'Auf wunsch kannst du Push-Benachrichtigungen aktivieren. Deine Einstellungen kannst du jederzeit ändern.', + confirmButton: 'Weiter', + onConfirm: () { + FirebaseMessaging.instance.requestPermission(provisional: false).then((value) { + if (!mounted) return; + switch (value.authorizationStatus) { + case AuthorizationStatus.authorized: + NotifyUpdater.enableAfterDisclaimer(_settings).asDialog(context); + break; + case AuthorizationStatus.denied: + showDialog( + context: context, + builder: (_) => const AlertDialog( + content: Text('Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.'), + ), + ); + break; + default: + break; + } + }); + }, + ).asDialog(context); + } + + @override + Widget build(BuildContext context) { + final bloc = context.read(); + return SplitView.material( + placeholder: const SplitViewPlaceholder(), + breakpoint: 1000, + child: BlocListener>( + listener: (_, _) => _maybeOpenPendingChat(), + child: Scaffold( + appBar: AppBar( + title: const Text('Talk'), + actions: [ + IconButton( + icon: const Icon(Icons.search), + onPressed: () { + final rooms = bloc.state.data?.rooms; + if (rooms == null) return; + showSearch(context: context, delegate: SearchChat(rooms.data.toList())); + }, + ), + ], + ), + floatingActionButton: FloatingActionButton( + heroTag: 'createChat', + backgroundColor: Theme.of(context).primaryColor, + onPressed: () { + showSearch(context: context, delegate: JoinChat()).then((username) { + if (username == null || !context.mounted) return; + ConfirmDialog( + title: 'Chat starten', + content: "Möchtest du einen Chat mit Nutzer '$username' starten?", + confirmButton: 'Chat starten', + onConfirm: () { + bloc.createDirectChat(username); + }, + ).asDialog(context); + }); + }, + child: const Icon(Icons.add_comment_outlined), + ), + body: LoadableStateConsumer( + child: (state, _) { + final rooms = state.rooms; + if (rooms == null) return const SizedBox.shrink(); + + final talkSettings = context.watch().val().talkSettings; + final sorted = rooms.sortBy( + lastActivity: true, + favoritesToTop: talkSettings.sortFavoritesToTop, + unreadToTop: talkSettings.sortUnreadToTop, + ); + + return ListView( + padding: EdgeInsets.zero, + children: sorted.map((room) { + final hasDraft = _settings.val().talkSettings.drafts.containsKey(room.token); + return ChatTile(data: room, hasDraft: hasDraft); + }).toList(), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/view/pages/talk/chatView.dart b/lib/view/pages/talk/chat_view.dart similarity index 93% rename from lib/view/pages/talk/chatView.dart rename to lib/view/pages/talk/chat_view.dart index e570405..e332150 100644 --- a/lib/view/pages/talk/chatView.dart +++ b/lib/view/pages/talk/chat_view.dart @@ -3,17 +3,17 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../api/marianumcloud/talk/chat/getChatResponse.dart'; import '../../../api/marianumcloud/talk/room/getRoomResponse.dart'; -import '../../../extensions/dateTime.dart'; +import '../../../extensions/date_time.dart'; import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; import '../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../state/app/modules/chat/bloc/chat_state.dart'; -import '../../../theming/appTheme.dart'; -import '../../../widget/clickableAppBar.dart'; -import '../../../widget/userAvatar.dart'; -import 'chatDetails/chatInfo.dart'; -import 'components/chatBubble.dart'; -import 'components/chatTextfield.dart'; -import 'talkNavigator.dart'; +import '../../../theming/app_theme.dart'; +import '../../../widget/clickable_app_bar.dart'; +import '../../../widget/user_avatar.dart'; +import 'details/chat_info.dart'; +import 'widgets/chat_bubble.dart'; +import 'widgets/chat_textfield.dart'; +import 'talk_navigator.dart'; class ChatView extends StatefulWidget { final GetRoomResponseObject room; diff --git a/lib/view/pages/talk/components/chatBubbleStyles.dart b/lib/view/pages/talk/data/chat_bubble_styles.dart similarity index 97% rename from lib/view/pages/talk/components/chatBubbleStyles.dart rename to lib/view/pages/talk/data/chat_bubble_styles.dart index 7451dd1..bad0822 100644 --- a/lib/view/pages/talk/components/chatBubbleStyles.dart +++ b/lib/view/pages/talk/data/chat_bubble_styles.dart @@ -1,7 +1,7 @@ import 'package:bubble/bubble.dart'; import 'package:flutter/material.dart'; -import '../../../../theming/appTheme.dart'; +import '../../../../theming/app_theme.dart'; extension ColorExtensions on Color { Color invert() { diff --git a/lib/view/pages/talk/components/chatMessage.dart b/lib/view/pages/talk/data/chat_message.dart similarity index 95% rename from lib/view/pages/talk/components/chatMessage.dart rename to lib/view/pages/talk/data/chat_message.dart index f545fa4..358e289 100644 --- a/lib/view/pages/talk/components/chatMessage.dart +++ b/lib/view/pages/talk/data/chat_message.dart @@ -5,9 +5,9 @@ import 'package:flutter_linkify/flutter_linkify.dart'; import '../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; import '../../../../api/marianumcloud/talk/chat/richObjectStringProcessor.dart'; -import '../../../../model/accountData.dart'; -import '../../../../model/endpointData.dart'; -import '../../../../utils/UrlOpener.dart'; +import '../../../../model/account_data.dart'; +import '../../../../model/endpoint_data.dart'; +import '../../../../utils/url_opener.dart'; class ChatMessage { String originalMessage; diff --git a/lib/view/pages/talk/chatDetails/chatInfo.dart b/lib/view/pages/talk/details/chat_info.dart similarity index 91% rename from lib/view/pages/talk/chatDetails/chatInfo.dart rename to lib/view/pages/talk/details/chat_info.dart index 81ca7a3..74afa79 100644 --- a/lib/view/pages/talk/chatDetails/chatInfo.dart +++ b/lib/view/pages/talk/details/chat_info.dart @@ -3,11 +3,11 @@ import 'package:flutter/material.dart'; import '../../../../api/marianumcloud/talk/getParticipants/getParticipantsCache.dart'; import '../../../../api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart'; import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; -import '../../../../widget/largeProfilePictureView.dart'; -import '../../../../widget/loadingSpinner.dart'; -import '../../../../widget/userAvatar.dart'; -import '../talkNavigator.dart'; -import 'participants/participantsListView.dart'; +import '../../../../widget/large_profile_picture_view.dart'; +import '../../../../widget/loading_spinner.dart'; +import '../../../../widget/user_avatar.dart'; +import '../talk_navigator.dart'; +import 'participants_list_view.dart'; class ChatInfo extends StatefulWidget { final GetRoomResponseObject room; diff --git a/lib/view/pages/talk/messageReactions.dart b/lib/view/pages/talk/details/message_reactions.dart similarity index 85% rename from lib/view/pages/talk/messageReactions.dart rename to lib/view/pages/talk/details/message_reactions.dart index b3aeaf2..29158f3 100644 --- a/lib/view/pages/talk/messageReactions.dart +++ b/lib/view/pages/talk/details/message_reactions.dart @@ -1,14 +1,14 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import '../../../api/marianumcloud/talk/getReactions/getReactions.dart'; -import '../../../api/marianumcloud/talk/getReactions/getReactionsResponse.dart'; -import '../../../model/accountData.dart'; -import '../../../widget/centeredLeading.dart'; -import '../../../widget/loadingSpinner.dart'; -import '../../../widget/placeholderView.dart'; -import '../../../widget/unimplementedDialog.dart'; -import '../../../widget/userAvatar.dart'; +import '../../../../api/marianumcloud/talk/getReactions/getReactions.dart'; +import '../../../../api/marianumcloud/talk/getReactions/getReactionsResponse.dart'; +import '../../../../model/account_data.dart'; +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/loading_spinner.dart'; +import '../../../../widget/placeholder_view.dart'; +import '../../../../widget/unimplemented_dialog.dart'; +import '../../../../widget/user_avatar.dart'; class MessageReactions extends StatefulWidget { final String token; diff --git a/lib/view/pages/talk/chatDetails/participants/participantsListView.dart b/lib/view/pages/talk/details/participants_list_view.dart similarity index 91% rename from lib/view/pages/talk/chatDetails/participants/participantsListView.dart rename to lib/view/pages/talk/details/participants_list_view.dart index dfb6b82..e58d1e5 100644 --- a/lib/view/pages/talk/chatDetails/participants/participantsListView.dart +++ b/lib/view/pages/talk/details/participants_list_view.dart @@ -1,8 +1,8 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import '../../../../../api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart'; -import '../../../../../widget/userAvatar.dart'; +import '../../../../api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart'; +import '../../../../widget/user_avatar.dart'; class ParticipantsListView extends StatelessWidget { final GetParticipantsResponse participantsResponse; diff --git a/lib/view/pages/talk/joinChat.dart b/lib/view/pages/talk/join_chat.dart similarity index 96% rename from lib/view/pages/talk/joinChat.dart rename to lib/view/pages/talk/join_chat.dart index e51815a..3acc834 100644 --- a/lib/view/pages/talk/joinChat.dart +++ b/lib/view/pages/talk/join_chat.dart @@ -4,8 +4,8 @@ import 'package:flutter/material.dart'; import '../../../api/marianumcloud/autocomplete/autocompleteApi.dart'; import '../../../api/marianumcloud/autocomplete/autocompleteResponse.dart'; -import '../../../model/endpointData.dart'; -import '../../../widget/placeholderView.dart'; +import '../../../model/endpoint_data.dart'; +import '../../../widget/placeholder_view.dart'; class JoinChat extends SearchDelegate { CancelableOperation? future; diff --git a/lib/view/pages/talk/searchChat.dart b/lib/view/pages/talk/search_chat.dart similarity index 96% rename from lib/view/pages/talk/searchChat.dart rename to lib/view/pages/talk/search_chat.dart index ebca04d..68976fc 100644 --- a/lib/view/pages/talk/searchChat.dart +++ b/lib/view/pages/talk/search_chat.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../../../api/marianumcloud/talk/room/getRoomResponse.dart'; -import 'components/chatTile.dart'; +import 'widgets/chat_tile.dart'; class SearchChat extends SearchDelegate { List chats; diff --git a/lib/view/pages/talk/talkNavigator.dart b/lib/view/pages/talk/talk_navigator.dart similarity index 100% rename from lib/view/pages/talk/talkNavigator.dart rename to lib/view/pages/talk/talk_navigator.dart diff --git a/lib/view/pages/talk/components/answerReference.dart b/lib/view/pages/talk/widgets/answer_reference.dart similarity index 98% rename from lib/view/pages/talk/components/answerReference.dart rename to lib/view/pages/talk/widgets/answer_reference.dart index 6a39b09..825ad18 100644 --- a/lib/view/pages/talk/components/answerReference.dart +++ b/lib/view/pages/talk/widgets/answer_reference.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; import '../../../../api/marianumcloud/talk/chat/richObjectStringProcessor.dart'; -import 'chatBubbleStyles.dart'; +import '../data/chat_bubble_styles.dart'; class AnswerReference extends StatelessWidget { final BuildContext context; diff --git a/lib/view/pages/talk/components/chatBubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart similarity index 60% rename from lib/view/pages/talk/components/chatBubble.dart rename to lib/view/pages/talk/widgets/chat_bubble.dart index c5f216e..e34a878 100644 --- a/lib/view/pages/talk/components/chatBubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -1,31 +1,26 @@ import 'package:bubble/bubble.dart'; -import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emojis; import 'package:flowder/flowder.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:jiffy/jiffy.dart'; import 'package:open_filex/open_filex.dart'; -import '../../../../api/marianumcloud/talk/getPoll/getPollState.dart'; -import '../../../../extensions/text.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; -import '../../../../api/marianumcloud/talk/deleteMessage/deleteMessage.dart'; import '../../../../api/marianumcloud/talk/deleteReactMessage/deleteReactMessage.dart'; import '../../../../api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.dart'; +import '../../../../api/marianumcloud/talk/getPoll/getPollState.dart'; import '../../../../api/marianumcloud/talk/reactMessage/reactMessage.dart'; import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart'; import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../../../../extensions/text.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; -import '../../../../widget/debug/debugTile.dart'; -import '../../../../widget/loadingSpinner.dart'; -import '../../files/fileElement.dart'; -import 'answerReference.dart'; -import 'chatBubbleStyles.dart'; -import 'chatMessage.dart'; -import '../messageReactions.dart'; -import 'pollOptionsList.dart'; +import '../../../../widget/loading_spinner.dart'; +import '../../files/widgets/file_element.dart'; +import '../data/chat_bubble_styles.dart'; +import '../data/chat_message.dart'; +import 'answer_reference.dart'; +import 'chat_message_options_dialog.dart'; +import 'poll_options_list.dart'; class ChatBubble extends StatefulWidget { final BuildContext context; @@ -77,176 +72,13 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM } void showOptionsDialog() { - showDialog(context: context, builder: (context) { - var commonReactions = ['👍', '👎', '😆', '❤️', '👀']; - var canReact = widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment; - return SimpleDialog( - children: [ - Visibility( - visible: canReact, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Wrap( - alignment: WrapAlignment.center, - children: [ - ...commonReactions.map((e) => TextButton( - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(40, 40) - ), - onPressed: () { - Navigator.of(context).pop(); - ReactMessage( - chatToken: widget.chatData.token, - messageId: widget.bubbleData.id, - params: ReactMessageParams(e), - ).run().then((value) => widget.refetch(renew: true)); - }, - child: Text(e), - ), - ), - IconButton( - onPressed: () { - showDialog(context: context, builder: (context) => AlertDialog( - contentPadding: const EdgeInsets.all(15), - titlePadding: const EdgeInsets.only(left: 6, top: 15), - title: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - IconButton( - onPressed: () { - Navigator.of(context).pop(); - }, - icon: const Icon(Icons.arrow_back), - ), - const SizedBox(width: 10), - const Text('Reagieren'), - ], - ), - content: SizedBox( - width: 256, - height: 270, - child: Column( - children: [ - emojis.EmojiPicker( - config: emojis.Config( - height: 256, - // swapCategoryAndBottomBar: true, // TODO this property is no longer supported, need to find an replacement - emojiViewConfig: emojis.EmojiViewConfig( - backgroundColor: Theme.of(context).canvasColor, - recentsLimit: 67, - emojiSizeMax: 25, - noRecents: const Text('Keine zuletzt verwendeten Emojis'), - columns: 7, - ), - bottomActionBarConfig: const emojis.BottomActionBarConfig( - enabled: false, - ), - categoryViewConfig: emojis.CategoryViewConfig( - backgroundColor: Theme.of(context).hoverColor, - iconColorSelected: Theme.of(context).primaryColor, - indicatorColor: Theme.of(context).primaryColor, - ), - searchViewConfig: emojis.SearchViewConfig( - backgroundColor: Theme.of(context).dividerColor, - // buttonColor: Theme.of(context).dividerColor, // TODO property no longer supported - hintText: 'Suchen', - buttonIconColor: Colors.white, - ), - ), - onEmojiSelected: (emojis.Category? category, emojis.Emoji emoji) { - Navigator.of(context).pop(); - Navigator.of(context).pop(); - ReactMessage( - chatToken: widget.chatData.token, - messageId: widget.bubbleData.id, - params: ReactMessageParams(emoji.emoji), - ).run().then((value) => widget.refetch(renew: true)); - }, - ), - ], - ), - ), - )); - }, - style: IconButton.styleFrom( - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(40, 40), - ), - icon: const Icon(Icons.add_circle_outline_outlined), - ), - ], - ), - const Divider(), - ], - ), - ), - Visibility( - visible: widget.bubbleData.isReplyable, - child: ListTile( - leading: const Icon(Icons.reply_outlined), - title: const Text('Antworten'), - onTap: () { - context.read().setReferenceMessageId(widget.bubbleData.id); - Navigator.of(context).pop(); - }, - ), - ), - Visibility( - visible: canReact, - child: ListTile( - leading: const Icon(Icons.emoji_emotions_outlined), - title: const Text('Reaktionen'), - onTap: () { - Navigator.of(context).push(MaterialPageRoute(builder: (context) => MessageReactions( - token: widget.chatData.token, - messageId: widget.bubbleData.id, - ))); - }, - ), - ), - Visibility( - visible: widget.bubbleData.message != '{file}', - child: ListTile( - leading: const Icon(Icons.copy), - title: const Text('Nachricht kopieren'), - onTap: () => { - Clipboard.setData(ClipboardData(text: widget.bubbleData.message)), - Navigator.of(context).pop(), - }, - ), - ), - Visibility( - visible: !kReleaseMode && !widget.isSender && widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne, - child: ListTile( - leading: const Icon(Icons.sms_outlined), - title: Text("Private Nachricht an '${widget.bubbleData.actorDisplayName}'"), - onTap: () => { - Navigator.of(context).pop() - }, - ), - ), - Visibility( - visible: widget.isSender && DateTime.fromMillisecondsSinceEpoch(widget.bubbleData.timestamp * 1000).add(const Duration(hours: 6)).isAfter(DateTime.now()), - child: ListTile( - leading: const Icon(Icons.delete_outline), - title: const Text('Nachricht löschen'), - onTap: () { - DeleteMessage(widget.chatData.token, widget.bubbleData.id).run().then((value) { - if (!context.mounted) return; - context.read().refresh(); - Navigator.of(context).pop(); - }); - }, - ), - ), - DebugTile(context).jsonData(widget.bubbleData.toJson()), - ], - ); - }); + showChatMessageOptionsDialog( + context, + chatData: widget.chatData, + bubbleData: widget.bubbleData, + isSender: widget.isSender, + onRefetch: widget.refetch, + ); } diff --git a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart new file mode 100644 index 0000000..02600f4 --- /dev/null +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -0,0 +1,202 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emojis; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; +import '../../../../api/marianumcloud/talk/deleteMessage/deleteMessage.dart'; +import '../../../../api/marianumcloud/talk/reactMessage/reactMessage.dart'; +import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart'; +import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../../../../routing/app_routes.dart'; +import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../../../../widget/debug/debug_tile.dart'; + +const _commonReactions = ['👍', '👎', '😆', '❤️', '👀']; + +/// Long-press / double-tap options dialog for a single chat message bubble. +/// The hosting [ChatBubble] keeps responsibility for rendering the bubble; +/// this file owns the modal interactions (react, reply, copy, delete, ...). +Future showChatMessageOptionsDialog( + BuildContext context, { + required GetRoomResponseObject chatData, + required GetChatResponseObject bubbleData, + required bool isSender, + required void Function({bool renew}) onRefetch, +}) { + final parentContext = context; + final canReact = bubbleData.messageType == GetRoomResponseObjectMessageType.comment; + final canDelete = isSender && + DateTime.fromMillisecondsSinceEpoch(bubbleData.timestamp * 1000) + .add(const Duration(hours: 6)) + .isAfter(DateTime.now()); + + return showDialog( + context: context, + builder: (dialogCtx) => SimpleDialog( + children: [ + if (canReact) + _ReactionsRow( + chatToken: chatData.token, + messageId: bubbleData.id, + onRefetch: onRefetch, + dialogContext: dialogCtx, + ), + if (bubbleData.isReplyable) + ListTile( + leading: const Icon(Icons.reply_outlined), + title: const Text('Antworten'), + onTap: () { + dialogCtx.read().setReferenceMessageId(bubbleData.id); + Navigator.of(dialogCtx).pop(); + }, + ), + if (canReact) + ListTile( + leading: const Icon(Icons.emoji_emotions_outlined), + title: const Text('Reaktionen'), + onTap: () { + Navigator.of(dialogCtx).pop(); + if (!parentContext.mounted) return; + AppRoutes.openMessageReactions(parentContext, chatData.token, bubbleData.id); + }, + ), + if (bubbleData.message != '{file}') + ListTile( + leading: const Icon(Icons.copy), + title: const Text('Nachricht kopieren'), + onTap: () { + Clipboard.setData(ClipboardData(text: bubbleData.message)); + Navigator.of(dialogCtx).pop(); + }, + ), + if (!kReleaseMode && !isSender && chatData.type != GetRoomResponseObjectConversationType.oneToOne) + ListTile( + leading: const Icon(Icons.sms_outlined), + title: Text("Private Nachricht an '${bubbleData.actorDisplayName}'"), + onTap: () => Navigator.of(dialogCtx).pop(), + ), + if (canDelete) + ListTile( + leading: const Icon(Icons.delete_outline), + title: const Text('Nachricht löschen'), + onTap: () async { + await DeleteMessage(chatData.token, bubbleData.id).run(); + if (!dialogCtx.mounted) return; + dialogCtx.read().refresh(); + Navigator.of(dialogCtx).pop(); + }, + ), + DebugTile(dialogCtx).jsonData(bubbleData.toJson()), + ], + ), + ); +} + +class _ReactionsRow extends StatelessWidget { + final String chatToken; + final int messageId; + final void Function({bool renew}) onRefetch; + final BuildContext dialogContext; + + const _ReactionsRow({ + required this.chatToken, + required this.messageId, + required this.onRefetch, + required this.dialogContext, + }); + + void _react(String emoji) { + Navigator.of(dialogContext).pop(); + ReactMessage( + chatToken: chatToken, + messageId: messageId, + params: ReactMessageParams(emoji), + ).run().then((_) => onRefetch(renew: true)); + } + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + alignment: WrapAlignment.center, + children: [ + ..._commonReactions.map( + (emoji) => TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(40, 40), + ), + onPressed: () => _react(emoji), + child: Text(emoji), + ), + ), + IconButton( + onPressed: () => _showEmojiPicker(context), + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(40, 40), + ), + icon: const Icon(Icons.add_circle_outline_outlined), + ), + ], + ), + const Divider(), + ], + ); + + void _showEmojiPicker(BuildContext rowContext) { + showDialog( + context: rowContext, + builder: (pickerCtx) => AlertDialog( + contentPadding: const EdgeInsets.all(15), + titlePadding: const EdgeInsets.only(left: 6, top: 15), + title: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(pickerCtx).pop(), + icon: const Icon(Icons.arrow_back), + ), + const SizedBox(width: 10), + const Text('Reagieren'), + ], + ), + content: SizedBox( + width: 256, + height: 270, + child: emojis.EmojiPicker( + config: emojis.Config( + height: 256, + emojiViewConfig: emojis.EmojiViewConfig( + backgroundColor: Theme.of(pickerCtx).canvasColor, + recentsLimit: 67, + emojiSizeMax: 25, + noRecents: const Text('Keine zuletzt verwendeten Emojis'), + columns: 7, + ), + bottomActionBarConfig: const emojis.BottomActionBarConfig(enabled: false), + categoryViewConfig: emojis.CategoryViewConfig( + backgroundColor: Theme.of(pickerCtx).hoverColor, + iconColorSelected: Theme.of(pickerCtx).primaryColor, + indicatorColor: Theme.of(pickerCtx).primaryColor, + ), + searchViewConfig: emojis.SearchViewConfig( + backgroundColor: Theme.of(pickerCtx).dividerColor, + hintText: 'Suchen', + buttonIconColor: Colors.white, + ), + ), + onEmojiSelected: (_, emoji) { + Navigator.of(pickerCtx).pop(); + _react(emoji.emoji); + }, + ), + ), + ), + ); + } +} diff --git a/lib/view/pages/talk/components/chatTextfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart similarity index 98% rename from lib/view/pages/talk/components/chatTextfield.dart rename to lib/view/pages/talk/widgets/chat_textfield.dart index a056984..2ef4527 100644 --- a/lib/view/pages/talk/components/chatTextfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -12,10 +12,10 @@ import '../../../../api/marianumcloud/talk/sendMessage/sendMessageParams.dart'; import '../../../../api/marianumcloud/webdav/webdavApi.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; -import '../../../../widget/filePick.dart'; -import '../../../../widget/focusBehaviour.dart'; -import '../../files/filesUploadDialog.dart'; -import 'answerReference.dart'; +import '../../../../widget/file_pick.dart'; +import '../../../../widget/focus_behaviour.dart'; +import '../../files/files_upload_dialog.dart'; +import 'answer_reference.dart'; class ChatTextfield extends StatefulWidget { final String sendToToken; diff --git a/lib/view/pages/talk/components/chatTile.dart b/lib/view/pages/talk/widgets/chat_tile.dart similarity index 96% rename from lib/view/pages/talk/components/chatTile.dart rename to lib/view/pages/talk/widgets/chat_tile.dart index 53d20b4..1696479 100644 --- a/lib/view/pages/talk/components/chatTile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -9,14 +9,14 @@ import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; import '../../../../api/marianumcloud/talk/setFavorite/setFavorite.dart'; import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarker.dart'; import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dart'; -import '../../../../model/accountData.dart'; +import '../../../../model/account_data.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/chatList/bloc/chat_list_bloc.dart'; -import '../../../../widget/confirmDialog.dart'; -import '../../../../widget/debug/debugTile.dart'; -import '../../../../widget/userAvatar.dart'; -import '../chatView.dart'; -import '../talkNavigator.dart'; +import '../../../../widget/confirm_dialog.dart'; +import '../../../../widget/debug/debug_tile.dart'; +import '../../../../widget/user_avatar.dart'; +import '../chat_view.dart'; +import '../talk_navigator.dart'; class ChatTile extends StatefulWidget { final GetRoomResponseObject data; diff --git a/lib/view/pages/talk/components/pollOptionsList.dart b/lib/view/pages/talk/widgets/poll_options_list.dart similarity index 98% rename from lib/view/pages/talk/components/pollOptionsList.dart rename to lib/view/pages/talk/widgets/poll_options_list.dart index c7eb9ea..4bade65 100644 --- a/lib/view/pages/talk/components/pollOptionsList.dart +++ b/lib/view/pages/talk/widgets/poll_options_list.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; import '../../../../api/marianumcloud/talk/getPoll/getPollStateResponse.dart'; -import '../../../../utils/UrlOpener.dart'; +import '../../../../utils/url_opener.dart'; class PollOptionsList extends StatefulWidget { final GetPollStateResponseObject pollData; diff --git a/lib/view/pages/talk/components/splitViewPlaceholder.dart b/lib/view/pages/talk/widgets/split_view_placeholder.dart similarity index 94% rename from lib/view/pages/talk/components/splitViewPlaceholder.dart rename to lib/view/pages/talk/widgets/split_view_placeholder.dart index d5c4030..590a1ec 100644 --- a/lib/view/pages/talk/components/splitViewPlaceholder.dart +++ b/lib/view/pages/talk/widgets/split_view_placeholder.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../../../../theming/appTheme.dart'; +import '../../../../theming/app_theme.dart'; class SplitViewPlaceholder extends StatelessWidget { const SplitViewPlaceholder({super.key}); diff --git a/lib/view/pages/timetable/custom_events/custom_event_colors.dart b/lib/view/pages/timetable/custom_events/custom_event_colors.dart index a4540fe..04f623a 100644 --- a/lib/view/pages/timetable/custom_events/custom_event_colors.dart +++ b/lib/view/pages/timetable/custom_events/custom_event_colors.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../../../../theming/darkAppTheme.dart'; +import '../../../../theming/dark_app_theme.dart'; enum CustomTimetableColors { orange, red, green, blue } 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 f886131..34479ca 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 @@ -7,10 +7,10 @@ import 'package:rrule_generator/rrule_generator.dart'; import 'package:time_range_picker/time_range_picker.dart'; import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; -import '../../../../extensions/dateTime.dart'; +import '../../../../extensions/date_time.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; -import '../../../../widget/focusBehaviour.dart'; -import '../../../../widget/infoDialog.dart'; +import '../../../../widget/focus_behaviour.dart'; +import '../../../../widget/info_dialog.dart'; import 'custom_event_colors.dart'; class CustomEventEditDialog extends StatefulWidget { diff --git a/lib/view/pages/timetable/custom_events/custom_events_view.dart b/lib/view/pages/timetable/custom_events/custom_events_view.dart index 34af737..a1c6595 100644 --- a/lib/view/pages/timetable/custom_events/custom_events_view.dart +++ b/lib/view/pages/timetable/custom_events/custom_events_view.dart @@ -4,8 +4,8 @@ import 'package:jiffy/jiffy.dart'; import '../../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; -import '../../../../widget/centeredLeading.dart'; -import '../../../../widget/placeholderView.dart'; +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/placeholder_view.dart'; import '../details/delete_custom_event.dart'; import 'custom_event_edit_dialog.dart'; diff --git a/lib/view/pages/timetable/details/custom_event_sheet.dart b/lib/view/pages/timetable/details/custom_event_sheet.dart index 9f11099..3675abf 100644 --- a/lib/view/pages/timetable/details/custom_event_sheet.dart +++ b/lib/view/pages/timetable/details/custom_event_sheet.dart @@ -3,8 +3,8 @@ import 'package:jiffy/jiffy.dart'; import 'package:rrule/rrule.dart'; import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; -import '../../../../widget/centeredLeading.dart'; -import '../../../../widget/debug/debugTile.dart'; +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/debug/debug_tile.dart'; import '../custom_events/custom_event_edit_dialog.dart'; import '_bottom_sheet.dart'; import 'delete_custom_event.dart'; diff --git a/lib/view/pages/timetable/details/delete_custom_event.dart b/lib/view/pages/timetable/details/delete_custom_event.dart index ad362d5..3161e93 100644 --- a/lib/view/pages/timetable/details/delete_custom_event.dart +++ b/lib/view/pages/timetable/details/delete_custom_event.dart @@ -5,7 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; -import '../../../../widget/confirmDialog.dart'; +import '../../../../widget/confirm_dialog.dart'; Completer showDeleteCustomEventDialog(BuildContext context, CustomTimetableEvent event) { final completer = Completer(); diff --git a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart index 1fb0bd7..7adfee6 100644 --- a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart +++ b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart @@ -1,17 +1,16 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; -import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; import '../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; -import '../../../../widget/debug/debugTile.dart'; -import '../../../../widget/unimplementedDialog.dart'; -import '../../more/roomplan/roomplan.dart'; +import '../../../../widget/debug/debug_tile.dart'; +import '../../../../widget/unimplemented_dialog.dart'; import '_bottom_sheet.dart'; class WebuntisLessonSheet { @@ -54,7 +53,7 @@ class WebuntisLessonSheet { title: Text('Raum: ${room.name} (${room.longName})'), trailing: IconButton( icon: const Icon(Icons.house_outlined), - onPressed: () => pushScreen(context, withNavBar: false, screen: const Roomplan()), + onPressed: () => AppRoutes.openRoomplan(context), ), ), ListTile( diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index 5099577..20ae430 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -2,13 +2,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; -import '../../../extensions/dateTime.dart'; +import '../../../extensions/date_time.dart'; +import '../../../routing/app_routes.dart'; import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import 'custom_events/custom_event_edit_dialog.dart'; -import 'custom_events/custom_events_view.dart'; import 'data/arbitrary_appointment.dart'; import 'data/lesson_period_schedule.dart'; import 'data/timetable_appointment_factory.dart'; @@ -46,7 +46,7 @@ class _TimetableState extends State { barrierDismissible: false, ); case _CalendarAction.viewEvents: - Navigator.of(context).push(MaterialPageRoute(builder: (_) => const CustomEventsView())); + AppRoutes.openCustomEvents(context); } } diff --git a/lib/view/pages/timetable/widgets/special_regions_builder.dart b/lib/view/pages/timetable/widgets/special_regions_builder.dart index 6587b93..2007f46 100644 --- a/lib/view/pages/timetable/widgets/special_regions_builder.dart +++ b/lib/view/pages/timetable/widgets/special_regions_builder.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; -import '../../../../extensions/dateTime.dart'; +import '../../../../extensions/date_time.dart'; import '../data/calendar_layout.dart'; import '../data/lesson_period_schedule.dart'; import '../data/webuntis_time.dart'; diff --git a/lib/view/settings/settings.dart b/lib/view/settings/settings.dart deleted file mode 100644 index 85cfbc5..0000000 --- a/lib/view/settings/settings.dart +++ /dev/null @@ -1,318 +0,0 @@ - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../../model/accountData.dart'; -import '../../notification/notifyUpdater.dart'; -import '../../state/app/modules/settings/bloc/settings_cubit.dart'; -import '../../state/app/modules/timetable/bloc/timetable_bloc.dart'; -import '../../storage/base/settings.dart' as model; -import '../../theming/appTheme.dart'; -import '../../widget/centeredLeading.dart'; -import '../../widget/confirmDialog.dart'; -import '../../widget/debug/cacheView.dart'; -import '../../storage/timetable/timetable_name_mode.dart'; -import 'defaultSettings.dart'; -import 'devToolsSettings.dart'; -import 'privacyInfo.dart'; - -class Settings extends StatefulWidget { - const Settings({super.key}); - - @override - State createState() => _SettingsState(); -} - -class _SettingsState extends State { - - @override - void initState() { - super.initState(); - } - - bool developerMode = false; - - @override - Widget build(BuildContext context) => BlocBuilder(builder: (context, _) { - final settings = context.read(); - return Scaffold( - appBar: AppBar( - title: const Text('Einstellungen'), - ), - body: ListView( - children: [ - ListTile( - leading: const CenteredLeading(Icon(Icons.logout_outlined)), - title: const Text('Konto abmelden'), - subtitle: Text('Angemeldet als ${AccountData().getUsername()}'), - onTap: () { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Abmelden?', - content: 'Möchtest du dich wirklich abmelden?', - confirmButton: 'Abmelden', - onConfirm: () { - SharedPreferences.getInstance().then((value) => { - value.clear(), - }).then((value) async { - PaintingBinding.instance.imageCache.clear(); - if (!context.mounted) return; - context.read().reset(); - const CacheView().clear(); - AccountData().removeData(context: context); - Navigator.popUntil(context, (route) => !Navigator.canPop(context)); - }); - }, - ), - ); - }, - ), - - const Divider(), - - ListTile( - leading: const Icon(Icons.dark_mode_outlined), - title: const Text('Farbgebung'), - trailing: DropdownButton( - value: settings.val().appTheme, - icon: const Icon(Icons.arrow_drop_down), - items: ThemeMode.values.map((e) => DropdownMenuItem( - value: e, - enabled: e != settings.val().appTheme, - child: Row( - children: [ - Icon(AppTheme.getDisplayOptions(e).icon), - const SizedBox(width: 10), - Text(AppTheme.getDisplayOptions(e).displayName), - ], - ), - )).toList(), - onChanged: (e) { - settings.val(write: true).appTheme = e!; - }, - ), - ), - - const Divider(), - - ListTile( - leading: const Icon(Icons.abc_outlined), - title: const Text('Fachbezeichnung'), - trailing: DropdownButton( - value: settings.val().timetableSettings.timetableNameMode, - icon: Icon(Icons.arrow_drop_down), - items: TimetableNameMode.values.map((e) => DropdownMenuItem( - value: e, - enabled: e != settings.val().timetableSettings.timetableNameMode, - child: Row( - children: [ - Icon(TimetableNameModes.getDisplayOptions(e).icon), - const SizedBox(width: 10), - Text(TimetableNameModes.getDisplayOptions(e).displayName), - ], - ), - )).toList(), - onChanged: (value) { - settings.val(write: true).timetableSettings.timetableNameMode = value!; - context.read().refresh(); - }, - ) - ), - ListTile( - leading: const Icon(Icons.calendar_view_day_outlined), - title: const Text('Doppelstunden zusammenhängend anzeigen'), - trailing: Checkbox( - value: settings.val().timetableSettings.connectDoubleLessons, - onChanged: (e) { - settings.val(write: true).timetableSettings.connectDoubleLessons = e!; - context.read().refresh(); - }, - ), - ), - - const Divider(), - - ListTile( - leading: const Icon(Icons.star_border), - title: const Text('Favoriten im Talk nach oben sortieren'), - trailing: Checkbox( - value: settings.val().talkSettings.sortFavoritesToTop, - onChanged: (e) { - settings.val(write: true).talkSettings.sortFavoritesToTop = e!; - }, - ), - ), - - ListTile( - leading: const Icon(Icons.mark_email_unread_outlined), - title: const Text('Ungelesene Chats nach oben sortieren'), - trailing: Checkbox( - value: settings.val().talkSettings.sortUnreadToTop, - onChanged: (e) { - settings.val(write: true).talkSettings.sortUnreadToTop = e!; - }, - ), - ), - - ListTile( - leading: const CenteredLeading(Icon(Icons.notifications_active_outlined)), - title: const Text('Push-Benachrichtigungen aktivieren'), - subtitle: const Text('Lange tippen für mehr Informationen'), - trailing: Checkbox( - value: settings.val().notificationSettings.enabled, - onChanged: (e) { - if(e!) { - NotifyUpdater.enableAfterDisclaimer(settings).asDialog(context); - } else { - settings.val(write: true).notificationSettings.enabled = e; - } - }, - ), - onLongPress: () => showDialog(context: context, builder: (context) => AlertDialog( - title: const Text('Info über Push'), - content: const SingleChildScrollView(child: Text('' - "Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n" - 'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n' - 'Der extene Server verwendet die Zugangsdaten um sich maschinell in Nextcloud Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n' - 'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n' - 'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!' - )), - actions: [ - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Zurück')) - ], - )), - ), - - const Divider(), - - ListTile( - leading: const Icon(Icons.drive_folder_upload_outlined), - title: const Text('Ordner in Dateien nach oben sortieren'), - trailing: Checkbox( - value: settings.val().fileSettings.sortFoldersToTop, - onChanged: (e) { - settings.val(write: true).fileSettings.sortFoldersToTop = e!; - }, - ), - ), - - ListTile( - leading: const Icon(Icons.open_in_new_outlined), - title: const Text('Dateien immer mit Systemdialog öffnen'), - trailing: Checkbox( - value: settings.val().fileViewSettings.alwaysOpenExternally, - onChanged: (e) { - settings.val(write: true).fileViewSettings.alwaysOpenExternally = e!; - }, - ), - ), - - const Divider(), - - ListTile( - leading: const Icon(Icons.live_help_outlined), - title: const Text('Informationen und Lizenzen'), - onTap: () { - PackageInfo.fromPlatform().then((appInfo) { - if (!context.mounted) return; - showAboutDialog( - context: context, - applicationIcon: const Icon(Icons.apps), - applicationName: 'MarianumMobile', - applicationVersion: '${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}', - applicationLegalese: 'Dies ist ein Inoffizieller Nextcloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n' - 'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n' - "${kReleaseMode ? "Production" : "Development"} build\n" - 'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller', - ); - }); - }, - trailing: const Icon(Icons.arrow_right), - ), - - ListTile( - leading: const Icon(Icons.policy_outlined), - title: const Text('Impressum & Datenschutz'), - onTap: () { - showDialog(context: context, builder: (context) => SimpleDialog( - children: [ - ListTile( - leading: const CenteredLeading(Icon(Icons.school_outlined)), - title: const Text('Infos zum Marianum Fulda'), - subtitle: const Text('Für Talk-Chats und Dateien'), - trailing: const Icon(Icons.arrow_right), - onTap: () => PrivacyInfo(providerText: 'Marianum', imprintUrl: 'https://www.marianum-fulda.de/impressum', privacyUrl: 'https://www.marianum-fulda.de/datenschutz').showPopup(context) - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.date_range_outlined)), - title: const Text('Infos zu Web-/ Untis'), - subtitle: const Text('Für den Stundenplan'), - trailing: const Icon(Icons.arrow_right), - onTap: () => PrivacyInfo(providerText: 'Untis', imprintUrl: 'https://www.untis.at/impressum', privacyUrl: 'https://www.untis.at/datenschutz-wu-apps').showPopup(context) - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.send_time_extension_outlined)), - title: const Text('Infos zu mhsl'), - subtitle: const Text('Für Countdowns, Marianum Message und mehr'), - trailing: const Icon(Icons.arrow_right), - onTap: () => PrivacyInfo(providerText: 'mhsl', imprintUrl: 'https://mhsl.eu/id.html', privacyUrl: 'https://mhsl.eu/datenschutz.html').showPopup(context), - ), - ], - )); - }, - trailing: const Icon(Icons.arrow_right), - ), - - const Divider(), - - ListTile( - leading: const CenteredLeading(Icon(Icons.code)), - title: const Text('Quellcode MarianumMobile/Client'), - subtitle: const Text('GNU GPL v3'), - onTap: () => ConfirmDialog.openBrowser(context, 'https://mhsl.eu/gitea/MarianumMobile/Client'), - ), - - ListTile( - leading: const Icon(Icons.developer_mode_outlined), - title: const Text('Entwicklermodus'), - trailing: Checkbox( - value: settings.val().devToolsEnabled, - onChanged: (state) { - changeView() { - var enabled = state ?? false; - settings.val(write: true).devToolsEnabled = enabled; - if(!enabled) settings.val(write: true).devToolsSettings = DefaultSettings.get().devToolsSettings; - } - - if(!state!) { - changeView(); - return; - } - - ConfirmDialog( - title: 'Entwicklermodus', - content: '' - 'Die Entwickleransicht bietet erweiterte Funktionen, die für den üblichen Gebrauch nicht benötigt werden.\n\nDie Verwendung der Tools kann darüber hinaus bei falscher Verwendung zu Fehlern führen.\n\n' - 'Aktivieren auf eigene Verantwortung.', - confirmButton: 'Ja, ich verstehe das Risiko', - cancelButton: 'Nein, zurück zur App', - onConfirm: changeView, - ).asDialog(context); - }, - ), - ), - - Visibility( - visible: settings.val().devToolsEnabled, - child: DevToolsSettings(settings: settings), - ), - ], - ), - ); - }); -} diff --git a/lib/widget/animatedTime.dart b/lib/widget/animated_time.dart similarity index 100% rename from lib/widget/animatedTime.dart rename to lib/widget/animated_time.dart diff --git a/lib/widget/breaker/breaker.dart b/lib/widget/breaker/breaker.dart index 9c66de2..9c9186e 100644 --- a/lib/widget/breaker/breaker.dart +++ b/lib/widget/breaker/breaker.dart @@ -3,7 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; import '../../state/app/modules/breaker/bloc/breaker_bloc.dart'; -import '../../widget/placeholderView.dart'; +import '../../widget/placeholder_view.dart'; class Breaker extends StatelessWidget { final BreakerArea breaker; diff --git a/lib/widget/centeredLeading.dart b/lib/widget/centered_leading.dart similarity index 100% rename from lib/widget/centeredLeading.dart rename to lib/widget/centered_leading.dart diff --git a/lib/widget/clickableAppBar.dart b/lib/widget/clickable_app_bar.dart similarity index 100% rename from lib/widget/clickableAppBar.dart rename to lib/widget/clickable_app_bar.dart diff --git a/lib/widget/confirmDialog.dart b/lib/widget/confirm_dialog.dart similarity index 100% rename from lib/widget/confirmDialog.dart rename to lib/widget/confirm_dialog.dart diff --git a/lib/widget/debug/cacheView.dart b/lib/widget/debug/cache_view.dart similarity index 97% rename from lib/widget/debug/cacheView.dart rename to lib/widget/debug/cache_view.dart index ab66605..c92132b 100644 --- a/lib/widget/debug/cacheView.dart +++ b/lib/widget/debug/cache_view.dart @@ -6,9 +6,9 @@ import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; import 'package:localstore/localstore.dart'; -import '../../../widget/placeholderView.dart'; +import '../../../widget/placeholder_view.dart'; import '../../api/requestCache.dart'; -import 'jsonViewer.dart'; +import 'json_viewer.dart'; class CacheView extends StatefulWidget { const CacheView({super.key}); diff --git a/lib/widget/debug/debugTile.dart b/lib/widget/debug/debug_tile.dart similarity index 94% rename from lib/widget/debug/debugTile.dart rename to lib/widget/debug/debug_tile.dart index 7a29fcc..50bf937 100644 --- a/lib/widget/debug/debugTile.dart +++ b/lib/widget/debug/debug_tile.dart @@ -3,8 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../state/app/modules/settings/bloc/settings_cubit.dart'; -import '../centeredLeading.dart'; -import 'jsonViewer.dart'; +import '../centered_leading.dart'; +import 'json_viewer.dart'; class DebugTile { BuildContext context; diff --git a/lib/widget/debug/jsonViewer.dart b/lib/widget/debug/json_viewer.dart similarity index 100% rename from lib/widget/debug/jsonViewer.dart rename to lib/widget/debug/json_viewer.dart diff --git a/lib/widget/dropdownDisplay.dart b/lib/widget/dropdown_display.dart similarity index 100% rename from lib/widget/dropdownDisplay.dart rename to lib/widget/dropdown_display.dart diff --git a/lib/widget/filePick.dart b/lib/widget/file_pick.dart similarity index 100% rename from lib/widget/filePick.dart rename to lib/widget/file_pick.dart diff --git a/lib/widget/fileViewer.dart b/lib/widget/file_viewer.dart similarity index 94% rename from lib/widget/fileViewer.dart rename to lib/widget/file_viewer.dart index 8ab5c58..88bb8f8 100644 --- a/lib/widget/fileViewer.dart +++ b/lib/widget/file_viewer.dart @@ -8,11 +8,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:share_plus/share_plus.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; +import '../routing/app_routes.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart'; -import '../utils/FileSaver.dart'; -import 'infoDialog.dart'; -import 'placeholderView.dart'; -import 'sharePositionOrigin.dart'; +import '../utils/file_saver.dart'; +import 'info_dialog.dart'; +import 'placeholder_view.dart'; +import 'share_position_origin.dart'; class FileViewer extends StatefulWidget { final String path; @@ -51,9 +52,7 @@ class _FileViewerState extends State { onSelected: (value) async { switch(value) { case FileViewingActions.openExternal: - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => FileViewer(path: widget.path, openExternal: true)) - ); + AppRoutes.openFileViewer(context, widget.path, openExternal: true); break; case FileViewingActions.share: SharePlus.instance.share( diff --git a/lib/widget/focusBehaviour.dart b/lib/widget/focus_behaviour.dart similarity index 100% rename from lib/widget/focusBehaviour.dart rename to lib/widget/focus_behaviour.dart diff --git a/lib/widget/infoDialog.dart b/lib/widget/info_dialog.dart similarity index 100% rename from lib/widget/infoDialog.dart rename to lib/widget/info_dialog.dart diff --git a/lib/widget/largeProfilePictureView.dart b/lib/widget/large_profile_picture_view.dart similarity index 94% rename from lib/widget/largeProfilePictureView.dart rename to lib/widget/large_profile_picture_view.dart index 65a9a4f..c8abe23 100644 --- a/lib/widget/largeProfilePictureView.dart +++ b/lib/widget/large_profile_picture_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:photo_view/photo_view.dart'; -import '../model/endpointData.dart'; +import '../model/endpoint_data.dart'; class LargeProfilePictureView extends StatelessWidget { final String username; diff --git a/lib/widget/loadingSpinner.dart b/lib/widget/loading_spinner.dart similarity index 100% rename from lib/widget/loadingSpinner.dart rename to lib/widget/loading_spinner.dart diff --git a/lib/widget/placeholderView.dart b/lib/widget/placeholder_view.dart similarity index 100% rename from lib/widget/placeholderView.dart rename to lib/widget/placeholder_view.dart diff --git a/lib/widget/sharePositionOrigin.dart b/lib/widget/share_position_origin.dart similarity index 100% rename from lib/widget/sharePositionOrigin.dart rename to lib/widget/share_position_origin.dart diff --git a/lib/widget/unimplementedDialog.dart b/lib/widget/unimplemented_dialog.dart similarity index 100% rename from lib/widget/unimplementedDialog.dart rename to lib/widget/unimplemented_dialog.dart diff --git a/lib/widget/userAvatar.dart b/lib/widget/user_avatar.dart similarity index 97% rename from lib/widget/userAvatar.dart rename to lib/widget/user_avatar.dart index bab843b..bcabac6 100644 --- a/lib/widget/userAvatar.dart +++ b/lib/widget/user_avatar.dart @@ -5,8 +5,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:http/http.dart' as http; -import '../model/accountData.dart'; -import '../model/endpointData.dart'; +import '../model/account_data.dart'; +import '../model/endpoint_data.dart'; class UserAvatar extends StatefulWidget { final String id; From 9b5a70b285a16cd4e0f673d90b126961351a9759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Tue, 5 May 2026 22:00:07 +0200 Subject: [PATCH 06/23] api and storage restructure --- lib/api/holidays/getHolidaysCache.dart | 30 ++++----- .../autocomplete/autocompleteApi.dart | 38 +++++------- .../files-sharing/fileSharingApi.dart | 18 +++--- lib/api/marianumcloud/nextcloud_ocs.dart | 33 ++++++++++ .../talk/actions/talk_actions.dart | 50 +++++++++++++++ .../marianumcloud/talk/chat/getChatCache.dart | 38 +++++------- .../talk/deleteMessage/deleteMessage.dart | 18 ------ .../getParticipants/getParticipantsCache.dart | 25 +++----- .../talk/leaveRoom/leaveRoom.dart | 16 ----- .../marianumcloud/talk/room/getRoomCache.dart | 29 ++------- .../talk/sendMessage/sendMessageResponse.dart | 5 -- .../talk/setFavorite/setFavorite.dart | 25 -------- lib/api/marianumcloud/talk/talkApi.dart | 35 ++++------- .../marianumcloud/talk/votePoll/votePoll.dart | 26 -------- .../talk/votePoll/votePollParams.dart | 15 ----- .../talk/votePoll/votePollParams.g.dart | 17 ----- .../queries/listFiles/listFilesCache.dart | 34 +++------- .../breaker/getBreakers/getBreakersCache.dart | 17 +++-- .../get/getCustomTimetableEventCache.dart | 27 +++----- lib/api/requestCache.dart | 35 +++++++++++ .../queries/getHolidays/getHolidaysCache.dart | 24 ++----- .../queries/getRooms/getRoomsCache.dart | 25 ++------ .../queries/getSubjects/getSubjectsCache.dart | 25 ++------ .../getTimegridUnitsCache.dart | 20 +++--- .../getTimetable/getTimetableCache.dart | 62 +++++++++---------- lib/main.dart | 2 +- .../modules/settings/bloc/settings_cubit.dart | 2 +- ...sSettings.dart => dev_tools_settings.dart} | 2 +- ...tings.g.dart => dev_tools_settings.g.dart} | 2 +- .../fileSettings.dart => file_settings.dart} | 2 +- ...leSettings.g.dart => file_settings.g.dart} | 2 +- ...wSettings.dart => file_view_settings.dart} | 2 +- ...tings.g.dart => file_view_settings.g.dart} | 2 +- ...ysSettings.dart => holidays_settings.dart} | 2 +- ...ttings.g.dart => holidays_settings.g.dart} | 2 +- ...lesSettings.dart => modules_settings.dart} | 2 +- ...ettings.g.dart => modules_settings.g.dart} | 2 +- ...ttings.dart => notification_settings.dart} | 2 +- ...gs.g.dart => notification_settings.g.dart} | 2 +- lib/storage/{base => }/settings.dart | 16 ++--- lib/storage/{base => }/settings.g.dart | 0 .../talkSettings.dart => talk_settings.dart} | 2 +- ...lkSettings.g.dart => talk_settings.g.dart} | 2 +- ...eSettings.dart => timetable_settings.dart} | 4 +- ...tings.g.dart => timetable_settings.g.dart} | 2 +- lib/view/pages/overhang.dart | 2 +- .../pages/settings/data/default_settings.dart | 20 +++--- .../settings/sections/timetable_section.dart | 2 +- lib/view/pages/settings/settings.dart | 2 +- .../widgets/chat_message_options_dialog.dart | 2 +- lib/view/pages/talk/widgets/chat_tile.dart | 3 +- .../data/timetable_appointment_factory.dart | 4 +- .../timetable/data}/timetable_name_mode.dart | 2 +- 53 files changed, 318 insertions(+), 460 deletions(-) create mode 100644 lib/api/marianumcloud/nextcloud_ocs.dart create mode 100644 lib/api/marianumcloud/talk/actions/talk_actions.dart delete mode 100644 lib/api/marianumcloud/talk/deleteMessage/deleteMessage.dart delete mode 100644 lib/api/marianumcloud/talk/leaveRoom/leaveRoom.dart delete mode 100644 lib/api/marianumcloud/talk/sendMessage/sendMessageResponse.dart delete mode 100644 lib/api/marianumcloud/talk/setFavorite/setFavorite.dart delete mode 100644 lib/api/marianumcloud/talk/votePoll/votePoll.dart delete mode 100644 lib/api/marianumcloud/talk/votePoll/votePollParams.dart delete mode 100644 lib/api/marianumcloud/talk/votePoll/votePollParams.g.dart rename lib/storage/{devTools/devToolsSettings.dart => dev_tools_settings.dart} (93%) rename lib/storage/{devTools/devToolsSettings.g.dart => dev_tools_settings.g.dart} (96%) rename lib/storage/{file/fileSettings.dart => file_settings.dart} (94%) rename lib/storage/{file/fileSettings.g.dart => file_settings.g.dart} (96%) rename lib/storage/{fileView/fileViewSettings.dart => file_view_settings.dart} (91%) rename lib/storage/{fileView/fileViewSettings.g.dart => file_view_settings.g.dart} (94%) rename lib/storage/{holidays/holidaysSettings.dart => holidays_settings.dart} (92%) rename lib/storage/{holidays/holidaysSettings.g.dart => holidays_settings.g.dart} (95%) rename lib/storage/{general/modulesSettings.dart => modules_settings.dart} (93%) rename lib/storage/{general/modulesSettings.g.dart => modules_settings.g.dart} (97%) rename lib/storage/{notification/notificationSettings.dart => notification_settings.dart} (91%) rename lib/storage/{notification/notificationSettings.g.dart => notification_settings.g.dart} (94%) rename lib/storage/{base => }/settings.dart (78%) rename lib/storage/{base => }/settings.g.dart (100%) rename lib/storage/{talk/talkSettings.dart => talk_settings.dart} (94%) rename lib/storage/{talk/talkSettings.g.dart => talk_settings.g.dart} (96%) rename lib/storage/{timetable/timetableSettings.dart => timetable_settings.dart} (81%) rename lib/storage/{timetable/timetableSettings.g.dart => timetable_settings.g.dart} (96%) rename lib/{storage/timetable => view/pages/timetable/data}/timetable_name_mode.dart (92%) diff --git a/lib/api/holidays/getHolidaysCache.dart b/lib/api/holidays/getHolidaysCache.dart index 66e3147..2707916 100644 --- a/lib/api/holidays/getHolidaysCache.dart +++ b/lib/api/holidays/getHolidaysCache.dart @@ -1,26 +1,18 @@ -import 'dart:convert'; - import '../requestCache.dart'; import 'getHolidays.dart'; import 'getHolidaysResponse.dart'; -class GetHolidaysCache extends RequestCache { - GetHolidaysCache({void Function(GetHolidaysResponse)? onUpdate, bool? renew}) : super(RequestCache.cacheDay, onUpdate, renew: renew) { +class GetHolidaysCache extends SimpleCache { + GetHolidaysCache({super.onUpdate, super.renew}) + : super( + cacheTime: RequestCache.cacheDay, + loader: () => GetHolidays().query(), + fromJson: (json) => GetHolidaysResponse( + (json['data'] as List) + .map((i) => GetHolidaysResponseObject.fromJson(i as Map)) + .toList(), + ), + ) { start('state-holidays'); } - - @override - GetHolidaysResponse onLocalData(String json) { - List parsedListJson = jsonDecode(json)['data']; - return GetHolidaysResponse( - List.from( - parsedListJson.map( - (i) => GetHolidaysResponseObject.fromJson(i as Map) - ) - ) - ); - } - - @override - Future onLoad() => GetHolidays().query(); } diff --git a/lib/api/marianumcloud/autocomplete/autocompleteApi.dart b/lib/api/marianumcloud/autocomplete/autocompleteApi.dart index 8c67694..1539bd7 100644 --- a/lib/api/marianumcloud/autocomplete/autocompleteApi.dart +++ b/lib/api/marianumcloud/autocomplete/autocompleteApi.dart @@ -3,31 +3,25 @@ import 'dart:io'; import 'package:http/http.dart' as http; -import '../../../model/account_data.dart'; -import '../../../model/endpoint_data.dart'; +import '../nextcloud_ocs.dart'; import 'autocompleteResponse.dart'; class AutocompleteApi { Future find(String query) async { - var getParameters = { - 'search': query, - 'itemType': ' ', - 'itemId': ' ', - 'shareTypes[]': ['0'], - 'limit': '10', - }; - - var headers = {}; - headers.putIfAbsent('Accept', () => 'application/json'); - headers.putIfAbsent('OCS-APIRequest', () => 'true'); - headers.putIfAbsent('Authorization', AccountData().getBasicAuthHeader); - - var endpoint = Uri.https(EndpointData().nextcloud().domain, '${EndpointData().nextcloud().path}/ocs/v2.php/core/autocomplete/get', getParameters); - - var response = await http.get(endpoint, headers: headers); - if(response.statusCode != HttpStatus.ok) throw Exception('Api call failed with ${response.statusCode}: ${response.body}'); - var result = response.body; - return AutocompleteResponse.fromJson(jsonDecode(result)['ocs']); + final endpoint = NextcloudOcs.uri( + 'core/autocomplete/get', + queryParameters: { + 'search': query, + 'itemType': ' ', + 'itemId': ' ', + 'shareTypes[]': ['0'], + 'limit': '10', + }, + ); + final response = await http.get(endpoint, headers: NextcloudOcs.headers()); + if (response.statusCode != HttpStatus.ok) { + throw Exception('Api call failed with ${response.statusCode}: ${response.body}'); + } + return AutocompleteResponse.fromJson(jsonDecode(response.body)['ocs']); } - } diff --git a/lib/api/marianumcloud/files-sharing/fileSharingApi.dart b/lib/api/marianumcloud/files-sharing/fileSharingApi.dart index ab1b180..5914915 100644 --- a/lib/api/marianumcloud/files-sharing/fileSharingApi.dart +++ b/lib/api/marianumcloud/files-sharing/fileSharingApi.dart @@ -2,21 +2,17 @@ import 'dart:io'; import 'package:http/http.dart' as http; -import '../../../model/account_data.dart'; -import '../../../model/endpoint_data.dart'; +import '../nextcloud_ocs.dart'; import 'fileSharingApiParams.dart'; class FileSharingApi { Future share(FileSharingApiParams query) async { - var headers = {}; - headers.putIfAbsent('Accept', () => 'application/json'); - headers.putIfAbsent('OCS-APIRequest', () => 'true'); - headers.putIfAbsent('Authorization', AccountData().getBasicAuthHeader); - - var endpoint = Uri.https(EndpointData().nextcloud().domain, '${EndpointData().nextcloud().path}/ocs/v2.php/apps/files_sharing/api/v1/shares', query.toJson().map((key, value) => MapEntry(key, value.toString()))); - var response = await http.post(endpoint, headers: headers); - - if(response.statusCode != HttpStatus.ok) { + final endpoint = NextcloudOcs.uri( + 'apps/files_sharing/api/v1/shares', + queryParameters: query.toJson(), + ); + final response = await http.post(endpoint, headers: NextcloudOcs.headers()); + if (response.statusCode != HttpStatus.ok) { throw Exception('Api call failed with ${response.statusCode}: ${response.body}'); } } diff --git a/lib/api/marianumcloud/nextcloud_ocs.dart b/lib/api/marianumcloud/nextcloud_ocs.dart new file mode 100644 index 0000000..64c04f7 --- /dev/null +++ b/lib/api/marianumcloud/nextcloud_ocs.dart @@ -0,0 +1,33 @@ +import '../../model/account_data.dart'; +import '../../model/endpoint_data.dart'; + +/// Shared helpers for Nextcloud OCS v2 endpoints. +/// +/// Three call sites previously duplicated the same header dictionary and the +/// same URI scaffolding (TalkApi, AutocompleteApi, FileSharingApi). Anything +/// that talks to `https:////ocs/v2.php/...` should go through +/// these two helpers so additions like a new header or a different auth +/// scheme only need to change here. +class NextcloudOcs { + NextcloudOcs._(); + + /// The standard OCS request header set: JSON accept, OCS API marker, + /// HTTP Basic auth from the active [AccountData]. + static Map headers() => { + 'Accept': 'application/json', + 'OCS-APIRequest': 'true', + 'Authorization': AccountData().getBasicAuthHeader(), + }; + + /// Builds an OCS URI by appending [pathSuffix] under `/ocs/v2.php/` of + /// the configured Nextcloud endpoint. Query parameters are converted to + /// strings (Uri rejects non-string values). + static Uri uri(String pathSuffix, {Map? queryParameters}) { + final endpoint = EndpointData().nextcloud(); + return Uri.https( + endpoint.domain, + '${endpoint.path}/ocs/v2.php/$pathSuffix', + queryParameters?.map((key, value) => MapEntry(key, value.toString())), + ); + } +} diff --git a/lib/api/marianumcloud/talk/actions/talk_actions.dart b/lib/api/marianumcloud/talk/actions/talk_actions.dart new file mode 100644 index 0000000..88a83f3 --- /dev/null +++ b/lib/api/marianumcloud/talk/actions/talk_actions.dart @@ -0,0 +1,50 @@ +import 'package:http/http.dart' as http; + +import '../../../apiParams.dart'; +import '../../../apiResponse.dart'; +import '../talkApi.dart'; + +/// Small POST/DELETE-only Talk endpoints that have no response payload. +/// Each class extends [TalkApi] with `assemble` returning `null`. They share +/// no state — they're collected here purely to avoid eight near-empty files. + +class SetFavorite extends TalkApi { + final String chatToken; + final bool favoriteState; + + SetFavorite(this.chatToken, this.favoriteState) : super('v4/room/$chatToken/favorite', null); + + @override + ApiResponse? assemble(String raw) => null; + + @override + Future request(Uri uri, ApiParams? body, Map? headers) => + favoriteState ? http.post(uri, headers: headers) : http.delete(uri, headers: headers); +} + +class LeaveRoom extends TalkApi { + final String chatToken; + + LeaveRoom(this.chatToken) : super('v4/room/$chatToken/participants/self', null); + + @override + ApiResponse? assemble(String raw) => null; + + @override + Future request(Uri uri, ApiParams? body, Map? headers) => + http.delete(uri, headers: headers); +} + +class DeleteMessage extends TalkApi { + final String chatToken; + final int messageId; + + DeleteMessage(this.chatToken, this.messageId) : super('v1/chat/$chatToken/$messageId', null); + + @override + ApiResponse? assemble(String raw) => null; + + @override + Future request(Uri uri, ApiParams? body, Map? headers) => + http.delete(uri, headers: headers); +} diff --git a/lib/api/marianumcloud/talk/chat/getChatCache.dart b/lib/api/marianumcloud/talk/chat/getChatCache.dart index f564a48..92efd3b 100644 --- a/lib/api/marianumcloud/talk/chat/getChatCache.dart +++ b/lib/api/marianumcloud/talk/chat/getChatCache.dart @@ -1,36 +1,26 @@ -import 'dart:convert'; - import '../../../requestCache.dart'; import 'getChat.dart'; import 'getChatParams.dart'; import 'getChatResponse.dart'; -class GetChatCache extends RequestCache { - String chatToken; - +class GetChatCache extends SimpleCache { GetChatCache({ required void Function(GetChatResponse) onUpdate, - void Function(Exception)? onError, - required this.chatToken, + super.onError, + required String chatToken, }) : super( - RequestCache.cacheNothing, - onUpdate, - onError: onError ?? RequestCache.ignore, + cacheTime: RequestCache.cacheNothing, + loader: () => GetChat( + chatToken, + GetChatParams( + lookIntoFuture: GetChatParamsSwitch.off, + setReadMarker: GetChatParamsSwitch.on, + limit: 200, + ), + ).run(), + fromJson: GetChatResponse.fromJson, + onUpdate: onUpdate, ) { start('nc-chat-$chatToken'); } - - @override - Future onLoad() => GetChat( - chatToken, - GetChatParams( - lookIntoFuture: GetChatParamsSwitch.off, - setReadMarker: GetChatParamsSwitch.on, - limit: 200, - ) - ).run(); - - @override - GetChatResponse onLocalData(String json) => GetChatResponse.fromJson(jsonDecode(json)); - } diff --git a/lib/api/marianumcloud/talk/deleteMessage/deleteMessage.dart b/lib/api/marianumcloud/talk/deleteMessage/deleteMessage.dart deleted file mode 100644 index 580f899..0000000 --- a/lib/api/marianumcloud/talk/deleteMessage/deleteMessage.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; - -import '../../../apiParams.dart'; -import '../talkApi.dart'; - -class DeleteMessage extends TalkApi { - String chatToken; - int messageId; - DeleteMessage(this.chatToken, this.messageId) : super('v1/chat/$chatToken/$messageId', null); - - @override - assemble(String raw) => null; - - @override - Future? request(Uri uri, ApiParams? body, Map? headers) => http.delete(uri, headers: headers); - -} diff --git a/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart b/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart index a3fddc1..c869b26 100644 --- a/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart +++ b/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart @@ -1,22 +1,17 @@ -import 'dart:convert'; - import '../../../requestCache.dart'; import 'getParticipants.dart'; import 'getParticipantsResponse.dart'; -class GetParticipantsCache extends RequestCache { - String chatToken; - - GetParticipantsCache({required void Function(GetParticipantsResponse) onUpdate, required this.chatToken}) : super(RequestCache.cacheNothing, onUpdate) { +class GetParticipantsCache extends SimpleCache { + GetParticipantsCache({ + required void Function(GetParticipantsResponse) onUpdate, + required String chatToken, + }) : super( + cacheTime: RequestCache.cacheNothing, + loader: () => GetParticipants(chatToken).run(), + fromJson: GetParticipantsResponse.fromJson, + onUpdate: onUpdate, + ) { start('nc-chat-participants-$chatToken'); } - - @override - Future onLoad() => GetParticipants( - chatToken, - ).run(); - - @override - GetParticipantsResponse onLocalData(String json) => GetParticipantsResponse.fromJson(jsonDecode(json)); - } diff --git a/lib/api/marianumcloud/talk/leaveRoom/leaveRoom.dart b/lib/api/marianumcloud/talk/leaveRoom/leaveRoom.dart deleted file mode 100644 index ee1f090..0000000 --- a/lib/api/marianumcloud/talk/leaveRoom/leaveRoom.dart +++ /dev/null @@ -1,16 +0,0 @@ - -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; - -import '../talkApi.dart'; -class LeaveRoom extends TalkApi { - String chatToken; - - LeaveRoom(this.chatToken) : super('v4/room/$chatToken/participants/self', null); - - @override - assemble(String raw) => null; - - @override - Future request(Uri uri, Object? body, Map? headers) => http.delete(uri, headers: headers); -} diff --git a/lib/api/marianumcloud/talk/room/getRoomCache.dart b/lib/api/marianumcloud/talk/room/getRoomCache.dart index d632b9e..03fd785 100644 --- a/lib/api/marianumcloud/talk/room/getRoomCache.dart +++ b/lib/api/marianumcloud/talk/room/getRoomCache.dart @@ -1,32 +1,15 @@ -import 'dart:convert'; - - import '../../../requestCache.dart'; import 'getRoom.dart'; import 'getRoomParams.dart'; import 'getRoomResponse.dart'; -class GetRoomCache extends RequestCache { - GetRoomCache({ - void Function(GetRoomResponse)? onUpdate, - void Function(Exception)? onError, - bool? renew, - }) : super( - RequestCache.cacheMinute, - onUpdate, - onError: onError ?? RequestCache.ignore, - renew: renew, +class GetRoomCache extends SimpleCache { + GetRoomCache({super.onUpdate, super.onError, super.renew}) + : super( + cacheTime: RequestCache.cacheMinute, + loader: () => GetRoom(GetRoomParams(includeStatus: true)).run(), + fromJson: GetRoomResponse.fromJson, ) { start('nc-rooms'); } - - @override - GetRoomResponse onLocalData(String json) => GetRoomResponse.fromJson(jsonDecode(json)); - - @override - Future onLoad() => GetRoom( - GetRoomParams( - includeStatus: true, - ) - ).run(); } diff --git a/lib/api/marianumcloud/talk/sendMessage/sendMessageResponse.dart b/lib/api/marianumcloud/talk/sendMessage/sendMessageResponse.dart deleted file mode 100644 index 5c9500b..0000000 --- a/lib/api/marianumcloud/talk/sendMessage/sendMessageResponse.dart +++ /dev/null @@ -1,5 +0,0 @@ -import '../../../apiResponse.dart'; - -class SendMessageResponse extends ApiResponse { - -} diff --git a/lib/api/marianumcloud/talk/setFavorite/setFavorite.dart b/lib/api/marianumcloud/talk/setFavorite/setFavorite.dart deleted file mode 100644 index 5f06d51..0000000 --- a/lib/api/marianumcloud/talk/setFavorite/setFavorite.dart +++ /dev/null @@ -1,25 +0,0 @@ - -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; - -import '../talkApi.dart'; - -class SetFavorite extends TalkApi { - String chatToken; - bool favoriteState; - - SetFavorite(this.chatToken, this.favoriteState) : super('v4/room/$chatToken/favorite', null); - - @override - assemble(String raw) => null; - - @override - Future request(Uri uri, Object? body, Map? headers) { - if(favoriteState) { - return http.post(uri, headers: headers); - } else { - return http.delete(uri, headers: headers); - } - } - -} diff --git a/lib/api/marianumcloud/talk/talkApi.dart b/lib/api/marianumcloud/talk/talkApi.dart index 5f6c31e..617ee6f 100644 --- a/lib/api/marianumcloud/talk/talkApi.dart +++ b/lib/api/marianumcloud/talk/talkApi.dart @@ -2,12 +2,11 @@ import 'dart:developer'; import 'package:http/http.dart' as http; -import '../../../model/account_data.dart'; -import '../../../model/endpoint_data.dart'; import '../../apiError.dart'; import '../../apiParams.dart'; import '../../apiRequest.dart'; import '../../apiResponse.dart'; +import '../nextcloud_ocs.dart'; enum TalkApiMethod { get, @@ -19,7 +18,7 @@ enum TalkApiMethod { abstract class TalkApi extends ApiRequest { String path; ApiParams? body; - Map? headers = {}; + Map? headers; Map? getParameters; http.Response? response; @@ -30,36 +29,28 @@ abstract class TalkApi extends ApiRequest { T assemble(String raw); Future run() async { - getParameters?.forEach((key, value) { - getParameters?.update(key, (value) => value.toString()); - }); - - var endpoint = Uri.https(EndpointData().nextcloud().domain, '${EndpointData().nextcloud().path}/ocs/v2.php/apps/spreed/api/$path', getParameters); - - headers ??= {}; - headers?.putIfAbsent('Accept', () => 'application/json'); - headers?.putIfAbsent('OCS-APIRequest', () => 'true'); - headers?.putIfAbsent('Authorization', AccountData().getBasicAuthHeader); + final endpoint = NextcloudOcs.uri('apps/spreed/api/$path', queryParameters: getParameters); + final mergedHeaders = {...NextcloudOcs.headers(), ...?headers}; http.Response? data; - try { - data = await request(endpoint, body, headers); - if(data == null) throw Exception('No response Data'); - if(data.statusCode >= 400 || data.statusCode < 200) throw Exception("Response status code '${data.statusCode}' might indicate an error"); - } catch(e) { + data = await request(endpoint, body, mergedHeaders); + if (data == null) throw Exception('No response Data'); + if (data.statusCode >= 400 || data.statusCode < 200) { + throw Exception("Response status code '${data.statusCode}' might indicate an error"); + } + } catch (e) { log(e.toString()); throw ApiError('Request $endpoint could not be dispatched: ${e.toString()}'); } - //dynamic jsonData = jsonDecode(data.body); - T assembled; try { - assembled = assemble(data.body); + final assembled = assemble(data.body); assembled?.headers = data.headers; return assembled; } catch (e) { - var message = 'Error assembling Talk API ${T.toString()} message: ${e.toString()} response with request body: $body and request headers: ${headers.toString()}'; + final message = 'Error assembling Talk API ${T.toString()} message: ${e.toString()}' + ' response with request body: $body and request headers: $mergedHeaders'; log(message); throw Exception(message); } diff --git a/lib/api/marianumcloud/talk/votePoll/votePoll.dart b/lib/api/marianumcloud/talk/votePoll/votePoll.dart deleted file mode 100644 index 7cedfe1..0000000 --- a/lib/api/marianumcloud/talk/votePoll/votePoll.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; - -import '../getPoll/getPollStateResponse.dart'; -import '../talkApi.dart'; -import 'votePollParams.dart'; - -@Deprecated('VotePoll is broken') -class VotePoll extends TalkApi { - String token; - int pollId; - VotePoll({required this.token, required this.pollId, required VotePollParams params}) : super('v1/poll/$token/$pollId', params); - - @override - GetPollStateResponse assemble(String raw) => GetPollStateResponse.fromJson(jsonDecode(raw)['ocs']); - - @override - Future? request(Uri uri, Object? body, Map? headers) { - if(body is VotePollParams) { - return http.post(uri, headers: headers, body: body.toJson().toString()); - } - return null; - } -} diff --git a/lib/api/marianumcloud/talk/votePoll/votePollParams.dart b/lib/api/marianumcloud/talk/votePoll/votePollParams.dart deleted file mode 100644 index 151459d..0000000 --- a/lib/api/marianumcloud/talk/votePoll/votePollParams.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../../../apiParams.dart'; - -part 'votePollParams.g.dart'; - -@JsonSerializable() -@Deprecated('VotePoll is broken') -class VotePollParams extends ApiParams { - List optionIds; - - VotePollParams({required this.optionIds}); - factory VotePollParams.fromJson(Map json) => _$VotePollParamsFromJson(json); - Map toJson() => _$VotePollParamsToJson(this); -} diff --git a/lib/api/marianumcloud/talk/votePoll/votePollParams.g.dart b/lib/api/marianumcloud/talk/votePoll/votePollParams.g.dart deleted file mode 100644 index 5b43858..0000000 --- a/lib/api/marianumcloud/talk/votePoll/votePollParams.g.dart +++ /dev/null @@ -1,17 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'votePollParams.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -VotePollParams _$VotePollParamsFromJson(Map json) => - VotePollParams( - optionIds: (json['optionIds'] as List) - .map((e) => (e as num).toInt()) - .toList(), - ); - -Map _$VotePollParamsToJson(VotePollParams instance) => - {'optionIds': instance.optionIds}; diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart b/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart index ac7d5b3..3a61460 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart +++ b/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart @@ -6,34 +6,20 @@ import 'listFiles.dart'; import 'listFilesParams.dart'; import 'listFilesResponse.dart'; -class ListFilesCache extends RequestCache { - String path; - +class ListFilesCache extends SimpleCache { ListFilesCache({ required void Function(ListFilesResponse) onUpdate, - void Function(ListFilesResponse)? onCacheData, - void Function(ListFilesResponse)? onNetworkData, - void Function(Exception)? onError, - required this.path, + super.onCacheData, + super.onNetworkData, + super.onError, + required String path, }) : super( - RequestCache.cacheNothing, - onUpdate, - onError: onError ?? RequestCache.ignore, - onCacheData: onCacheData, - onNetworkData: onNetworkData, + cacheTime: RequestCache.cacheNothing, + loader: () => ListFiles(ListFilesParams(path)).run(), + fromJson: ListFilesResponse.fromJson, + onUpdate: onUpdate, ) { - var bytes = utf8.encode('MarianumMobile-$path'); - var cacheName = md5.convert(bytes).toString(); + final cacheName = md5.convert(utf8.encode('MarianumMobile-$path')).toString(); start('wd-folder-$cacheName'); } - - @override - Future onLoad() async { - var data = await ListFiles(ListFilesParams(path)).run(); - return data; - } - - @override - ListFilesResponse onLocalData(String json) => ListFilesResponse.fromJson(jsonDecode(json)); - } diff --git a/lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart b/lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart index 9f7e24d..d7bc0f8 100644 --- a/lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart +++ b/lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart @@ -1,17 +1,14 @@ -import 'dart:convert'; import '../../../requestCache.dart'; import 'getBreakers.dart'; import 'getBreakersResponse.dart'; - -class GetBreakersCache extends RequestCache { - GetBreakersCache({void Function(GetBreakersResponse)? onUpdate, bool? renew}) : super(RequestCache.cacheMinute, onUpdate, renew: renew) { +class GetBreakersCache extends SimpleCache { + GetBreakersCache({super.onUpdate, super.renew}) + : super( + cacheTime: RequestCache.cacheMinute, + loader: () => GetBreakers().run(), + fromJson: GetBreakersResponse.fromJson, + ) { start('breakers'); } - - @override - GetBreakersResponse onLocalData(String json) => GetBreakersResponse.fromJson(jsonDecode(json)); - - @override - Future onLoad() => GetBreakers().run(); } diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart b/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart index f0a0119..5adc186 100644 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart +++ b/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart @@ -1,30 +1,19 @@ -import 'dart:convert'; - import '../../../requestCache.dart'; import 'getCustomTimetableEvent.dart'; import 'getCustomTimetableEventParams.dart'; import 'getCustomTimetableEventResponse.dart'; -class GetCustomTimetableEventCache extends RequestCache { - GetCustomTimetableEventParams params; - +class GetCustomTimetableEventCache extends SimpleCache { GetCustomTimetableEventCache( - this.params, { - void Function(GetCustomTimetableEventResponse)? onUpdate, - void Function(Exception)? onError, - bool? renew, + GetCustomTimetableEventParams params, { + super.onUpdate, + super.onError, + super.renew, }) : super( - RequestCache.cacheMinute, - onUpdate, - onError: onError ?? RequestCache.ignore, - renew: renew, + cacheTime: RequestCache.cacheMinute, + loader: () => GetCustomTimetableEvent(params).run(), + fromJson: GetCustomTimetableEventResponse.fromJson, ) { start('customTimetableEvents'); } - - @override - Future onLoad() => GetCustomTimetableEvent(params).run(); - - @override - GetCustomTimetableEventResponse onLocalData(String json) => GetCustomTimetableEventResponse.fromJson(jsonDecode(json)); } diff --git a/lib/api/requestCache.dart b/lib/api/requestCache.dart index acfe990..df88c25 100644 --- a/lib/api/requestCache.dart +++ b/lib/api/requestCache.dart @@ -79,3 +79,38 @@ abstract class RequestCache { Future onLoad(); } + +/// Concrete [RequestCache] that delegates the two overrides to functions +/// passed in the constructor. Used to collapse the dozens of one-class-per- +/// endpoint cache files that all just forward to `().run()` and +/// `.fromJson(jsonDecode(...))`. +class SimpleCache extends RequestCache { + final Future Function() _loader; + final T Function(Map json) _fromJson; + + SimpleCache({ + required int cacheTime, + required Future Function() loader, + required T Function(Map json) fromJson, + void Function(T)? onUpdate, + void Function(T)? onCacheData, + void Function(T)? onNetworkData, + void Function(Exception)? onError, + bool? renew, + }) : _loader = loader, + _fromJson = fromJson, + super( + cacheTime, + onUpdate, + onError: onError ?? RequestCache.ignore, + renew: renew, + onCacheData: onCacheData, + onNetworkData: onNetworkData, + ); + + @override + Future onLoad() => _loader(); + + @override + T onLocalData(String json) => _fromJson(jsonDecode(json)); +} diff --git a/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart b/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart index e986965..d6a2ff4 100644 --- a/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart +++ b/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart @@ -1,26 +1,14 @@ -import 'dart:convert'; - import '../../../requestCache.dart'; import 'getHolidays.dart'; import 'getHolidaysResponse.dart'; -class GetHolidaysCache extends RequestCache { - GetHolidaysCache({ - void Function(GetHolidaysResponse)? onUpdate, - void Function(Exception)? onError, - bool? renew, - }) : super( - RequestCache.cacheDay, - onUpdate, - onError: onError ?? RequestCache.ignore, - renew: renew, +class GetHolidaysCache extends SimpleCache { + GetHolidaysCache({super.onUpdate, super.onError, super.renew}) + : super( + cacheTime: RequestCache.cacheDay, + loader: () => GetHolidays().run(), + fromJson: GetHolidaysResponse.fromJson, ) { start('wu-holidays'); } - - @override - Future onLoad() => GetHolidays().run(); - - @override - GetHolidaysResponse onLocalData(String json) => GetHolidaysResponse.fromJson(jsonDecode(json)); } diff --git a/lib/api/webuntis/queries/getRooms/getRoomsCache.dart b/lib/api/webuntis/queries/getRooms/getRoomsCache.dart index 00c155f..4f8e064 100644 --- a/lib/api/webuntis/queries/getRooms/getRoomsCache.dart +++ b/lib/api/webuntis/queries/getRooms/getRoomsCache.dart @@ -1,27 +1,14 @@ -import 'dart:convert'; - import '../../../requestCache.dart'; import 'getRooms.dart'; import 'getRoomsResponse.dart'; -class GetRoomsCache extends RequestCache { - GetRoomsCache({ - void Function(GetRoomsResponse)? onUpdate, - void Function(Exception)? onError, - bool? renew, - }) : super( - RequestCache.cacheHour, - onUpdate, - onError: onError ?? RequestCache.ignore, - renew: renew, +class GetRoomsCache extends SimpleCache { + GetRoomsCache({super.onUpdate, super.onError, super.renew}) + : super( + cacheTime: RequestCache.cacheHour, + loader: () => GetRooms().run(), + fromJson: GetRoomsResponse.fromJson, ) { start('wu-rooms'); } - - @override - Future onLoad() => GetRooms().run(); - - @override - GetRoomsResponse onLocalData(String json) => GetRoomsResponse.fromJson(jsonDecode(json)); - } diff --git a/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart b/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart index 6e834a4..5eeb8d3 100644 --- a/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart +++ b/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart @@ -1,27 +1,14 @@ -import 'dart:convert'; - import '../../../requestCache.dart'; import 'getSubjects.dart'; import 'getSubjectsResponse.dart'; -class GetSubjectsCache extends RequestCache { - GetSubjectsCache({ - void Function(GetSubjectsResponse)? onUpdate, - void Function(Exception)? onError, - bool? renew, - }) : super( - RequestCache.cacheHour, - onUpdate, - onError: onError ?? RequestCache.ignore, - renew: renew, +class GetSubjectsCache extends SimpleCache { + GetSubjectsCache({super.onUpdate, super.onError, super.renew}) + : super( + cacheTime: RequestCache.cacheHour, + loader: () => GetSubjects().run(), + fromJson: GetSubjectsResponse.fromJson, ) { start('wu-subjects'); } - - @override - Future onLoad() => GetSubjects().run(); - - @override - onLocalData(String json) => GetSubjectsResponse.fromJson(jsonDecode(json)); - } diff --git a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart b/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart index 6de483d..200aa9c 100644 --- a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart +++ b/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart @@ -1,20 +1,14 @@ -import 'dart:convert'; - import '../../../requestCache.dart'; import 'getTimegridUnits.dart'; import 'getTimegridUnitsResponse.dart'; -class GetTimegridUnitsCache extends RequestCache { - GetTimegridUnitsCache({ - void Function(GetTimegridUnitsResponse)? onUpdate, - bool? renew, - }) : super(RequestCache.cacheDay, onUpdate, renew: renew) { +class GetTimegridUnitsCache extends SimpleCache { + GetTimegridUnitsCache({super.onUpdate, super.renew}) + : super( + cacheTime: RequestCache.cacheDay, + loader: () => GetTimegridUnits().run(), + fromJson: GetTimegridUnitsResponse.fromJson, + ) { start('wu-timegrid'); } - - @override - Future onLoad() => GetTimegridUnits().run(); - - @override - GetTimegridUnitsResponse onLocalData(String json) => GetTimegridUnitsResponse.fromJson(jsonDecode(json)); } diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart b/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart index c834030..8a8cd7e 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart +++ b/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart @@ -1,49 +1,43 @@ -import 'dart:convert'; - import '../../../requestCache.dart'; import '../authenticate/authenticate.dart'; import 'getTimetable.dart'; import 'getTimetableParams.dart'; import 'getTimetableResponse.dart'; -class GetTimetableCache extends RequestCache { - int startdate; - int enddate; - +class GetTimetableCache extends SimpleCache { GetTimetableCache({ required void Function(GetTimetableResponse) onUpdate, - void Function(Exception)? onError, - required this.startdate, - required this.enddate, - bool? renew, + super.onError, + required int startdate, + required int enddate, + super.renew, }) : super( - RequestCache.cacheMinute, - onUpdate, - onError: onError ?? RequestCache.ignore, - renew: renew, + cacheTime: RequestCache.cacheMinute, + loader: () => _load(startdate, enddate), + fromJson: GetTimetableResponse.fromJson, + onUpdate: onUpdate, ) { start('wu-timetable-$startdate-$enddate'); } - @override - GetTimetableResponse onLocalData(String json) => GetTimetableResponse.fromJson(jsonDecode(json)); - - @override - Future onLoad() async => GetTimetable( - GetTimetableParams( - options: GetTimetableParamsOptions( - element: GetTimetableParamsOptionsElement( - id: (await Authenticate.getSession()).personId, - type: (await Authenticate.getSession()).personType, - keyType: GetTimetableParamsOptionsElementKeyType.id, - ), - startDate: startdate, - endDate: enddate, - teacherFields: GetTimetableParamsOptionsFields.all, - subjectFields: GetTimetableParamsOptionsFields.all, - roomFields: GetTimetableParamsOptionsFields.all, - klasseFields: GetTimetableParamsOptionsFields.all, - ) - ) + static Future _load(int startdate, int enddate) async { + final session = await Authenticate.getSession(); + return GetTimetable( + GetTimetableParams( + options: GetTimetableParamsOptions( + element: GetTimetableParamsOptionsElement( + id: session.personId, + type: session.personType, + keyType: GetTimetableParamsOptionsElementKeyType.id, + ), + startDate: startdate, + endDate: enddate, + teacherFields: GetTimetableParamsOptionsFields.all, + subjectFields: GetTimetableParamsOptionsFields.all, + roomFields: GetTimetableParamsOptionsFields.all, + klasseFields: GetTimetableParamsOptionsFields.all, + ), + ), ).run(); + } } diff --git a/lib/main.dart b/lib/main.dart index 307af7b..cb92177 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -28,7 +28,7 @@ import 'state/app/modules/chat/bloc/chat_bloc.dart'; import 'state/app/modules/chatList/bloc/chat_list_bloc.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; -import 'storage/base/settings.dart'; +import 'storage/settings.dart'; import 'theming/dark_app_theme.dart'; import 'theming/light_app_theme.dart'; import 'view/login/login.dart'; diff --git a/lib/state/app/modules/settings/bloc/settings_cubit.dart b/lib/state/app/modules/settings/bloc/settings_cubit.dart index 0fde846..a80d47c 100644 --- a/lib/state/app/modules/settings/bloc/settings_cubit.dart +++ b/lib/state/app/modules/settings/bloc/settings_cubit.dart @@ -3,7 +3,7 @@ import 'dart:developer'; import 'package:easy_debounce/easy_debounce.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; -import '../../../../../storage/base/settings.dart'; +import '../../../../../storage/settings.dart'; import '../../../../../view/pages/settings/data/default_settings.dart'; import '../../app_modules.dart'; diff --git a/lib/storage/devTools/devToolsSettings.dart b/lib/storage/dev_tools_settings.dart similarity index 93% rename from lib/storage/devTools/devToolsSettings.dart rename to lib/storage/dev_tools_settings.dart index 4a882ed..03e2fac 100644 --- a/lib/storage/devTools/devToolsSettings.dart +++ b/lib/storage/dev_tools_settings.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'devToolsSettings.g.dart'; +part 'dev_tools_settings.g.dart'; @JsonSerializable() class DevToolsSettings { diff --git a/lib/storage/devTools/devToolsSettings.g.dart b/lib/storage/dev_tools_settings.g.dart similarity index 96% rename from lib/storage/devTools/devToolsSettings.g.dart rename to lib/storage/dev_tools_settings.g.dart index 3f480fe..a1cc6de 100644 --- a/lib/storage/devTools/devToolsSettings.g.dart +++ b/lib/storage/dev_tools_settings.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'devToolsSettings.dart'; +part of 'dev_tools_settings.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/storage/file/fileSettings.dart b/lib/storage/file_settings.dart similarity index 94% rename from lib/storage/file/fileSettings.dart rename to lib/storage/file_settings.dart index 9dec6ca..c493f7a 100644 --- a/lib/storage/file/fileSettings.dart +++ b/lib/storage/file_settings.dart @@ -2,7 +2,7 @@ import 'package:json_annotation/json_annotation.dart'; import '../../view/pages/files/files.dart'; -part 'fileSettings.g.dart'; +part 'file_settings.g.dart'; @JsonSerializable() class FileSettings { diff --git a/lib/storage/file/fileSettings.g.dart b/lib/storage/file_settings.g.dart similarity index 96% rename from lib/storage/file/fileSettings.g.dart rename to lib/storage/file_settings.g.dart index 9855925..38d4aa5 100644 --- a/lib/storage/file/fileSettings.g.dart +++ b/lib/storage/file_settings.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'fileSettings.dart'; +part of 'file_settings.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/storage/fileView/fileViewSettings.dart b/lib/storage/file_view_settings.dart similarity index 91% rename from lib/storage/fileView/fileViewSettings.dart rename to lib/storage/file_view_settings.dart index 1f0f1b3..736377f 100644 --- a/lib/storage/fileView/fileViewSettings.dart +++ b/lib/storage/file_view_settings.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'fileViewSettings.g.dart'; +part 'file_view_settings.g.dart'; @JsonSerializable() class FileViewSettings { diff --git a/lib/storage/fileView/fileViewSettings.g.dart b/lib/storage/file_view_settings.g.dart similarity index 94% rename from lib/storage/fileView/fileViewSettings.g.dart rename to lib/storage/file_view_settings.g.dart index f34915a..44d2e30 100644 --- a/lib/storage/fileView/fileViewSettings.g.dart +++ b/lib/storage/file_view_settings.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'fileViewSettings.dart'; +part of 'file_view_settings.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/storage/holidays/holidaysSettings.dart b/lib/storage/holidays_settings.dart similarity index 92% rename from lib/storage/holidays/holidaysSettings.dart rename to lib/storage/holidays_settings.dart index 6a25292..d4034e5 100644 --- a/lib/storage/holidays/holidaysSettings.dart +++ b/lib/storage/holidays_settings.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'holidaysSettings.g.dart'; +part 'holidays_settings.g.dart'; @JsonSerializable() class HolidaysSettings { diff --git a/lib/storage/holidays/holidaysSettings.g.dart b/lib/storage/holidays_settings.g.dart similarity index 95% rename from lib/storage/holidays/holidaysSettings.g.dart rename to lib/storage/holidays_settings.g.dart index f229202..976acb6 100644 --- a/lib/storage/holidays/holidaysSettings.g.dart +++ b/lib/storage/holidays_settings.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'holidaysSettings.dart'; +part of 'holidays_settings.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/storage/general/modulesSettings.dart b/lib/storage/modules_settings.dart similarity index 93% rename from lib/storage/general/modulesSettings.dart rename to lib/storage/modules_settings.dart index 387982f..14edc66 100644 --- a/lib/storage/general/modulesSettings.dart +++ b/lib/storage/modules_settings.dart @@ -2,7 +2,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import '../../state/app/modules/app_modules.dart'; -part 'modulesSettings.g.dart'; +part 'modules_settings.g.dart'; @JsonSerializable() class ModulesSettings { diff --git a/lib/storage/general/modulesSettings.g.dart b/lib/storage/modules_settings.g.dart similarity index 97% rename from lib/storage/general/modulesSettings.g.dart rename to lib/storage/modules_settings.g.dart index dbc7318..fd51b41 100644 --- a/lib/storage/general/modulesSettings.g.dart +++ b/lib/storage/modules_settings.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'modulesSettings.dart'; +part of 'modules_settings.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/storage/notification/notificationSettings.dart b/lib/storage/notification_settings.dart similarity index 91% rename from lib/storage/notification/notificationSettings.dart rename to lib/storage/notification_settings.dart index ce02847..ae08533 100644 --- a/lib/storage/notification/notificationSettings.dart +++ b/lib/storage/notification_settings.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'notificationSettings.g.dart'; +part 'notification_settings.g.dart'; @JsonSerializable() class NotificationSettings { diff --git a/lib/storage/notification/notificationSettings.g.dart b/lib/storage/notification_settings.g.dart similarity index 94% rename from lib/storage/notification/notificationSettings.g.dart rename to lib/storage/notification_settings.g.dart index 0229204..ac37ecb 100644 --- a/lib/storage/notification/notificationSettings.g.dart +++ b/lib/storage/notification_settings.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'notificationSettings.dart'; +part of 'notification_settings.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/storage/base/settings.dart b/lib/storage/settings.dart similarity index 78% rename from lib/storage/base/settings.dart rename to lib/storage/settings.dart index 2914e1a..af3abbb 100644 --- a/lib/storage/base/settings.dart +++ b/lib/storage/settings.dart @@ -1,14 +1,14 @@ import 'package:flutter/material.dart'; import 'package:json_annotation/json_annotation.dart'; -import '../devTools/devToolsSettings.dart'; -import '../file/fileSettings.dart'; -import '../fileView/fileViewSettings.dart'; -import '../general/modulesSettings.dart'; -import '../holidays/holidaysSettings.dart'; -import '../notification/notificationSettings.dart'; -import '../talk/talkSettings.dart'; -import '../timetable/timetableSettings.dart'; +import 'dev_tools_settings.dart'; +import 'file_settings.dart'; +import 'file_view_settings.dart'; +import 'modules_settings.dart'; +import 'holidays_settings.dart'; +import 'notification_settings.dart'; +import 'talk_settings.dart'; +import 'timetable_settings.dart'; part 'settings.g.dart'; diff --git a/lib/storage/base/settings.g.dart b/lib/storage/settings.g.dart similarity index 100% rename from lib/storage/base/settings.g.dart rename to lib/storage/settings.g.dart diff --git a/lib/storage/talk/talkSettings.dart b/lib/storage/talk_settings.dart similarity index 94% rename from lib/storage/talk/talkSettings.dart rename to lib/storage/talk_settings.dart index 7c3123a..180dc45 100644 --- a/lib/storage/talk/talkSettings.dart +++ b/lib/storage/talk_settings.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'talkSettings.g.dart'; +part 'talk_settings.g.dart'; @JsonSerializable() class TalkSettings { diff --git a/lib/storage/talk/talkSettings.g.dart b/lib/storage/talk_settings.g.dart similarity index 96% rename from lib/storage/talk/talkSettings.g.dart rename to lib/storage/talk_settings.g.dart index 568988e..0b3e55b 100644 --- a/lib/storage/talk/talkSettings.g.dart +++ b/lib/storage/talk_settings.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'talkSettings.dart'; +part of 'talk_settings.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/storage/timetable/timetableSettings.dart b/lib/storage/timetable_settings.dart similarity index 81% rename from lib/storage/timetable/timetableSettings.dart rename to lib/storage/timetable_settings.dart index 72d6a93..c4db103 100644 --- a/lib/storage/timetable/timetableSettings.dart +++ b/lib/storage/timetable_settings.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import 'timetable_name_mode.dart'; +import '../../../view/pages/timetable/data/timetable_name_mode.dart'; -part 'timetableSettings.g.dart'; +part 'timetable_settings.g.dart'; @JsonSerializable() class TimetableSettings { diff --git a/lib/storage/timetable/timetableSettings.g.dart b/lib/storage/timetable_settings.g.dart similarity index 96% rename from lib/storage/timetable/timetableSettings.g.dart rename to lib/storage/timetable_settings.g.dart index 2ea7c43..87c60d7 100644 --- a/lib/storage/timetable/timetableSettings.g.dart +++ b/lib/storage/timetable_settings.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'timetableSettings.dart'; +part of 'timetable_settings.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/view/pages/overhang.dart b/lib/view/pages/overhang.dart index c43412f..f27445f 100644 --- a/lib/view/pages/overhang.dart +++ b/lib/view/pages/overhang.dart @@ -9,7 +9,7 @@ import '../../extensions/render_not_null.dart'; import '../../routing/app_routes.dart'; import '../../state/app/modules/app_modules.dart'; import '../../state/app/modules/settings/bloc/settings_cubit.dart'; -import '../../storage/base/settings.dart' as model; +import '../../storage/settings.dart' as model; import '../../widget/centered_leading.dart'; import '../../widget/info_dialog.dart'; import 'settings/data/default_settings.dart'; diff --git a/lib/view/pages/settings/data/default_settings.dart b/lib/view/pages/settings/data/default_settings.dart index 18088d0..49640a1 100644 --- a/lib/view/pages/settings/data/default_settings.dart +++ b/lib/view/pages/settings/data/default_settings.dart @@ -3,16 +3,16 @@ import 'dart:io'; import 'package:flutter/material.dart'; import '../../../../state/app/modules/app_modules.dart'; -import '../../../../storage/base/settings.dart'; -import '../../../../storage/devTools/devToolsSettings.dart'; -import '../../../../storage/file/fileSettings.dart'; -import '../../../../storage/fileView/fileViewSettings.dart'; -import '../../../../storage/general/modulesSettings.dart'; -import '../../../../storage/holidays/holidaysSettings.dart'; -import '../../../../storage/notification/notificationSettings.dart'; -import '../../../../storage/talk/talkSettings.dart'; -import '../../../../storage/timetable/timetable_name_mode.dart'; -import '../../../../storage/timetable/timetableSettings.dart'; +import '../../../../storage/settings.dart'; +import '../../../../storage/dev_tools_settings.dart'; +import '../../../../storage/file_settings.dart'; +import '../../../../storage/file_view_settings.dart'; +import '../../../../storage/modules_settings.dart'; +import '../../../../storage/holidays_settings.dart'; +import '../../../../storage/notification_settings.dart'; +import '../../../../storage/talk_settings.dart'; +import '../../../../view/pages/timetable/data/timetable_name_mode.dart'; +import '../../../../storage/timetable_settings.dart'; import '../../files/files.dart'; class DefaultSettings { diff --git a/lib/view/pages/settings/sections/timetable_section.dart b/lib/view/pages/settings/sections/timetable_section.dart index a755eb5..a402cd9 100644 --- a/lib/view/pages/settings/sections/timetable_section.dart +++ b/lib/view/pages/settings/sections/timetable_section.dart @@ -3,7 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; -import '../../../../storage/timetable/timetable_name_mode.dart'; +import '../../../../view/pages/timetable/data/timetable_name_mode.dart'; class TimetableSection extends StatelessWidget { const TimetableSection({super.key}); diff --git a/lib/view/pages/settings/settings.dart b/lib/view/pages/settings/settings.dart index b584bd4..196d5e5 100644 --- a/lib/view/pages/settings/settings.dart +++ b/lib/view/pages/settings/settings.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; -import '../../../storage/base/settings.dart' as model; +import '../../../storage/settings.dart' as model; import 'sections/about_section.dart'; import 'sections/account_section.dart'; import 'sections/appearance_section.dart'; 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 02600f4..1de61f6 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -5,7 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; -import '../../../../api/marianumcloud/talk/deleteMessage/deleteMessage.dart'; +import '../../../../api/marianumcloud/talk/actions/talk_actions.dart'; import '../../../../api/marianumcloud/talk/reactMessage/reactMessage.dart'; import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart'; import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index 1696479..bfce9c5 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -3,10 +3,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:jiffy/jiffy.dart'; +import '../../../../api/marianumcloud/talk/actions/talk_actions.dart'; import '../../../../api/marianumcloud/talk/chat/richObjectStringProcessor.dart'; -import '../../../../api/marianumcloud/talk/leaveRoom/leaveRoom.dart'; import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; -import '../../../../api/marianumcloud/talk/setFavorite/setFavorite.dart'; import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarker.dart'; import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dart'; import '../../../../model/account_data.dart'; diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index 868bc60..3d14aa8 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -5,8 +5,8 @@ import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; import '../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; import '../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; -import '../../../../storage/timetable/timetableSettings.dart'; -import '../../../../storage/timetable/timetable_name_mode.dart'; +import '../../../../storage/timetable_settings.dart'; +import 'timetable_name_mode.dart'; import '../custom_events/custom_event_colors.dart'; import 'arbitrary_appointment.dart'; import 'lesson_color.dart'; diff --git a/lib/storage/timetable/timetable_name_mode.dart b/lib/view/pages/timetable/data/timetable_name_mode.dart similarity index 92% rename from lib/storage/timetable/timetable_name_mode.dart rename to lib/view/pages/timetable/data/timetable_name_mode.dart index 36e0f7e..7e534a9 100644 --- a/lib/storage/timetable/timetable_name_mode.dart +++ b/lib/view/pages/timetable/data/timetable_name_mode.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../../widget/dropdown_display.dart'; +import '../../../../widget/dropdown_display.dart'; enum TimetableNameMode { name, longName, alternateName } From 54ba04a7bdcd8a63397e4637259df3abf3191e5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Tue, 5 May 2026 22:08:10 +0200 Subject: [PATCH 07/23] wait for account data population and set initial AccountBloc status --- lib/main.dart | 13 +++++++++++-- .../app/modules/account/bloc/account_bloc.dart | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index cb92177..adcc9c1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -43,7 +43,7 @@ Future main() async { final initialisationTasks = [ Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform) - .then((_) async => log('Firebase token: ${await FirebaseMessaging.instance.getToken() ?? "Error: no Firebase token!"}')) + .then((_) {}) .onError((error, _) => log('Error initializing Firebase: $error')), PlatformAssetBundle().load('assets/ca/lets-encrypt-r3.pem').then(addCertificateAsTrusted), PlatformAssetBundle().load('assets/ca/lets-encrypt-r10.pem').then(addCertificateAsTrusted), @@ -54,12 +54,19 @@ Future main() async { ); HydratedBloc.storage = storage; }), + AccountData().waitForPopulation(), ]; log('starting app initialisation...'); await Future.wait(initialisationTasks); log('app initialisation done!'); + unawaited( + FirebaseMessaging.instance.getToken().then( + (token) => log('Firebase token: ${token ?? "Error: no Firebase token!"}'), + ), + ); + if (kReleaseMode) { ErrorWidget.builder = (error) => PlaceholderView( icon: Icons.phonelink_erase_rounded, @@ -83,7 +90,9 @@ Future main() async { MultiBlocProvider( providers: [ BlocProvider(create: (_) => SettingsCubit()), - BlocProvider(create: (_) => AccountBloc()), + BlocProvider(create: (_) => AccountBloc( + initialStatus: AccountData().isPopulated() ? AccountStatus.loggedIn : AccountStatus.loggedOut, + )), BlocProvider(create: (_) => BreakerBloc()), BlocProvider(create: (_) => ChatListBloc()), BlocProvider(create: (_) => ChatBloc()), diff --git a/lib/state/app/modules/account/bloc/account_bloc.dart b/lib/state/app/modules/account/bloc/account_bloc.dart index 59514dd..4ad0f5d 100644 --- a/lib/state/app/modules/account/bloc/account_bloc.dart +++ b/lib/state/app/modules/account/bloc/account_bloc.dart @@ -4,7 +4,7 @@ import 'account_event.dart'; import 'account_state.dart'; class AccountBloc extends Bloc { - AccountBloc() : super(const AccountState()) { + AccountBloc({AccountStatus initialStatus = AccountStatus.undefined}) : super(AccountState(status: initialStatus)) { on((event, emit) => emit(state.copyWith(status: event.status))); } From 2c376afd919396af61ebc3ef1a9eb97d44631a31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Tue, 5 May 2026 22:38:07 +0200 Subject: [PATCH 08/23] removed stray character in settings.gradle --- android/settings.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/settings.gradle b/android/settings.gradle index 9ba153b..82e2b5a 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -7,7 +7,7 @@ pluginManagement { return flutterSdkPath } settings.ext.flutterSdkPath = flutterSdkPath() -0 + includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") repositories { From 4b1d4379a027257a667620f51641043435628ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 6 May 2026 10:11:45 +0200 Subject: [PATCH 09/23] loading state and error handling refactor --- lib/api/errors/app_exception.dart | 16 + lib/api/errors/auth_exception.dart | 23 + lib/api/errors/error_mapper.dart | 64 +++ lib/api/errors/network_exception.dart | 13 + lib/api/errors/not_found_exception.dart | 8 + lib/api/errors/parse_exception.dart | 8 + lib/api/errors/server_exception.dart | 14 + lib/api/errors/talk_exception.dart | 33 ++ lib/api/errors/webuntis_exception.dart | 31 + lib/api/marianumcloud/talk/talkApi.dart | 45 +- lib/api/mhsl/mhslApi.dart | 41 +- lib/api/webuntis/webuntisApi.dart | 32 +- lib/main.dart | 33 +- .../bloc/loadable_state_bloc.dart | 6 + .../loadableState/loading_error.dart | 1 + .../loadableState/loading_error.freezed.dart | 43 +- .../view/loadable_state_consumer.dart | 7 +- .../view/loadable_state_error_bar.dart | 17 +- .../view/loadable_state_error_screen.dart | 72 ++- .../view/loadable_state_primary_loading.dart | 3 +- .../loadable_hydrated_bloc.dart | 6 +- .../app/modules/chat/bloc/chat_bloc.dart | 6 +- .../modules/chatList/bloc/chat_list_bloc.dart | 6 +- .../app/modules/files/bloc/files_bloc.dart | 6 +- .../modules/settings/bloc/settings_cubit.dart | 16 +- lib/theming/app_theme.dart | 11 + lib/view/pages/files/files.dart | 26 +- lib/view/pages/files/files_upload_dialog.dart | 2 +- .../pages/files/widgets/file_element.dart | 11 +- .../settings/sections/about_section.dart | 2 +- .../settings/sections/account_section.dart | 4 +- .../settings/sections/appearance_section.dart | 2 +- .../settings/sections/dev_tools_section.dart | 66 ++- .../settings/sections/files_section.dart | 2 +- .../pages/settings/sections/talk_section.dart | 2 +- .../settings/sections/timetable_section.dart | 16 +- lib/view/pages/settings/settings.dart | 37 +- lib/view/pages/talk/chat_list.dart | 12 +- lib/view/pages/talk/join_chat.dart | 23 +- lib/view/pages/talk/widgets/chat_bubble.dart | 33 +- .../widgets/chat_message_options_dialog.dart | 113 ++-- .../pages/talk/widgets/chat_textfield.dart | 78 ++- lib/view/pages/talk/widgets/chat_tile.dart | 89 ++- .../details/delete_custom_event.dart | 4 +- lib/view/pages/timetable/timetable.dart | 11 +- lib/widget/app_progress_indicator.dart | 35 ++ lib/widget/async_action_button.dart | 541 ++++++++++++++++++ lib/widget/confirm_dialog.dart | 61 +- 48 files changed, 1377 insertions(+), 354 deletions(-) create mode 100644 lib/api/errors/app_exception.dart create mode 100644 lib/api/errors/auth_exception.dart create mode 100644 lib/api/errors/error_mapper.dart create mode 100644 lib/api/errors/network_exception.dart create mode 100644 lib/api/errors/not_found_exception.dart create mode 100644 lib/api/errors/parse_exception.dart create mode 100644 lib/api/errors/server_exception.dart create mode 100644 lib/api/errors/talk_exception.dart create mode 100644 lib/api/errors/webuntis_exception.dart create mode 100644 lib/widget/app_progress_indicator.dart create mode 100644 lib/widget/async_action_button.dart diff --git a/lib/api/errors/app_exception.dart b/lib/api/errors/app_exception.dart new file mode 100644 index 0000000..e90b716 --- /dev/null +++ b/lib/api/errors/app_exception.dart @@ -0,0 +1,16 @@ +abstract class AppException implements Exception { + final String userMessage; + final String? technicalDetails; + final bool allowRetry; + + const AppException({ + required this.userMessage, + this.technicalDetails, + this.allowRetry = true, + }); + + @override + String toString() => technicalDetails == null + ? '$runtimeType: $userMessage' + : '$runtimeType: $userMessage ($technicalDetails)'; +} diff --git a/lib/api/errors/auth_exception.dart b/lib/api/errors/auth_exception.dart new file mode 100644 index 0000000..60a70f7 --- /dev/null +++ b/lib/api/errors/auth_exception.dart @@ -0,0 +1,23 @@ +import 'app_exception.dart'; + +class AuthException extends AppException { + final int statusCode; + + const AuthException({ + required this.statusCode, + required super.userMessage, + super.technicalDetails, + }) : super(allowRetry: false); + + factory AuthException.unauthorized({String? technicalDetails}) => AuthException( + statusCode: 401, + userMessage: 'Bitte melde dich erneut an, um fortzufahren.', + technicalDetails: technicalDetails, + ); + + factory AuthException.forbidden({String? technicalDetails}) => AuthException( + statusCode: 403, + userMessage: 'Du hast keine Berechtigung für diese Aktion.', + technicalDetails: technicalDetails, + ); +} diff --git a/lib/api/errors/error_mapper.dart b/lib/api/errors/error_mapper.dart new file mode 100644 index 0000000..2619643 --- /dev/null +++ b/lib/api/errors/error_mapper.dart @@ -0,0 +1,64 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import '../apiError.dart'; +import '../marianumcloud/talk/talkError.dart'; +import '../webuntis/webuntisError.dart'; +import 'app_exception.dart'; +import 'network_exception.dart'; +import 'parse_exception.dart'; +import 'talk_exception.dart'; +import 'webuntis_exception.dart'; + +const String _defaultFallback = 'Etwas ist schiefgelaufen. Bitte versuche es erneut.'; + +String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) { + if (error == null) return fallback; + if (error is AppException) return error.userMessage; + + if (error is TalkError) return TalkException(error).userMessage; + if (error is WebuntisError) return WebuntisException(error).userMessage; + + if (error is SocketException) { + return const NetworkException().userMessage; + } + if (error is TimeoutException) { + return NetworkException.timeout().userMessage; + } + if (error is http.ClientException) { + return const NetworkException().userMessage; + } + if (error is HandshakeException) { + return 'Sichere Verbindung konnte nicht hergestellt werden.'; + } + if (error is FormatException) { + return const ParseException().userMessage; + } + if (error is ApiError) { + return _stripDioPrefix(error.message); + } + + return fallback; +} + +String? errorToTechnicalDetails(Object? error) { + if (error == null) return null; + if (error is AppException) return error.technicalDetails ?? error.toString(); + if (error is TalkError) return TalkException(error).technicalDetails; + if (error is WebuntisError) return WebuntisException(error).technicalDetails; + return error.toString(); +} + +bool errorAllowsRetry(Object? error) { + if (error == null) return true; + if (error is AppException) return error.allowRetry; + return true; +} + +String _stripDioPrefix(String raw) { + // ApiError messages embed full request URIs; only surface the first line. + final firstLine = raw.split('\n').first.trim(); + return firstLine.isEmpty ? _defaultFallback : firstLine; +} diff --git a/lib/api/errors/network_exception.dart b/lib/api/errors/network_exception.dart new file mode 100644 index 0000000..10fbb56 --- /dev/null +++ b/lib/api/errors/network_exception.dart @@ -0,0 +1,13 @@ +import 'app_exception.dart'; + +class NetworkException extends AppException { + const NetworkException({ + super.userMessage = 'Keine Internetverbindung. Bitte prüfe dein Netzwerk und versuche es erneut.', + super.technicalDetails, + }) : super(allowRetry: true); + + factory NetworkException.timeout({String? technicalDetails}) => NetworkException( + userMessage: 'Der Server hat zu lange gebraucht. Bitte versuche es erneut.', + technicalDetails: technicalDetails, + ); +} diff --git a/lib/api/errors/not_found_exception.dart b/lib/api/errors/not_found_exception.dart new file mode 100644 index 0000000..f3d525a --- /dev/null +++ b/lib/api/errors/not_found_exception.dart @@ -0,0 +1,8 @@ +import 'app_exception.dart'; + +class NotFoundException extends AppException { + const NotFoundException({ + super.userMessage = 'Der angeforderte Eintrag wurde nicht gefunden.', + super.technicalDetails, + }) : super(allowRetry: false); +} diff --git a/lib/api/errors/parse_exception.dart b/lib/api/errors/parse_exception.dart new file mode 100644 index 0000000..57a3bf2 --- /dev/null +++ b/lib/api/errors/parse_exception.dart @@ -0,0 +1,8 @@ +import 'app_exception.dart'; + +class ParseException extends AppException { + const ParseException({ + super.userMessage = 'Die Antwort des Servers konnte nicht gelesen werden.', + super.technicalDetails, + }) : super(allowRetry: true); +} diff --git a/lib/api/errors/server_exception.dart b/lib/api/errors/server_exception.dart new file mode 100644 index 0000000..efabca2 --- /dev/null +++ b/lib/api/errors/server_exception.dart @@ -0,0 +1,14 @@ +import 'app_exception.dart'; + +class ServerException extends AppException { + final int statusCode; + + ServerException({ + required this.statusCode, + String? userMessage, + super.technicalDetails, + }) : super( + userMessage: userMessage ?? 'Der Server hat gerade Probleme (Status $statusCode). Bitte später erneut versuchen.', + allowRetry: true, + ); +} diff --git a/lib/api/errors/talk_exception.dart b/lib/api/errors/talk_exception.dart new file mode 100644 index 0000000..f46c0c7 --- /dev/null +++ b/lib/api/errors/talk_exception.dart @@ -0,0 +1,33 @@ +import '../marianumcloud/talk/talkError.dart'; +import 'app_exception.dart'; + +class TalkException extends AppException { + final TalkError source; + + TalkException(this.source) + : super( + userMessage: _mapMessage(source), + technicalDetails: 'Talk ${source.status} (${source.code}): ${source.message}', + allowRetry: source.code >= 500, + ); + + static String _mapMessage(TalkError e) { + switch (e.code) { + case 401: + return 'Bitte melde dich erneut an, um auf Talk zuzugreifen.'; + case 403: + return 'Du hast keine Berechtigung für diese Talk-Aktion.'; + case 404: + return 'Dieser Chat existiert nicht oder wurde entfernt.'; + case 412: + return 'Diese Aktion ist im aktuellen Chat-Zustand nicht erlaubt.'; + case 429: + return 'Zu viele Anfragen. Bitte kurz warten und erneut versuchen.'; + default: + if (e.code >= 500) { + return 'Talk-Server hat gerade Probleme (${e.code}).'; + } + return e.message.isNotEmpty ? e.message : 'Talk meldet einen Fehler (${e.code}).'; + } + } +} diff --git a/lib/api/errors/webuntis_exception.dart b/lib/api/errors/webuntis_exception.dart new file mode 100644 index 0000000..fd35d35 --- /dev/null +++ b/lib/api/errors/webuntis_exception.dart @@ -0,0 +1,31 @@ +import '../webuntis/webuntisError.dart'; +import 'app_exception.dart'; + +class WebuntisException extends AppException { + final WebuntisError source; + + WebuntisException(this.source) + : super( + userMessage: _mapMessage(source), + technicalDetails: 'WebUntis (${source.code}): ${source.message}', + allowRetry: true, + ); + + static String _mapMessage(WebuntisError e) { + switch (e.code) { + case -8504: + case -8502: + return 'WebUntis-Anmeldung abgelaufen. Bitte erneut anmelden.'; + case -8520: + return 'Bitte melde dich erneut an.'; + case -7004: + return 'Für diesen Zeitraum sind keine Stundenplandaten verfügbar.'; + case -32601: + return 'WebUntis kennt diese Anfrage nicht. Bitte App aktualisieren.'; + default: + return e.message.isNotEmpty + ? 'WebUntis: ${e.message}' + : 'WebUntis konnte die Anfrage nicht bearbeiten (Code ${e.code}).'; + } + } +} diff --git a/lib/api/marianumcloud/talk/talkApi.dart b/lib/api/marianumcloud/talk/talkApi.dart index 617ee6f..371d9f2 100644 --- a/lib/api/marianumcloud/talk/talkApi.dart +++ b/lib/api/marianumcloud/talk/talkApi.dart @@ -1,11 +1,17 @@ +import 'dart:async'; import 'dart:developer'; +import 'dart:io'; import 'package:http/http.dart' as http; -import '../../apiError.dart'; import '../../apiParams.dart'; import '../../apiRequest.dart'; import '../../apiResponse.dart'; +import '../../errors/auth_exception.dart'; +import '../../errors/network_exception.dart'; +import '../../errors/not_found_exception.dart'; +import '../../errors/parse_exception.dart'; +import '../../errors/server_exception.dart'; import '../nextcloud_ocs.dart'; enum TalkApiMethod { @@ -32,16 +38,32 @@ abstract class TalkApi extends ApiRequest { final endpoint = NextcloudOcs.uri('apps/spreed/api/$path', queryParameters: getParameters); final mergedHeaders = {...NextcloudOcs.headers(), ...?headers}; - http.Response? data; + final http.Response data; try { - data = await request(endpoint, body, mergedHeaders); - if (data == null) throw Exception('No response Data'); - if (data.statusCode >= 400 || data.statusCode < 200) { - throw Exception("Response status code '${data.statusCode}' might indicate an error"); + final raw = await request(endpoint, body, mergedHeaders); + if (raw == null) { + throw const NetworkException( + userMessage: 'Keine Antwort vom Talk-Server erhalten.', + technicalDetails: 'Talk request returned null', + ); } - } catch (e) { - log(e.toString()); - throw ApiError('Request $endpoint could not be dispatched: ${e.toString()}'); + data = raw; + } on SocketException catch (e) { + throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}'); + } on TimeoutException catch (e) { + throw NetworkException.timeout(technicalDetails: 'Talk $endpoint: $e'); + } on http.ClientException catch (e) { + throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}'); + } + + final status = data.statusCode; + if (status < 200 || status >= 300) { + final detail = 'Talk $endpoint -> HTTP $status'; + log(detail); + if (status == 401) throw AuthException.unauthorized(technicalDetails: detail); + if (status == 403) throw AuthException.forbidden(technicalDetails: detail); + if (status == 404) throw NotFoundException(technicalDetails: detail); + throw ServerException(statusCode: status, technicalDetails: detail); } try { @@ -49,10 +71,7 @@ abstract class TalkApi extends ApiRequest { assembled?.headers = data.headers; return assembled; } catch (e) { - final message = 'Error assembling Talk API ${T.toString()} message: ${e.toString()}' - ' response with request body: $body and request headers: $mergedHeaders'; - log(message); - throw Exception(message); + throw ParseException(technicalDetails: 'Talk $endpoint assemble: $e'); } } } diff --git a/lib/api/mhsl/mhslApi.dart b/lib/api/mhsl/mhslApi.dart index eb4910a..55f31c1 100644 --- a/lib/api/mhsl/mhslApi.dart +++ b/lib/api/mhsl/mhslApi.dart @@ -1,9 +1,14 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:http/http.dart' as http; import 'package:jiffy/jiffy.dart'; -import '../apiError.dart'; + import '../apiRequest.dart'; +import '../errors/network_exception.dart'; +import '../errors/parse_exception.dart'; +import '../errors/server_exception.dart'; abstract class MhslApi extends ApiRequest { String subpath; @@ -15,18 +20,38 @@ abstract class MhslApi extends ApiRequest { T assemble(String raw); Future run() async { - var endpoint = Uri.parse('https://mhsl.eu/marianum/marianummobile/$subpath'); + final endpoint = Uri.parse('https://mhsl.eu/marianum/marianummobile/$subpath'); - var data = await request(endpoint); - if(data == null) { - throw ApiError('Request could not be dispatched!'); + final http.Response data; + try { + final raw = await request(endpoint); + if (raw == null) { + throw const NetworkException( + userMessage: 'Keine Antwort vom MHSL-Dienst erhalten.', + technicalDetails: 'mhsl request returned null', + ); + } + data = raw; + } on SocketException catch (e) { + throw NetworkException(technicalDetails: 'mhsl $subpath: ${e.message}'); + } on TimeoutException catch (e) { + throw NetworkException.timeout(technicalDetails: 'mhsl $subpath: $e'); + } on http.ClientException catch (e) { + throw NetworkException(technicalDetails: 'mhsl $subpath: ${e.message}'); } - if(data.statusCode > 299) { - throw ApiError('Non 200 Status code from mhsl services: $subpath: ${data.statusCode}'); + if (data.statusCode > 299) { + throw ServerException( + statusCode: data.statusCode, + technicalDetails: 'mhsl $subpath HTTP ${data.statusCode}', + ); } - return assemble(utf8.decode(data.bodyBytes)); + try { + return assemble(utf8.decode(data.bodyBytes)); + } catch (e) { + throw ParseException(technicalDetails: 'mhsl $subpath assemble: $e'); + } } static String dateTimeToJson(DateTime time) => Jiffy.parseFromDateTime(time).format(pattern: 'yyyy-MM-dd HH:mm:ss'); diff --git a/lib/api/webuntis/webuntisApi.dart b/lib/api/webuntis/webuntisApi.dart index 9008949..846c1e6 100644 --- a/lib/api/webuntis/webuntisApi.dart +++ b/lib/api/webuntis/webuntisApi.dart @@ -1,10 +1,15 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:io'; + import 'package:http/http.dart' as http; import '../../model/endpoint_data.dart'; import '../apiParams.dart'; import '../apiRequest.dart'; import '../apiResponse.dart'; +import '../errors/network_exception.dart'; +import '../errors/parse_exception.dart'; import 'queries/authenticate/authenticate.dart'; import 'webuntisError.dart'; @@ -29,10 +34,15 @@ abstract class WebuntisApi extends ApiRequest { var data = await post(query, {'Cookie': 'JSESSIONID=$sessionId'}); response = data; - dynamic jsonData = jsonDecode(data.body); + final dynamic jsonData; + try { + jsonData = jsonDecode(data.body); + } on FormatException catch (e) { + throw ParseException(technicalDetails: 'WebUntis JSON decode: ${e.message}'); + } if(jsonData['error'] != null) { if(jsonData['error']['code'] == -8520) { - if(retry) throw WebuntisError('Authentication was tried (probably session timeout), but was not successful!', 1); + if(retry) throw WebuntisError('Authentication was tried (probably session timeout), but was not successful!', -8520); await Authenticate.createSession(); return await this.query(untis, retry: true); } else { @@ -51,10 +61,16 @@ abstract class WebuntisApi extends ApiRequest { String _body() => genericParam == null ? '{}' : jsonEncode(genericParam); - Future post(String data, Map? headers) async => await http - .post(endpoint, body: data, headers: headers) - .timeout( - const Duration(seconds: 10), - onTimeout: () => throw WebuntisError('Timeout', 1) - ); + Future post(String data, Map? headers) async { + try { + return await http.post(endpoint, body: data, headers: headers).timeout( + const Duration(seconds: 10), + onTimeout: () => throw NetworkException.timeout(technicalDetails: 'WebUntis $method timed out after 10s'), + ); + } on SocketException catch (e) { + throw NetworkException(technicalDetails: 'WebUntis $method: ${e.message}'); + } on http.ClientException catch (e) { + throw NetworkException(technicalDetails: 'WebUntis $method: ${e.message}'); + } + } } diff --git a/lib/main.dart b/lib/main.dart index adcc9c1..411a05c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -32,7 +32,7 @@ import 'storage/settings.dart'; import 'theming/dark_app_theme.dart'; import 'theming/light_app_theme.dart'; import 'view/login/login.dart'; -import 'widget/placeholder_view.dart'; +import 'widget/app_progress_indicator.dart'; Future main() async { log('MarianumMobile started'); @@ -68,9 +68,21 @@ Future main() async { ); if (kReleaseMode) { - ErrorWidget.builder = (error) => PlaceholderView( - icon: Icons.phonelink_erase_rounded, - text: error.toStringShort(), + ErrorWidget.builder = (error) => Material( + color: Colors.white, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.phonelink_erase_rounded, size: 40), + const SizedBox(height: 12), + Text(error.toStringShort(), textAlign: TextAlign.center), + ], + ), + ), + ), ); } @@ -156,7 +168,18 @@ class _MainState extends State
{ case AccountStatus.loggedOut: return const Login(); case AccountStatus.undefined: - return const PlaceholderView(icon: Icons.timer, text: 'Daten werden geladen'); + return const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AppProgressIndicator.large(), + SizedBox(height: 16), + Text('Konto wird geladen…'), + ], + ), + ), + ); } }, ), diff --git a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_bloc.dart b/lib/state/app/infrastructure/loadableState/bloc/loadable_state_bloc.dart index b246417..ef0e167 100644 --- a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_bloc.dart +++ b/lib/state/app/infrastructure/loadableState/bloc/loadable_state_bloc.dart @@ -41,6 +41,12 @@ class LoadableStateBloc extends Bloc { ? Colors.grey.shade600 : Theme.of(context).primaryColor; + Color connectionForegroundColor(BuildContext context) => connectivityStatusKnown() && !isConnected() + ? Colors.white + : ThemeData.estimateBrightnessForColor(Theme.of(context).primaryColor) == Brightness.dark + ? Colors.white + : Colors.black; + String connectionText({int? lastUpdated}) => connectivityStatusKnown() ? isConnected() ? 'Verbindung fehlgeschlagen' diff --git a/lib/state/app/infrastructure/loadableState/loading_error.dart b/lib/state/app/infrastructure/loadableState/loading_error.dart index 9f82716..77bbf22 100644 --- a/lib/state/app/infrastructure/loadableState/loading_error.dart +++ b/lib/state/app/infrastructure/loadableState/loading_error.dart @@ -6,6 +6,7 @@ part 'loading_error.freezed.dart'; abstract class LoadingError with _$LoadingError { const factory LoadingError({ required String message, + String? technicalDetails, @Default(false) bool allowRetry, }) = _LoadingError; } diff --git a/lib/state/app/infrastructure/loadableState/loading_error.freezed.dart b/lib/state/app/infrastructure/loadableState/loading_error.freezed.dart index c4c3924..958c021 100644 --- a/lib/state/app/infrastructure/loadableState/loading_error.freezed.dart +++ b/lib/state/app/infrastructure/loadableState/loading_error.freezed.dart @@ -14,7 +14,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$LoadingError { - String get message; bool get allowRetry; + String get message; String? get technicalDetails; bool get allowRetry; /// Create a copy of LoadingError /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -25,16 +25,16 @@ $LoadingErrorCopyWith get copyWith => _$LoadingErrorCopyWithImpl Object.hash(runtimeType,message,allowRetry); +int get hashCode => Object.hash(runtimeType,message,technicalDetails,allowRetry); @override String toString() { - return 'LoadingError(message: $message, allowRetry: $allowRetry)'; + return 'LoadingError(message: $message, technicalDetails: $technicalDetails, allowRetry: $allowRetry)'; } @@ -45,7 +45,7 @@ abstract mixin class $LoadingErrorCopyWith<$Res> { factory $LoadingErrorCopyWith(LoadingError value, $Res Function(LoadingError) _then) = _$LoadingErrorCopyWithImpl; @useResult $Res call({ - String message, bool allowRetry + String message, String? technicalDetails, bool allowRetry }); @@ -62,10 +62,11 @@ class _$LoadingErrorCopyWithImpl<$Res> /// Create a copy of LoadingError /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? message = null,Object? allowRetry = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? message = null,Object? technicalDetails = freezed,Object? allowRetry = null,}) { return _then(_self.copyWith( message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable -as String,allowRetry: null == allowRetry ? _self.allowRetry : allowRetry // ignore: cast_nullable_to_non_nullable +as String,technicalDetails: freezed == technicalDetails ? _self.technicalDetails : technicalDetails // ignore: cast_nullable_to_non_nullable +as String?,allowRetry: null == allowRetry ? _self.allowRetry : allowRetry // ignore: cast_nullable_to_non_nullable as bool, )); } @@ -151,10 +152,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String message, bool allowRetry)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String message, String? technicalDetails, bool allowRetry)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _LoadingError() when $default != null: -return $default(_that.message,_that.allowRetry);case _: +return $default(_that.message,_that.technicalDetails,_that.allowRetry);case _: return orElse(); } @@ -172,10 +173,10 @@ return $default(_that.message,_that.allowRetry);case _: /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String message, bool allowRetry) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String message, String? technicalDetails, bool allowRetry) $default,) {final _that = this; switch (_that) { case _LoadingError(): -return $default(_that.message,_that.allowRetry);case _: +return $default(_that.message,_that.technicalDetails,_that.allowRetry);case _: throw StateError('Unexpected subclass'); } @@ -192,10 +193,10 @@ return $default(_that.message,_that.allowRetry);case _: /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String message, bool allowRetry)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String message, String? technicalDetails, bool allowRetry)? $default,) {final _that = this; switch (_that) { case _LoadingError() when $default != null: -return $default(_that.message,_that.allowRetry);case _: +return $default(_that.message,_that.technicalDetails,_that.allowRetry);case _: return null; } @@ -207,10 +208,11 @@ return $default(_that.message,_that.allowRetry);case _: class _LoadingError implements LoadingError { - const _LoadingError({required this.message, this.allowRetry = false}); + const _LoadingError({required this.message, this.technicalDetails, this.allowRetry = false}); @override final String message; +@override final String? technicalDetails; @override@JsonKey() final bool allowRetry; /// Create a copy of LoadingError @@ -223,16 +225,16 @@ _$LoadingErrorCopyWith<_LoadingError> get copyWith => __$LoadingErrorCopyWithImp @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoadingError&&(identical(other.message, message) || other.message == message)&&(identical(other.allowRetry, allowRetry) || other.allowRetry == allowRetry)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoadingError&&(identical(other.message, message) || other.message == message)&&(identical(other.technicalDetails, technicalDetails) || other.technicalDetails == technicalDetails)&&(identical(other.allowRetry, allowRetry) || other.allowRetry == allowRetry)); } @override -int get hashCode => Object.hash(runtimeType,message,allowRetry); +int get hashCode => Object.hash(runtimeType,message,technicalDetails,allowRetry); @override String toString() { - return 'LoadingError(message: $message, allowRetry: $allowRetry)'; + return 'LoadingError(message: $message, technicalDetails: $technicalDetails, allowRetry: $allowRetry)'; } @@ -243,7 +245,7 @@ abstract mixin class _$LoadingErrorCopyWith<$Res> implements $LoadingErrorCopyWi factory _$LoadingErrorCopyWith(_LoadingError value, $Res Function(_LoadingError) _then) = __$LoadingErrorCopyWithImpl; @override @useResult $Res call({ - String message, bool allowRetry + String message, String? technicalDetails, bool allowRetry }); @@ -260,10 +262,11 @@ class __$LoadingErrorCopyWithImpl<$Res> /// Create a copy of LoadingError /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? message = null,Object? allowRetry = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? message = null,Object? technicalDetails = freezed,Object? allowRetry = null,}) { return _then(_LoadingError( message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable -as String,allowRetry: null == allowRetry ? _self.allowRetry : allowRetry // ignore: cast_nullable_to_non_nullable +as String,technicalDetails: freezed == technicalDetails ? _self.technicalDetails : technicalDetails // ignore: cast_nullable_to_non_nullable +as String?,allowRetry: null == allowRetry ? _self.allowRetry : allowRetry // ignore: cast_nullable_to_non_nullable as bool, )); } diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart b/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart index ce45fcd..cce2287 100644 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart +++ b/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart @@ -88,6 +88,7 @@ class LoadableStateConsumer { @override Widget build(BuildContext context) { var bloc = context.watch(); + final foreground = bloc.connectionForegroundColor(context); return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(bloc.connectionIcon(), size: 14), + Icon(bloc.connectionIcon(), size: 14, color: foreground), const SizedBox(width: 10), - Text(bloc.connectionText(lastUpdated: widget.lastUpdated), style: const TextStyle(fontSize: 12)) + Text( + bloc.connectionText(lastUpdated: widget.lastUpdated), + style: TextStyle(fontSize: 12, color: foreground), + ), ], ); } diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_error_screen.dart b/lib/state/app/infrastructure/loadableState/view/loadable_state_error_screen.dart index cb68e76..4d118bc 100644 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_error_screen.dart +++ b/lib/state/app/infrastructure/loadableState/view/loadable_state_error_screen.dart @@ -1,49 +1,69 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../../../../widget/info_dialog.dart'; import '../bloc/loadable_state_bloc.dart'; import 'loadable_state_consumer.dart'; class LoadableStateErrorScreen extends StatelessWidget { final bool visible; final String? message; - const LoadableStateErrorScreen({required this.visible, this.message, super.key}); - + final String? technicalDetails; + const LoadableStateErrorScreen({ + required this.visible, + this.message, + this.technicalDetails, + super.key, + }); @override Widget build(BuildContext context) { final bloc = context.watch(); + final isOffline = bloc.connectivityStatusKnown() && !bloc.isConnected(); + final headline = isOffline ? bloc.connectionText() : (message ?? bloc.connectionText()); + return AnimatedOpacity( opacity: visible ? 1.0 : 0.0, duration: LoadableStateConsumer.animationDuration, curve: Curves.easeInOut, child: !visible ? null : Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(bloc.connectionIcon(), size: 40), - const SizedBox(height: 10), - Text(bloc.connectionText(), style: const TextStyle(fontSize: 20)), - - if(bloc.allowRetry()) ...[ - const SizedBox(height: 10), - TextButton(onPressed: () => bloc.reFetch!(), child: const Text('Erneut versuschen')), - const SizedBox(height: 40), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text( - message ?? 'Task failed successfully :)', - style: TextStyle( - color: Theme.of(context).hintColor, - fontSize: 12 - ), - maxLines: 10, - overflow: TextOverflow.ellipsis, - ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(bloc.connectionIcon(), size: 40), + const SizedBox(height: 12), + Text( + headline, + style: const TextStyle(fontSize: 20), + textAlign: TextAlign.center, ), + if (!isOffline && message != null && message != headline) ...[ + const SizedBox(height: 8), + Text( + message!, + style: TextStyle(color: Theme.of(context).hintColor, fontSize: 14), + textAlign: TextAlign.center, + ), + ], + if (bloc.allowRetry()) ...[ + const SizedBox(height: 16), + TextButton( + onPressed: () => bloc.reFetch!(), + child: const Text('Erneut versuchen'), + ), + ], + if (technicalDetails != null) ...[ + const SizedBox(height: 4), + TextButton( + onPressed: () => InfoDialog.show(context, technicalDetails!), + child: const Text('Details anzeigen'), + ), + ], ], - ], + ), ), ), ); diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_primary_loading.dart b/lib/state/app/infrastructure/loadableState/view/loadable_state_primary_loading.dart index 053aaba..6e996a6 100644 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_primary_loading.dart +++ b/lib/state/app/infrastructure/loadableState/view/loadable_state_primary_loading.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../../../../widget/app_progress_indicator.dart'; import 'loadable_state_consumer.dart'; class LoadableStatePrimaryLoading extends StatelessWidget { @@ -11,6 +12,6 @@ class LoadableStatePrimaryLoading extends StatelessWidget { opacity: visible ? 1.0 : 0.0, duration: LoadableStateConsumer.animationDuration, curve: Curves.easeInOut, - child: const Center(child: CircularProgressIndicator()), + child: const Center(child: AppProgressIndicator.large()), ); } diff --git a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart index 67cf540..b6807b5 100644 --- a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart +++ b/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart @@ -2,6 +2,7 @@ import 'dart:developer'; import 'package:hydrated_bloc/hydrated_bloc.dart'; +import '../../../../../api/errors/error_mapper.dart'; import '../../loadableState/loading_error.dart'; import '../../repository/repository.dart'; import 'loadable_hydrated_bloc_event.dart'; @@ -78,8 +79,9 @@ abstract class LoadableHydratedBloc< (e) { log('Error while fetching ${TState.toString()}: ${e.toString()}'); add(Error(LoadingError( - message: e.message ?? e.toString(), - allowRetry: true, + message: errorToUserMessage(e), + technicalDetails: errorToTechnicalDetails(e), + allowRetry: errorAllowsRetry(e), ))); }, ).then((value) { diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index c3a45ca..5dfb29a 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -1,3 +1,4 @@ +import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; import '../../../infrastructure/loadableState/loading_error.dart'; import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; @@ -75,8 +76,9 @@ class ChatBloc extends LoadableHydratedBloc { static const _debounceTag = 'settings_persist'; + bool _emitScheduled = false; SettingsCubit() : super(DefaultSettings.get()); Settings val({bool write = false}) { if (write) { - // Notify listeners immediately so the UI reflects the mutation right away; - // debounce the actual persistence to disk to avoid hammering on rapid edits. - _emitFreshInstance(); + // Defer the emit until the synchronous mutation on the returned object + // has finished. Without this scheduleMicrotask the cubit emits a copy + // captured *before* the assignment runs, so listeners (and HydratedBloc + // persistence) see the old value on the first emit. + if (!_emitScheduled) { + _emitScheduled = true; + scheduleMicrotask(() { + _emitScheduled = false; + _emitFreshInstance(); + }); + } EasyDebounce.debounce(_debounceTag, const Duration(milliseconds: 500), _emitFreshInstance); } return state; diff --git a/lib/theming/app_theme.dart b/lib/theming/app_theme.dart index cf831c0..d39f459 100644 --- a/lib/theming/app_theme.dart +++ b/lib/theming/app_theme.dart @@ -2,6 +2,17 @@ import 'package:flutter/material.dart'; import '../widget/dropdown_display.dart'; +class AppSpacing { + static const double xs = 4; + static const double sm = 8; + static const double md = 16; + static const double lg = 24; + static const double xl = 40; +} + +TextStyle inputErrorStyle(BuildContext context) => + TextStyle(color: Theme.of(context).colorScheme.error); + class AppTheme { static DropdownDisplay getDisplayOptions(ThemeMode theme) { switch(theme) { diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index 9f98cea..212c8fc 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -2,7 +2,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:loader_overlay/loader_overlay.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import '../../../api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart'; @@ -12,6 +11,7 @@ import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; import '../../../state/app/modules/files/bloc/files_bloc.dart'; import '../../../state/app/modules/files/bloc/files_state.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../widget/async_action_button.dart'; import '../../../widget/file_pick.dart'; import '../../../widget/placeholder_view.dart'; import 'widgets/file_element.dart'; @@ -175,12 +175,10 @@ class _FilesViewState extends State<_FilesView> { foldersToTop: context.watch().val().fileSettings.sortFoldersToTop, reversed: currentSortDirection, ); - return LoaderOverlay( - child: ListView.builder( - padding: EdgeInsets.zero, - itemCount: files.length, - itemBuilder: (context, index) => FileElement(files[index], widget.path, bloc.refresh), - ), + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: files.length, + itemBuilder: (context, index) => FileElement(files[index], widget.path, bloc.refresh), ); }, ), @@ -233,15 +231,17 @@ class _FilesViewState extends State<_FilesView> { content: TextField( controller: inputController, decoration: const InputDecoration(labelText: 'Name'), + autofocus: true, ), actions: [ - TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Abbrechen')), - TextButton( - onPressed: () { - bloc.createFolder(inputController.text); - Navigator.of(dialogCtx).pop(); + AsyncDialogAction( + confirmLabel: 'Ordner erstellen', + onConfirm: () async { + if (inputController.text.trim().isEmpty) { + throw Exception('Bitte einen Namen eingeben.'); + } + await bloc.createFolder(inputController.text.trim()); }, - child: const Text('Ordner erstellen'), ), ], ), diff --git a/lib/view/pages/files/files_upload_dialog.dart b/lib/view/pages/files/files_upload_dialog.dart index d4d53fd..4c9b1c6 100644 --- a/lib/view/pages/files/files_upload_dialog.dart +++ b/lib/view/pages/files/files_upload_dialog.dart @@ -254,7 +254,7 @@ class _FilesUploadDialogState extends State { border: const UnderlineInputBorder(), label: Text('Datei ${index+1}'), errorText: currentFile.isConflicting ? 'existiert bereits' : null, - errorStyle: const TextStyle(color: Colors.red), + errorStyle: TextStyle(color: Theme.of(context).colorScheme.error), ), onChanged: (input) { currentFile.fileName = input; diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart index dc2c626..26439a0 100644 --- a/lib/view/pages/files/widgets/file_element.dart +++ b/lib/view/pages/files/widgets/file_element.dart @@ -159,11 +159,12 @@ class _FileElementState extends State { showDialog(context: context, builder: (context) => ConfirmDialog( title: 'Element löschen?', content: 'Das Element wird unwiederruflich gelöscht.', - onConfirm: () { - WebdavApi.webdav - .then((value) => value.delete(PathUri.parse(widget.file.path))) - .then((value) => widget.refetch()); - } + confirmButton: 'Löschen', + onConfirmAsync: () async { + final webdav = await WebdavApi.webdav; + await webdav.delete(PathUri.parse(widget.file.path)); + widget.refetch(); + }, )); }, ), diff --git a/lib/view/pages/settings/sections/about_section.dart b/lib/view/pages/settings/sections/about_section.dart index 0784a6d..e8b3157 100644 --- a/lib/view/pages/settings/sections/about_section.dart +++ b/lib/view/pages/settings/sections/about_section.dart @@ -16,7 +16,7 @@ class AboutSection extends StatelessWidget { @override Widget build(BuildContext context) { - final settings = context.read(); + final settings = context.watch(); return Column( children: [ ListTile( diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index b7bf522..6cc6846 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -22,11 +22,11 @@ class AccountSection extends StatelessWidget { void _showLogoutDialog(BuildContext context) { showDialog( context: context, - builder: (context) => ConfirmDialog( + builder: (dialogContext) => ConfirmDialog( title: 'Abmelden?', content: 'Möchtest du dich wirklich abmelden?', confirmButton: 'Abmelden', - onConfirm: () async { + onConfirmAsync: () async { final prefs = await SharedPreferences.getInstance(); await prefs.clear(); PaintingBinding.instance.imageCache.clear(); diff --git a/lib/view/pages/settings/sections/appearance_section.dart b/lib/view/pages/settings/sections/appearance_section.dart index f5df162..003441a 100644 --- a/lib/view/pages/settings/sections/appearance_section.dart +++ b/lib/view/pages/settings/sections/appearance_section.dart @@ -9,7 +9,7 @@ class AppearanceSection extends StatelessWidget { @override Widget build(BuildContext context) { - final settings = context.read(); + final settings = context.watch(); return ListTile( leading: const Icon(Icons.dark_mode_outlined), title: const Text('Farbgebung'), diff --git a/lib/view/pages/settings/sections/dev_tools_section.dart b/lib/view/pages/settings/sections/dev_tools_section.dart index 619392c..95d8efd 100644 --- a/lib/view/pages/settings/sections/dev_tools_section.dart +++ b/lib/view/pages/settings/sections/dev_tools_section.dart @@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../storage/settings.dart' as model; import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/debug/cache_view.dart'; @@ -28,34 +29,43 @@ class _DevToolsSectionState extends State { title: const Text('Performance overlays'), trailing: const Icon(Icons.arrow_right), onTap: () { - showDialog(context: context, builder: (context) => SimpleDialog( - children: [ - ListTile( - leading: const Icon(Icons.auto_graph_outlined), - title: const Text('Performance graph'), - trailing: Checkbox( - value: widget.settings.val().devToolsSettings.showPerformanceOverlay, - onChanged: (e) => widget.settings.val(write: true).devToolsSettings.showPerformanceOverlay = e!, - ), - ), - ListTile( - leading: const Icon(Icons.screen_search_desktop_outlined), - title: const Text('Indicate offscreen layers'), - trailing: Checkbox( - value: widget.settings.val().devToolsSettings.checkerboardOffscreenLayers, - onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardOffscreenLayers = e!, - ), - ), - ListTile( - leading: const Icon(Icons.imagesearch_roller_outlined), - title: const Text('Indicate raster cache images'), - trailing: Checkbox( - value: widget.settings.val().devToolsSettings.checkerboardRasterCacheImages, - onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardRasterCacheImages = e!, - ), - ), - ], - )); + showDialog( + context: context, + builder: (dialogCtx) => BlocBuilder( + bloc: widget.settings, + builder: (_, _) { + final dev = widget.settings.val().devToolsSettings; + return SimpleDialog( + children: [ + ListTile( + leading: const Icon(Icons.auto_graph_outlined), + title: const Text('Performance graph'), + trailing: Checkbox( + value: dev.showPerformanceOverlay, + onChanged: (e) => widget.settings.val(write: true).devToolsSettings.showPerformanceOverlay = e!, + ), + ), + ListTile( + leading: const Icon(Icons.screen_search_desktop_outlined), + title: const Text('Indicate offscreen layers'), + trailing: Checkbox( + value: dev.checkerboardOffscreenLayers, + onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardOffscreenLayers = e!, + ), + ), + ListTile( + leading: const Icon(Icons.imagesearch_roller_outlined), + title: const Text('Indicate raster cache images'), + trailing: Checkbox( + value: dev.checkerboardRasterCacheImages, + onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardRasterCacheImages = e!, + ), + ), + ], + ); + }, + ), + ); }, ), ListTile( diff --git a/lib/view/pages/settings/sections/files_section.dart b/lib/view/pages/settings/sections/files_section.dart index 2035648..982a464 100644 --- a/lib/view/pages/settings/sections/files_section.dart +++ b/lib/view/pages/settings/sections/files_section.dart @@ -8,7 +8,7 @@ class FilesSection extends StatelessWidget { @override Widget build(BuildContext context) { - final settings = context.read(); + final settings = context.watch(); return Column( children: [ ListTile( diff --git a/lib/view/pages/settings/sections/talk_section.dart b/lib/view/pages/settings/sections/talk_section.dart index 1133c11..1596222 100644 --- a/lib/view/pages/settings/sections/talk_section.dart +++ b/lib/view/pages/settings/sections/talk_section.dart @@ -10,7 +10,7 @@ class TalkSection extends StatelessWidget { @override Widget build(BuildContext context) { - final settings = context.read(); + final settings = context.watch(); final talkSettings = settings.val().talkSettings; final notificationSettings = settings.val().notificationSettings; return Column( diff --git a/lib/view/pages/settings/sections/timetable_section.dart b/lib/view/pages/settings/sections/timetable_section.dart index a402cd9..4879c18 100644 --- a/lib/view/pages/settings/sections/timetable_section.dart +++ b/lib/view/pages/settings/sections/timetable_section.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; -import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../view/pages/timetable/data/timetable_name_mode.dart'; class TimetableSection extends StatelessWidget { @@ -10,8 +9,7 @@ class TimetableSection extends StatelessWidget { @override Widget build(BuildContext context) { - final settings = context.read(); - final timetableBloc = context.read(); + final settings = context.watch(); final timetableSettings = settings.val().timetableSettings; return Column( children: [ @@ -34,10 +32,8 @@ class TimetableSection extends StatelessWidget { ), )) .toList(), - onChanged: (value) { - settings.val(write: true).timetableSettings.timetableNameMode = value!; - timetableBloc.refresh(); - }, + onChanged: (value) => + settings.val(write: true).timetableSettings.timetableNameMode = value!, ), ), ListTile( @@ -45,10 +41,8 @@ class TimetableSection extends StatelessWidget { title: const Text('Doppelstunden zusammenhängend anzeigen'), trailing: Checkbox( value: timetableSettings.connectDoubleLessons, - onChanged: (e) { - settings.val(write: true).timetableSettings.connectDoubleLessons = e!; - timetableBloc.refresh(); - }, + onChanged: (e) => + settings.val(write: true).timetableSettings.connectDoubleLessons = e!, ), ), ], diff --git a/lib/view/pages/settings/settings.dart b/lib/view/pages/settings/settings.dart index 196d5e5..ebb45ea 100644 --- a/lib/view/pages/settings/settings.dart +++ b/lib/view/pages/settings/settings.dart @@ -1,8 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; -import '../../../storage/settings.dart' as model; import 'sections/about_section.dart'; import 'sections/account_section.dart'; import 'sections/appearance_section.dart'; @@ -14,24 +11,22 @@ class Settings extends StatelessWidget { const Settings({super.key}); @override - Widget build(BuildContext context) => BlocBuilder( - builder: (context, _) => Scaffold( - appBar: AppBar(title: const Text('Einstellungen')), - body: ListView( - children: const [ - AccountSection(), - Divider(), - AppearanceSection(), - Divider(), - TimetableSection(), - Divider(), - TalkSection(), - Divider(), - FilesSection(), - Divider(), - AboutSection(), - ], - ), + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('Einstellungen')), + body: ListView( + children: const [ + AccountSection(), + Divider(), + AppearanceSection(), + Divider(), + TimetableSection(), + Divider(), + TalkSection(), + Divider(), + FilesSection(), + Divider(), + AboutSection(), + ], ), ); } diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart index 04c53d2..7037624 100644 --- a/lib/view/pages/talk/chat_list.dart +++ b/lib/view/pages/talk/chat_list.dart @@ -12,6 +12,7 @@ import '../../../state/app/modules/chatList/bloc/chat_list_state.dart'; import '../../../notification/notify_updater.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../widget/confirm_dialog.dart'; +import '../../../widget/placeholder_view.dart'; import 'widgets/chat_tile.dart'; import 'widgets/split_view_placeholder.dart'; import 'join_chat.dart'; @@ -144,9 +145,7 @@ class _ChatListViewState extends State<_ChatListView> { title: 'Chat starten', content: "Möchtest du einen Chat mit Nutzer '$username' starten?", confirmButton: 'Chat starten', - onConfirm: () { - bloc.createDirectChat(username); - }, + onConfirmAsync: () => bloc.createDirectChat(username), ).asDialog(context); }); }, @@ -164,6 +163,13 @@ class _ChatListViewState extends State<_ChatListView> { unreadToTop: talkSettings.sortUnreadToTop, ); + if (sorted.isEmpty) { + return const PlaceholderView( + icon: Icons.chat_bubble_outline, + text: 'Noch keine Chats — starte einen über das +-Symbol.', + ); + } + return ListView( padding: EdgeInsets.zero, children: sorted.map((room) { diff --git a/lib/view/pages/talk/join_chat.dart b/lib/view/pages/talk/join_chat.dart index 3acc834..f937bc3 100644 --- a/lib/view/pages/talk/join_chat.dart +++ b/lib/view/pages/talk/join_chat.dart @@ -2,9 +2,11 @@ import 'package:async/async.dart'; import 'package:flutter/material.dart'; +import '../../../api/errors/error_mapper.dart'; import '../../../api/marianumcloud/autocomplete/autocompleteApi.dart'; import '../../../api/marianumcloud/autocomplete/autocompleteResponse.dart'; import '../../../model/endpoint_data.dart'; +import '../../../widget/app_progress_indicator.dart'; import '../../../widget/placeholder_view.dart'; class JoinChat extends SearchDelegate { @@ -16,17 +18,9 @@ class JoinChat extends SearchDelegate { future: future!.value, builder: (context, snapshot) { if(snapshot.connectionState != ConnectionState.done) { - return Container( - padding: const EdgeInsets.all(10), - child: const Center( - child: SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 3, - ), - ), - ), + return const Padding( + padding: EdgeInsets.all(10), + child: Center(child: AppProgressIndicator.medium()), ); } return const SizedBox.shrink(); @@ -76,10 +70,13 @@ class JoinChat extends SearchDelegate { } ); } else if(snapshot.hasError) { - return const PlaceholderView(icon: Icons.search_off, text: 'Ein fehler ist aufgetreten. Bist du mit dem Internet verbunden?'); + return PlaceholderView( + icon: Icons.search_off, + text: errorToUserMessage(snapshot.error), + ); } - return const Center(child: CircularProgressIndicator()); + return const Center(child: AppProgressIndicator.large()); }, ); } diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index e34a878..ede8081 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -14,6 +14,7 @@ import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart' import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; import '../../../../extensions/text.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../../../../widget/async_action_button.dart'; import '../../../../widget/loading_spinner.dart'; import '../../files/widgets/file_element.dart'; import '../data/chat_bubble_styles.dart'; @@ -306,22 +307,22 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM padding: EdgeInsets.zero, backgroundColor: hasSelfReacted ? Theme.of(context).primaryColor : null, onPressed: () { - if(hasSelfReacted) { - // Delete existing reaction - DeleteReactMessage( - chatToken: widget.chatData.token, - messageId: widget.bubbleData.id, - params: DeleteReactMessageParams(e.key), - ).run().then((value) => widget.refetch(renew: true)); - - } else { - // Add reaction - ReactMessage( - chatToken: widget.chatData.token, - messageId: widget.bubbleData.id, - params: ReactMessageParams(e.key) - ).run().then((value) => widget.refetch(renew: true)); - } + runWithErrorDialog(context, () async { + if (hasSelfReacted) { + await DeleteReactMessage( + chatToken: widget.chatData.token, + messageId: widget.bubbleData.id, + params: DeleteReactMessageParams(e.key), + ).run(); + } else { + await ReactMessage( + chatToken: widget.chatData.token, + messageId: widget.bubbleData.id, + params: ReactMessageParams(e.key), + ).run(); + } + widget.refetch(renew: true); + }); }, ), ); 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 1de61f6..7f7fa39 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -11,6 +11,8 @@ import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart' import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../../../../widget/app_progress_indicator.dart'; +import '../../../../widget/async_action_button.dart'; import '../../../../widget/debug/debug_tile.dart'; const _commonReactions = ['👍', '👎', '😆', '❤️', '👀']; @@ -78,14 +80,12 @@ Future showChatMessageOptionsDialog( onTap: () => Navigator.of(dialogCtx).pop(), ), if (canDelete) - ListTile( + AsyncListTile( leading: const Icon(Icons.delete_outline), title: const Text('Nachricht löschen'), - onTap: () async { + onPressed: () async { await DeleteMessage(chatData.token, bubbleData.id).run(); - if (!dialogCtx.mounted) return; - dialogCtx.read().refresh(); - Navigator.of(dialogCtx).pop(); + if (dialogCtx.mounted) dialogCtx.read().refresh(); }, ), DebugTile(dialogCtx).jsonData(bubbleData.toJson()), @@ -94,7 +94,7 @@ Future showChatMessageOptionsDialog( ); } -class _ReactionsRow extends StatelessWidget { +class _ReactionsRow extends StatefulWidget { final String chatToken; final int messageId; final void Function({bool renew}) onRefetch; @@ -107,46 +107,83 @@ class _ReactionsRow extends StatelessWidget { required this.dialogContext, }); - void _react(String emoji) { - Navigator.of(dialogContext).pop(); - ReactMessage( - chatToken: chatToken, - messageId: messageId, - params: ReactMessageParams(emoji), - ).run().then((_) => onRefetch(renew: true)); + @override + State<_ReactionsRow> createState() => _ReactionsRowState(); +} + +class _ReactionsRowState extends State<_ReactionsRow> { + final AsyncActionController _controller = AsyncActionController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _react(String emoji) async { + final ok = await _controller.run(() async { + await ReactMessage( + chatToken: widget.chatToken, + messageId: widget.messageId, + params: ReactMessageParams(emoji), + ).run(); + }); + if (!mounted) return; + if (ok) { + widget.onRefetch(renew: true); + if (widget.dialogContext.mounted) Navigator.of(widget.dialogContext).pop(); + } } @override - Widget build(BuildContext context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - Wrap( - alignment: WrapAlignment.center, + Widget build(BuildContext context) => AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final busy = _controller.busy; + final err = _controller.error; + return Column( + mainAxisSize: MainAxisSize.min, children: [ - ..._commonReactions.map( - (emoji) => TextButton( - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(40, 40), + Wrap( + alignment: WrapAlignment.center, + children: [ + ..._commonReactions.map( + (emoji) => TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(40, 40), + ), + onPressed: busy ? null : () => _react(emoji), + child: Text(emoji), + ), ), - onPressed: () => _react(emoji), - child: Text(emoji), - ), + IconButton( + onPressed: busy ? null : () => _showEmojiPicker(context), + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(40, 40), + ), + icon: busy + ? const AppProgressIndicator.small() + : const Icon(Icons.add_circle_outline_outlined), + ), + ], ), - IconButton( - onPressed: () => _showEmojiPicker(context), - style: IconButton.styleFrom( - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(40, 40), + if (err != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Text( + err, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12), + ), ), - icon: const Icon(Icons.add_circle_outline_outlined), - ), + const Divider(), ], - ), - const Divider(), - ], + ); + }, ); void _showEmojiPicker(BuildContext rowContext) { diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index 2ef4527..266958d 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -12,6 +12,7 @@ import '../../../../api/marianumcloud/talk/sendMessage/sendMessageParams.dart'; import '../../../../api/marianumcloud/webdav/webdavApi.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../widget/async_action_button.dart'; import '../../../../widget/file_pick.dart'; import '../../../../widget/focus_behaviour.dart'; import '../../files/files_upload_dialog.dart'; @@ -30,7 +31,8 @@ class ChatTextfield extends StatefulWidget { class _ChatTextfieldState extends State { late SettingsCubit settings; final TextEditingController _textBoxController = TextEditingController(); - bool isLoading = false; + final AsyncActionController _sendController = AsyncActionController(); + String? _sendError; void share(String shareFolder, List filePaths) { for (final element in filePaths) { @@ -92,6 +94,29 @@ class _ChatTextfieldState extends State { } } + @override + void dispose() { + _sendController.dispose(); + super.dispose(); + } + + Future _sendMessage(ChatBloc chatBloc) async { + if (_textBoxController.text.isEmpty) return; + final text = _textBoxController.text; + final replyTo = chatBloc.state.data?.referenceMessageId?.toString(); + setState(() => _sendError = null); + await SendMessage( + widget.sendToToken, + SendMessageParams(text, replyTo: replyTo), + ).run(); + if (!mounted) return; + chatBloc.refresh(); + _textBoxController.text = ''; + _setDraft(''); + chatBloc.setReferenceMessageId(null); + _setDraftReply(null); + } + @override Widget build(BuildContext context) { _textBoxController.text = settings.val().talkSettings.drafts[widget.sendToToken] ?? ''; @@ -135,6 +160,14 @@ class _ChatTextfieldState extends State { child: Column( children: [ replyBanner, + if (_sendError != null) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + _sendError!, + style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12), + ), + ), Row(children: [ GestureDetector( onTap: () { @@ -200,36 +233,19 @@ class _ChatTextfieldState extends State { ), ), const SizedBox(width: 15), - FloatingActionButton( - mini: true, - onPressed: () { - if (_textBoxController.text.isEmpty || isLoading) return; - - setState(() => isLoading = true); - SendMessage( - widget.sendToToken, - SendMessageParams( - _textBoxController.text, - replyTo: chatBloc.state.data?.referenceMessageId?.toString(), - ), - ).run().then((_) { - if (!mounted) return; - chatBloc.refresh(); - setState(() => isLoading = false); - _textBoxController.text = ''; - _setDraft(''); - chatBloc.setReferenceMessageId(null); - _setDraftReply(null); - }); - }, - backgroundColor: Theme.of(context).primaryColor, - elevation: 5, - child: isLoading - ? Container( - padding: const EdgeInsets.all(10), - child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2), - ) - : const Icon(Icons.send, color: Colors.white, size: 18), + ValueListenableBuilder( + valueListenable: _textBoxController, + builder: (context, value, _) => AsyncFab( + mini: true, + heroTag: 'chatSend_${widget.sendToToken}', + icon: Icons.send, + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Colors.white, + controller: _sendController, + onPressed: value.text.trim().isEmpty ? null : () => _sendMessage(chatBloc), + onError: (message) => setState(() => _sendError = message), + onSuccess: () => setState(() => _sendError = null), + ), ), ]), ], diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index bfce9c5..b4f9c8d 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -11,6 +12,7 @@ import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dar import '../../../../model/account_data.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/chatList/bloc/chat_list_bloc.dart'; +import '../../../../widget/async_action_button.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/user_avatar.dart'; @@ -42,15 +44,14 @@ class _ChatTileState extends State { void _refreshList() => context.read().refresh(); - void setCurrentAsRead() { - SetReadMarker( + Future _setCurrentAsRead() async { + await SetReadMarker( widget.data.token, true, setReadMarkerParams: SetReadMarkerParams(lastReadMessage: widget.data.lastMessage.id), - ).run().then((_) { - if (!mounted) return; - _refreshList(); - }); + ).run(); + if (!mounted) return; + _refreshList(); } @override @@ -116,7 +117,7 @@ class _ChatTileState extends State { ), onTap: () { if (selfUsername == null) return; - setCurrentAsRead(); + unawaited(_setCurrentAsRead()); final view = ChatView(room: widget.data, selfId: selfUsername!, avatar: circleAvatar); TalkNavigator.pushSplitView(context, view, overrideToSingleSubScreen: true); context.read().setToken(widget.data.token); @@ -125,65 +126,53 @@ class _ChatTileState extends State { if (widget.disableContextActions) return; showDialog(context: context, builder: (dialogCtx) => SimpleDialog( children: [ - Visibility( - visible: widget.data.unreadMessages > 0, - replacement: ListTile( - leading: const Icon(Icons.mark_chat_unread_outlined), - title: const Text('Als ungelesen markieren'), - onTap: () { - SetReadMarker(widget.data.token, false).run().then((_) { - if (mounted) _refreshList(); - }); - Navigator.of(dialogCtx).pop(); - }, - ), - child: ListTile( + if (widget.data.unreadMessages > 0) + AsyncListTile( leading: const Icon(Icons.mark_chat_read_outlined), title: const Text('Als gelesen markieren'), - onTap: () { - setCurrentAsRead(); - Navigator.of(dialogCtx).pop(); + onPressed: _setCurrentAsRead, + ) + else + AsyncListTile( + leading: const Icon(Icons.mark_chat_unread_outlined), + title: const Text('Als ungelesen markieren'), + onPressed: () async { + await SetReadMarker(widget.data.token, false).run(); + if (mounted) _refreshList(); }, ), - ), - Visibility( - visible: widget.data.isFavorite, - replacement: ListTile( - leading: const Icon(Icons.star_outline), - title: const Text('Zu Favoriten hinzufügen'), - onTap: () { - SetFavorite(widget.data.token, true).run().then((_) { - if (mounted) _refreshList(); - }); - Navigator.of(dialogCtx).pop(); - }, - ), - child: ListTile( + if (widget.data.isFavorite) + AsyncListTile( leading: const Icon(Icons.stars_outlined), title: const Text('Von Favoriten entfernen'), - onTap: () { - SetFavorite(widget.data.token, false).run().then((_) { - if (mounted) _refreshList(); - }); - Navigator.of(dialogCtx).pop(); + 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(); }, ), - ), ListTile( leading: const Icon(Icons.delete_outline), title: const Text('Konversation verlassen'), onTap: () { + Navigator.of(dialogCtx).pop(); ConfirmDialog( title: 'Chat verlassen', content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.', - confirmButton: 'Löschen', - onConfirm: () { - LeaveRoom(widget.data.token).run().then((_) { - if (mounted) _refreshList(); - }); - Navigator.of(dialogCtx).pop(); + confirmButton: 'Verlassen', + onConfirmAsync: () async { + await LeaveRoom(widget.data.token).run(); + if (mounted) _refreshList(); }, - ).asDialog(dialogCtx); + ).asDialog(context); }, ), DebugTile(dialogCtx).jsonData(widget.data.toJson()), diff --git a/lib/view/pages/timetable/details/delete_custom_event.dart b/lib/view/pages/timetable/details/delete_custom_event.dart index 3161e93..7d29e86 100644 --- a/lib/view/pages/timetable/details/delete_custom_event.dart +++ b/lib/view/pages/timetable/details/delete_custom_event.dart @@ -15,9 +15,7 @@ Completer showDeleteCustomEventDialog(BuildContext context, CustomTimetabl content: 'Der ${event.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.', confirmButton: 'Löschen', onConfirm: () { - bloc.removeCustomEvent(event.id).then(completer.complete).onError((Object error, StackTrace stack) { - completer.completeError(error, stack); - }); + bloc.removeCustomEvent(event.id).then(completer.complete).onError(completer.completeError); }, ).asDialog(context); return completer; diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index 20ae430..ee4ed3a 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -8,6 +8,7 @@ import '../../../state/app/infrastructure/loadableState/view/loadable_state_cons import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../storage/timetable_settings.dart'; import 'custom_events/custom_event_edit_dialog.dart'; import 'data/arbitrary_appointment.dart'; import 'data/lesson_period_schedule.dart'; @@ -30,6 +31,7 @@ class _TimetableState extends State { List? _cachedAppointments; int? _lastDataVersion; + TimetableSettings? _lastTimetableSettings; DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2)); @@ -51,18 +53,21 @@ class _TimetableState extends State { } List _appointments(TimetableState state) { - if (_cachedAppointments != null && _lastDataVersion == state.dataVersion) { + final timetableSettings = context.watch().val().timetableSettings; + if (_cachedAppointments != null && + _lastDataVersion == state.dataVersion && + identical(_lastTimetableSettings, timetableSettings)) { return _cachedAppointments!; } _lastDataVersion = state.dataVersion; + _lastTimetableSettings = timetableSettings; - final settings = context.read(); return _cachedAppointments = TimetableAppointmentFactory( lessons: state.getAllKnownLessons().toList(), customEvents: state.customEvents?.events ?? const [], rooms: state.rooms!, subjects: state.subjects!, - settings: settings.val().timetableSettings, + settings: timetableSettings, now: DateTime.now(), ).build(); } diff --git a/lib/widget/app_progress_indicator.dart b/lib/widget/app_progress_indicator.dart new file mode 100644 index 0000000..643096f --- /dev/null +++ b/lib/widget/app_progress_indicator.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +class AppProgressIndicator extends StatelessWidget { + final double size; + final double strokeWidth; + final Color? color; + + const AppProgressIndicator._({ + required this.size, + required this.strokeWidth, + this.color, + }); + + const AppProgressIndicator.small({Color? color}) + : this._(size: 16, strokeWidth: 2, color: color); + + const AppProgressIndicator.medium({Color? color}) + : this._(size: 24, strokeWidth: 2.5, color: color); + + const AppProgressIndicator.large({Color? color}) + : this._(size: 40, strokeWidth: 3, color: color); + + @override + Widget build(BuildContext context) { + final resolved = color ?? Theme.of(context).colorScheme.primary; + return SizedBox( + width: size, + height: size, + child: CircularProgressIndicator( + strokeWidth: strokeWidth, + valueColor: AlwaysStoppedAnimation(resolved), + ), + ); + } +} diff --git a/lib/widget/async_action_button.dart b/lib/widget/async_action_button.dart new file mode 100644 index 0000000..4a5e941 --- /dev/null +++ b/lib/widget/async_action_button.dart @@ -0,0 +1,541 @@ +import 'package:flutter/material.dart'; + +import '../api/errors/error_mapper.dart'; +import 'app_progress_indicator.dart'; +import 'info_dialog.dart'; + +Future runWithErrorDialog( + BuildContext context, + AsyncActionCallback action, { + AsyncErrorBuilder? errorBuilder, +}) async { + try { + await action(); + return true; + } catch (e) { + if (!context.mounted) return false; + final message = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e); + InfoDialog.show(context, message); + return false; + } +} + +typedef AsyncActionCallback = Future Function(); +typedef AsyncErrorBuilder = String Function(Object error); + +class AsyncActionController extends ChangeNotifier { + bool _busy = false; + String? _error; + + bool get busy => _busy; + String? get error => _error; + + Future run( + AsyncActionCallback action, { + AsyncErrorBuilder? errorBuilder, + }) async { + if (_busy) return false; + _busy = true; + _error = null; + notifyListeners(); + try { + await action(); + _busy = false; + notifyListeners(); + return true; + } catch (e) { + _busy = false; + _error = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e); + notifyListeners(); + return false; + } + } + + void clearError() { + if (_error == null) return; + _error = null; + notifyListeners(); + } +} + +class _AsyncMixin extends StatefulWidget { + final AsyncActionCallback? onPressed; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + final Widget Function(BuildContext context, bool busy, VoidCallback? handler) builder; + + const _AsyncMixin({ + required this.onPressed, + required this.builder, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + }); + + @override + State<_AsyncMixin> createState() => _AsyncMixinState(); +} + +class _AsyncMixinState extends State<_AsyncMixin> { + late final AsyncActionController _internal; + AsyncActionController get _controller => widget.controller ?? _internal; + + @override + void initState() { + super.initState(); + if (widget.controller == null) { + _internal = AsyncActionController(); + } + _controller.addListener(_onControllerChange); + } + + @override + void didUpdateWidget(covariant _AsyncMixin oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + (oldWidget.controller ?? _internal).removeListener(_onControllerChange); + _controller.addListener(_onControllerChange); + } + } + + @override + void dispose() { + _controller.removeListener(_onControllerChange); + if (widget.controller == null) { + _internal.dispose(); + } + super.dispose(); + } + + void _onControllerChange() { + if (mounted) setState(() {}); + } + + Future _trigger() async { + final action = widget.onPressed; + if (action == null) return; + final success = await _controller.run(action, errorBuilder: widget.errorBuilder); + if (!mounted) return; + if (success) { + widget.onSuccess?.call(); + } else if (widget.onError != null && _controller.error != null) { + widget.onError!(_controller.error!); + } + } + + @override + Widget build(BuildContext context) { + final handler = widget.onPressed == null ? null : _trigger; + return widget.builder(context, _controller.busy, _controller.busy ? null : handler); + } +} + +class AsyncActionButton extends StatelessWidget { + final AsyncActionCallback? onPressed; + final Widget child; + final IconData? icon; + final ButtonStyle? style; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + final bool showInlineError; + + const AsyncActionButton({ + required this.onPressed, + required this.child, + this.icon, + this.style, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + this.showInlineError = true, + super.key, + }); + + @override + Widget build(BuildContext context) => _AsyncMixin( + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + final spinner = AppProgressIndicator.small( + color: Theme.of(context).colorScheme.onPrimary, + ); + final content = busy + ? Row( + mainAxisSize: MainAxisSize.min, + children: [spinner, const SizedBox(width: 8), child], + ) + : (icon != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [Icon(icon), const SizedBox(width: 8), child], + ) + : child); + final button = ElevatedButton( + onPressed: handler, + style: style, + child: content, + ); + return _withInlineError(context, button); + }, + ); + + Widget _withInlineError(BuildContext context, Widget button) { + if (!showInlineError) return button; + return _InlineErrorWrapper(controller: controller, child: button); + } +} + +class AsyncTextButton extends StatelessWidget { + final AsyncActionCallback? onPressed; + final Widget child; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + final bool showInlineError; + + const AsyncTextButton({ + required this.onPressed, + required this.child, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + this.showInlineError = true, + super.key, + }); + + @override + Widget build(BuildContext context) => _AsyncMixin( + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + final content = busy + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppProgressIndicator.small( + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + child, + ], + ) + : child; + return _InlineErrorWrapper( + controller: controller, + child: TextButton(onPressed: handler, child: content), + ); + }, + ); +} + +class AsyncIconButton extends StatelessWidget { + final AsyncActionCallback? onPressed; + final IconData icon; + final Color? color; + final String? tooltip; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + + const AsyncIconButton({ + required this.onPressed, + required this.icon, + this.color, + this.tooltip, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + super.key, + }); + + @override + Widget build(BuildContext context) => _AsyncMixin( + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + if (busy) { + return Padding( + padding: const EdgeInsets.all(12), + child: AppProgressIndicator.small(color: color), + ); + } + return IconButton( + icon: Icon(icon, color: color), + tooltip: tooltip, + onPressed: handler, + ); + }, + ); +} + +class AsyncFab extends StatelessWidget { + final AsyncActionCallback? onPressed; + final IconData icon; + final Color? backgroundColor; + final Color? foregroundColor; + final Object? heroTag; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + final bool mini; + + const AsyncFab({ + required this.onPressed, + required this.icon, + this.backgroundColor, + this.foregroundColor, + this.heroTag, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + this.mini = false, + super.key, + }); + + @override + Widget build(BuildContext context) => _AsyncMixin( + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + final fg = foregroundColor ?? Theme.of(context).colorScheme.onPrimary; + return FloatingActionButton( + heroTag: heroTag, + backgroundColor: backgroundColor, + foregroundColor: fg, + mini: mini, + onPressed: handler, + child: busy ? AppProgressIndicator.small(color: fg) : Icon(icon), + ); + }, + ); +} + +class AsyncListTile extends StatefulWidget { + final AsyncActionCallback onPressed; + final Widget? leading; + final Widget title; + final Widget? subtitle; + final bool closeOnSuccess; + final VoidCallback? onSuccess; + final AsyncErrorBuilder? errorBuilder; + final bool enabled; + + const AsyncListTile({ + required this.onPressed, + required this.title, + this.leading, + this.subtitle, + this.closeOnSuccess = true, + this.onSuccess, + this.errorBuilder, + this.enabled = true, + super.key, + }); + + @override + State createState() => _AsyncListTileState(); +} + +class _AsyncListTileState extends State { + final AsyncActionController _controller = AsyncActionController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _handleTap() async { + final ok = await _controller.run(widget.onPressed, errorBuilder: widget.errorBuilder); + if (!mounted) return; + if (ok) { + widget.onSuccess?.call(); + if (widget.closeOnSuccess && Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + } + } + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final busy = _controller.busy; + final err = _controller.error; + final leading = busy + ? const SizedBox( + width: 24, + height: 24, + child: AppProgressIndicator.small(), + ) + : widget.leading; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + leading: leading, + title: widget.title, + subtitle: widget.subtitle, + enabled: widget.enabled && !busy, + onTap: busy ? null : _handleTap, + ), + if (err != null) + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), + child: Text( + err, + style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13), + ), + ), + ], + ); + }, + ); +} + +class _InlineErrorWrapper extends StatelessWidget { + final AsyncActionController? controller; + final Widget child; + const _InlineErrorWrapper({required this.controller, required this.child}); + + @override + Widget build(BuildContext context) { + final c = controller; + if (c == null) return child; + return AnimatedBuilder( + animation: c, + builder: (context, _) { + final err = c.error; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + child, + if (err != null) ...[ + const SizedBox(height: 8), + Text( + err, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13), + ), + ], + ], + ); + }, + ); + } +} + +class AsyncDialogAction extends StatefulWidget { + final String confirmLabel; + final AsyncActionCallback onConfirm; + final String? cancelLabel; + final AsyncErrorBuilder? errorBuilder; + final ButtonStyle? confirmStyle; + + const AsyncDialogAction({ + required this.confirmLabel, + required this.onConfirm, + this.cancelLabel = 'Abbrechen', + this.errorBuilder, + this.confirmStyle, + super.key, + }); + + @override + State createState() => _AsyncDialogActionState(); +} + +class _AsyncDialogActionState extends State { + final AsyncActionController _controller = AsyncActionController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final err = _controller.error; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (err != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + err, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (widget.cancelLabel != null) + TextButton( + onPressed: _controller.busy ? null : () => Navigator.of(context).pop(), + child: Text(widget.cancelLabel!), + ), + TextButton( + style: widget.confirmStyle, + onPressed: _controller.busy + ? null + : () async { + final ok = await _controller.run( + widget.onConfirm, + errorBuilder: widget.errorBuilder, + ); + if (ok && context.mounted) { + Navigator.of(context).pop(true); + } + }, + child: _controller.busy + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppProgressIndicator.small( + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text(widget.confirmLabel), + ], + ) + : Text(widget.confirmLabel), + ), + ], + ), + ], + ); + }, + ); +} diff --git a/lib/widget/confirm_dialog.dart b/lib/widget/confirm_dialog.dart index 8e2f01f..5bb6d1f 100644 --- a/lib/widget/confirm_dialog.dart +++ b/lib/widget/confirm_dialog.dart @@ -1,14 +1,30 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'async_action_button.dart'; + class ConfirmDialog extends StatelessWidget { final String title; final String content; final IconData? icon; final String confirmButton; final String cancelButton; - final void Function() onConfirm; - const ConfirmDialog({super.key, required this.title, this.content = '', this.icon, this.confirmButton = 'Ok', this.cancelButton = 'Abbrechen', required this.onConfirm}); + final void Function()? onConfirm; + final AsyncActionCallback? onConfirmAsync; + final AsyncErrorBuilder? errorBuilder; + + const ConfirmDialog({ + super.key, + required this.title, + this.content = '', + this.icon, + this.confirmButton = 'Ok', + this.cancelButton = 'Abbrechen', + this.onConfirm, + this.onConfirmAsync, + this.errorBuilder, + }) : assert(onConfirm != null || onConfirmAsync != null, + 'ConfirmDialog requires either onConfirm or onConfirmAsync'); void asDialog(BuildContext context) { showDialog(context: context, builder: build); @@ -16,20 +32,33 @@ class ConfirmDialog extends StatelessWidget { @override Widget build(BuildContext context) => AlertDialog( - icon: icon != null ? Icon(icon) : null, - title: Text(title), - content: Text(content), - actions: [ - TextButton(onPressed: () { - Navigator.of(context).pop(); - }, child: Text(cancelButton)), - TextButton(onPressed: () { - Navigator.of(context).pop(); - onConfirm(); - }, child: Text(confirmButton)), - ], - ); - + icon: icon != null ? Icon(icon) : null, + title: Text(title), + content: Text(content), + actions: onConfirmAsync != null + ? [ + AsyncDialogAction( + confirmLabel: confirmButton, + cancelLabel: cancelButton, + onConfirm: onConfirmAsync!, + errorBuilder: errorBuilder, + ), + ] + : [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(cancelButton), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onConfirm!(); + }, + child: Text(confirmButton), + ), + ], + ); + static void openBrowser(BuildContext context, String url) { showDialog( context: context, From 72ebe6f7e7391fdac752c7059647d967d0776795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 6 May 2026 11:58:50 +0200 Subject: [PATCH 10/23] claude refactorings, flutter best practices, platform dependent changes, general cleanup --- analysis_options.yaml | 102 +++-- android/app/build.gradle | 2 +- lib/api/{apiError.dart => api_error.dart} | 0 lib/api/{apiParams.dart => api_params.dart} | 0 lib/api/{apiRequest.dart => api_request.dart} | 0 .../{apiResponse.dart => api_response.dart} | 0 lib/api/errors/error_mapper.dart | 6 +- lib/api/errors/talk_exception.dart | 2 +- lib/api/errors/webuntis_exception.dart | 2 +- .../{getHolidays.dart => get_holidays.dart} | 2 +- ...daysCache.dart => get_holidays_cache.dart} | 6 +- ...sponse.dart => get_holidays_response.dart} | 4 +- ...se.g.dart => get_holidays_response.g.dart} | 2 +- ...completeApi.dart => autocomplete_api.dart} | 5 +- ...sponse.dart => autocomplete_response.dart} | 2 +- ...se.g.dart => autocomplete_response.g.dart} | 2 +- .../file_sharing_api.dart} | 2 +- .../file_sharing_api_params.dart} | 2 +- .../file_sharing_api_params.g.dart} | 2 +- .../talk/actions/talk_actions.dart | 6 +- .../talk/chat/{getChat.dart => get_chat.dart} | 11 +- ...{getChatCache.dart => get_chat_cache.dart} | 8 +- ...etChatParams.dart => get_chat_params.dart} | 4 +- ...atParams.g.dart => get_chat_params.g.dart} | 2 +- ...atResponse.dart => get_chat_response.dart} | 14 +- ...sponse.g.dart => get_chat_response.g.dart} | 2 +- ...dart => rich_object_string_processor.dart} | 2 +- .../create_room.dart} | 6 +- .../create_room_params.dart} | 4 +- .../create_room_params.g.dart} | 2 +- .../delete_react_message.dart} | 8 +- .../delete_react_message_params.dart} | 4 +- .../delete_react_message_params.g.dart} | 2 +- .../get_participants.dart} | 9 +- .../get_participants_cache.dart} | 6 +- .../get_participants_response.dart} | 4 +- .../get_participants_response.g.dart} | 2 +- .../get_poll_state.dart} | 9 +- .../get_poll_state_response.dart} | 4 +- .../get_poll_state_response.g.dart} | 2 +- .../get_reactions.dart} | 11 +- .../get_reactions_response.dart} | 4 +- .../get_reactions_response.g.dart} | 2 +- .../react_message.dart} | 8 +- .../react_message_params.dart} | 4 +- .../react_message_params.g.dart} | 2 +- .../talk/room/{getRoom.dart => get_room.dart} | 11 +- ...{getRoomCache.dart => get_room_cache.dart} | 8 +- ...etRoomParams.dart => get_room_params.dart} | 4 +- ...omParams.g.dart => get_room_params.g.dart} | 2 +- ...omResponse.dart => get_room_response.dart} | 6 +- ...sponse.g.dart => get_room_response.g.dart} | 2 +- .../send_message.dart} | 8 +- .../send_message_params.dart} | 4 +- .../send_message_params.g.dart} | 2 +- .../set_read_marker.dart} | 6 +- .../set_read_marker_params.dart} | 4 +- .../set_read_marker_params.g.dart} | 2 +- .../talk/{talkApi.dart => talk_api.dart} | 6 +- .../talk/{talkError.dart => talk_error.dart} | 0 .../queries/downloadFile/downloadFile.dart | 22 - .../queries/download_file/download_file.dart | 16 + .../download_file_params.dart} | 4 +- .../download_file_params.g.dart} | 2 +- .../download_file_response.dart} | 2 +- .../download_file_response.g.dart} | 2 +- .../queries/listFiles/listFilesCache.dart | 25 -- .../cacheable_file.dart} | 6 +- .../cacheable_file.g.dart} | 2 +- .../list_files.dart} | 18 +- .../queries/list_files/list_files_cache.dart | 41 ++ .../list_files_params.dart} | 4 +- .../list_files_params.g.dart} | 2 +- .../list_files_response.dart} | 6 +- .../list_files_response.g.dart} | 2 +- .../{webdavApi.dart => webdav_api.dart} | 4 +- .../get_breakers.dart} | 6 +- .../get_breakers_cache.dart} | 6 +- .../get_breakers_response.dart} | 4 +- .../get_breakers_response.g.dart} | 2 +- .../add/add_custom_timetable_event.dart} | 4 +- .../add_custom_timetable_event_params.dart} | 4 +- .../add_custom_timetable_event_params.g.dart} | 2 +- .../custom_timetable_event.dart} | 4 +- .../custom_timetable_event.g.dart} | 2 +- .../get/get_custom_timetable_event.dart} | 6 +- .../get_custom_timetable_event_cache.dart} | 8 +- .../get_custom_timetable_event_params.dart} | 2 +- .../get_custom_timetable_event_params.g.dart} | 2 +- .../get_custom_timetable_event_response.dart} | 6 +- ...et_custom_timetable_event_response.g.dart} | 2 +- .../remove_custom_timetable_event.dart} | 4 +- ...remove_custom_timetable_event_params.dart} | 2 +- ...move_custom_timetable_event_params.g.dart} | 2 +- .../update_custom_timetable_event.dart} | 4 +- ...update_custom_timetable_event_params.dart} | 4 +- ...date_custom_timetable_event_params.g.dart} | 2 +- lib/api/mhsl/{mhslApi.dart => mhsl_api.dart} | 2 +- ...tifyRegister.dart => notify_register.dart} | 4 +- ...arams.dart => notify_register_params.dart} | 2 +- ...s.g.dart => notify_register_params.g.dart} | 2 +- .../{addFeedback.dart => add_feedback.dart} | 4 +- ...ckParams.dart => add_feedback_params.dart} | 2 +- ...rams.g.dart => add_feedback_params.g.dart} | 2 +- .../update/update_user_index_params.dart} | 2 +- .../update/update_user_index_params.g.dart} | 2 +- .../update/update_userindex.dart} | 9 +- .../{requestCache.dart => request_cache.dart} | 13 +- .../queries/authenticate/authenticate.dart | 19 +- ...teParams.dart => authenticate_params.dart} | 4 +- ...rams.g.dart => authenticate_params.g.dart} | 2 +- ...sponse.dart => authenticate_response.dart} | 4 +- ...se.g.dart => authenticate_response.g.dart} | 2 +- .../get_holidays.dart} | 8 +- .../get_holidays_cache.dart} | 6 +- .../get_holidays_response.dart} | 4 +- .../get_holidays_response.g.dart} | 2 +- .../get_rooms.dart} | 10 +- .../get_rooms_cache.dart} | 6 +- .../get_rooms_response.dart} | 4 +- .../get_rooms_response.g.dart} | 2 +- .../get_subjects.dart} | 8 +- .../get_subjects_cache.dart} | 6 +- .../get_subjects_response.dart} | 4 +- .../get_subjects_response.g.dart} | 2 +- .../get_timegrid_units.dart} | 8 +- .../get_timegrid_units_cache.dart} | 6 +- .../get_timegrid_units_response.dart} | 4 +- .../get_timegrid_units_response.g.dart} | 2 +- .../get_timetable.dart} | 10 +- .../get_timetable_cache.dart} | 8 +- .../get_timetable_params.dart} | 4 +- .../get_timetable_params.g.dart} | 2 +- .../get_timetable_response.dart} | 4 +- .../get_timetable_response.g.dart} | 2 +- .../{webuntisApi.dart => webuntis_api.dart} | 32 +- ...webuntisError.dart => webuntis_error.dart} | 0 lib/app.dart | 12 +- lib/main.dart | 6 +- lib/model/account_data.dart | 4 +- lib/model/data_cleaner.dart | 8 +- lib/notification/notification_controller.dart | 28 +- lib/notification/notification_tasks.dart | 4 +- lib/notification/notify_updater.dart | 14 +- lib/routing/app_routes.dart | 8 +- .../basis/dataloader/holiday_data_loader.dart | 2 +- .../basis/dataloader/mhsl_data_loader.dart | 2 +- .../data_loader.dart | 8 +- .../bloc/loadable_state_bloc.dart | 4 +- .../bloc/loadable_state_event.dart | 0 .../bloc/loadable_state_state.dart | 0 .../bloc/loadable_state_state.freezed.dart | 0 .../loadable_state.dart | 0 .../loadable_state.freezed.dart | 0 .../loading_error.dart | 0 .../loading_error.freezed.dart | 0 .../loadable_state_background_loading.dart | 0 .../view/loadable_state_consumer.dart | 7 +- .../view/loadable_state_error_bar.dart | 0 .../view/loadable_state_error_screen.dart | 2 +- .../view/loadable_state_primary_loading.dart | 0 .../bloc_module.dart | 0 .../loadable_hydrated_bloc.dart | 6 +- .../loadable_hydrated_bloc_event.dart | 2 +- .../loadable_save_context.dart | 2 +- .../loadable_save_context.freezed.dart | 0 .../loadable_save_context.g.dart | 0 lib/state/app/modules/app_modules.dart | 21 +- .../modules/breaker/bloc/breaker_bloc.dart | 6 +- .../modules/breaker/bloc/breaker_event.dart | 2 +- .../modules/breaker/bloc/breaker_state.dart | 2 +- .../breaker_data_provider.dart | 4 +- .../repository/breaker_repository.dart | 2 +- .../app/modules/chat/bloc/chat_bloc.dart | 8 +- .../app/modules/chat/bloc/chat_event.dart | 2 +- .../app/modules/chat/bloc/chat_state.dart | 2 +- .../chat_data_provider.dart | 4 +- .../chat/repository/chat_repository.dart | 2 +- .../bloc/chat_list_bloc.dart | 17 +- .../bloc/chat_list_event.dart | 2 +- .../bloc/chat_list_state.dart | 2 +- .../bloc/chat_list_state.freezed.dart | 0 .../bloc/chat_list_state.g.dart | 0 .../chat_list_data_provider.dart | 8 +- .../repository/chat_list_repository.dart | 2 +- .../app/modules/files/bloc/files_bloc.dart | 8 +- .../app/modules/files/bloc/files_event.dart | 2 +- .../app/modules/files/bloc/files_state.dart | 2 +- .../files_data_provider.dart | 6 +- .../files/repository/files_repository.dart | 2 +- .../bloc/grade_averages_bloc.dart | 0 .../bloc/grade_averages_event.dart | 0 .../bloc/grade_averages_state.dart | 0 .../bloc/grade_averages_state.freezed.dart | 0 .../bloc/grade_averages_state.g.dart | 0 .../modules/holidays/bloc/holidays_bloc.dart | 10 +- .../modules/holidays/bloc/holidays_event.dart | 2 +- .../modules/holidays/bloc/holidays_state.dart | 2 +- .../holidays_get_holidays.dart | 2 +- .../repository/holidays_repository.dart | 2 +- .../bloc/marianum_dates_bloc.dart | 10 +- .../bloc/marianum_dates_event.dart | 2 +- .../bloc/marianum_dates_state.dart | 2 +- .../bloc/marianum_dates_state.freezed.dart | 0 .../bloc/marianum_dates_state.g.dart | 0 .../marianum_dates_get_events.dart | 4 +- .../repository/marianum_dates_repository.dart | 2 +- .../bloc/marianum_message_bloc.dart | 4 +- .../bloc/marianum_message_event.dart | 2 +- .../bloc/marianum_message_state.dart | 0 .../bloc/marianum_message_state.freezed.dart | 0 .../bloc/marianum_message_state.g.dart | 0 .../marianum_message_get_messages.dart | 4 +- .../marianum_message_repository.dart | 2 +- .../modules/settings/bloc/settings_cubit.dart | 6 +- .../timetable/bloc/timetable_bloc.dart | 8 +- .../timetable/bloc/timetable_event.dart | 2 +- .../timetable/bloc/timetable_state.dart | 12 +- .../timetable_data_provider.dart | 40 +- .../repository/timetable_repository.dart | 2 +- lib/storage/settings.dart | 2 +- lib/utils/debouncer.dart | 34 ++ lib/utils/download_manager.dart | 146 ++++++ lib/utils/file_downloader.dart | 47 ++ lib/utils/file_saver.dart | 23 - lib/view/login/login.dart | 4 +- lib/view/pages/files/files.dart | 256 +++++++++-- lib/view/pages/files/files_upload_dialog.dart | 22 +- .../pages/files/widgets/file_element.dart | 414 ++++++++++++------ .../grade_averages_list_view.dart | 6 +- .../grade_averages/grade_averages_view.dart | 6 +- lib/view/pages/holidays/holidays_view.dart | 6 +- .../marianum_dates/marianum_dates_view.dart | 12 +- .../marianum_message_list_view.dart | 10 +- .../marianum_message_view.dart | 2 +- .../pages/more/feedback/feedback_dialog.dart | 20 +- lib/view/pages/overhang.dart | 12 +- .../pages/settings/data/default_settings.dart | 6 +- .../settings/sections/account_section.dart | 7 +- .../settings/sections/dev_tools_section.dart | 2 +- lib/view/pages/talk/chat_list.dart | 16 +- lib/view/pages/talk/chat_view.dart | 12 +- .../pages/talk/data/chat_bubble_styles.dart | 2 +- lib/view/pages/talk/data/chat_message.dart | 4 +- lib/view/pages/talk/details/chat_info.dart | 6 +- .../pages/talk/details/message_reactions.dart | 4 +- .../talk/details/participants_list_view.dart | 4 +- lib/view/pages/talk/join_chat.dart | 4 +- lib/view/pages/talk/search_chat.dart | 4 +- .../pages/talk/widgets/answer_reference.dart | 4 +- lib/view/pages/talk/widgets/bubble.dart | 87 ++++ lib/view/pages/talk/widgets/chat_bubble.dart | 172 +++++--- .../widgets/chat_message_options_dialog.dart | 8 +- .../pages/talk/widgets/chat_textfield.dart | 17 +- lib/view/pages/talk/widgets/chat_tile.dart | 10 +- .../pages/talk/widgets/poll_options_list.dart | 2 +- .../custom_event_edit_dialog.dart | 3 +- .../custom_events/custom_events_view.dart | 2 +- .../timetable/data/arbitrary_appointment.dart | 4 +- .../data/lesson_period_schedule.dart | 2 +- .../pages/timetable/data/lesson_status.dart | 2 +- .../data/timetable_appointment_factory.dart | 10 +- .../timetable/details/_bottom_sheet.dart | 20 - .../pages/timetable/details/bottom_sheet.dart | 51 +++ .../timetable/details/custom_event_sheet.dart | 4 +- .../details/delete_custom_event.dart | 2 +- .../details/webuntis_lesson_sheet.dart | 8 +- lib/view/pages/timetable/timetable.dart | 4 +- .../timetable/widgets/appointment_tile.dart | 2 +- .../widgets/custom_workweek_calendar.dart | 4 +- .../widgets/special_regions_builder.dart | 2 +- lib/widget/animated_time.dart | 21 +- lib/widget/breaker/breaker.dart | 2 +- lib/widget/debug/cache_view.dart | 17 +- lib/widget/debug/json_viewer.dart | 7 +- lib/widget/file_pick.dart | 20 +- lib/widget/file_viewer.dart | 89 +++- pubspec.yaml | 26 +- 278 files changed, 1804 insertions(+), 1041 deletions(-) rename lib/api/{apiError.dart => api_error.dart} (100%) rename lib/api/{apiParams.dart => api_params.dart} (100%) rename lib/api/{apiRequest.dart => api_request.dart} (100%) rename lib/api/{apiResponse.dart => api_response.dart} (100%) rename lib/api/holidays/{getHolidays.dart => get_holidays.dart} (93%) rename lib/api/holidays/{getHolidaysCache.dart => get_holidays_cache.dart} (83%) rename lib/api/holidays/{getHolidaysResponse.dart => get_holidays_response.dart} (93%) rename lib/api/holidays/{getHolidaysResponse.g.dart => get_holidays_response.g.dart} (97%) rename lib/api/marianumcloud/autocomplete/{autocompleteApi.dart => autocomplete_api.dart} (77%) rename lib/api/marianumcloud/autocomplete/{autocompleteResponse.dart => autocomplete_response.dart} (96%) rename lib/api/marianumcloud/autocomplete/{autocompleteResponse.g.dart => autocomplete_response.g.dart} (97%) rename lib/api/marianumcloud/{files-sharing/fileSharingApi.dart => files_sharing/file_sharing_api.dart} (93%) rename lib/api/marianumcloud/{files-sharing/fileSharingApiParams.dart => files_sharing/file_sharing_api_params.dart} (93%) rename lib/api/marianumcloud/{files-sharing/fileSharingApiParams.g.dart => files_sharing/file_sharing_api_params.g.dart} (95%) rename lib/api/marianumcloud/talk/chat/{getChat.dart => get_chat.dart} (62%) rename lib/api/marianumcloud/talk/chat/{getChatCache.dart => get_chat_cache.dart} (83%) rename lib/api/marianumcloud/talk/chat/{getChatParams.dart => get_chat_params.dart} (92%) rename lib/api/marianumcloud/talk/chat/{getChatParams.g.dart => get_chat_params.g.dart} (97%) rename lib/api/marianumcloud/talk/chat/{getChatResponse.dart => get_chat_response.dart} (91%) rename lib/api/marianumcloud/talk/chat/{getChatResponse.g.dart => get_chat_response.g.dart} (99%) rename lib/api/marianumcloud/talk/chat/{richObjectStringProcessor.dart => rich_object_string_processor.dart} (89%) rename lib/api/marianumcloud/talk/{createRoom/createRoom.dart => create_room/create_room.dart} (83%) rename lib/api/marianumcloud/talk/{createRoom/createRoomParams.dart => create_room/create_room_params.dart} (89%) rename lib/api/marianumcloud/talk/{createRoom/createRoomParams.g.dart => create_room/create_room_params.g.dart} (96%) rename lib/api/marianumcloud/talk/{deleteReactMessage/deleteReactMessage.dart => delete_react_message/delete_react_message.dart} (80%) rename lib/api/marianumcloud/talk/{deleteReactMessage/deleteReactMessageParams.dart => delete_react_message/delete_react_message_params.dart} (83%) rename lib/api/marianumcloud/talk/{deleteReactMessage/deleteReactMessageParams.g.dart => delete_react_message/delete_react_message_params.g.dart} (92%) rename lib/api/marianumcloud/talk/{getParticipants/getParticipants.dart => get_participants/get_participants.dart} (58%) rename lib/api/marianumcloud/talk/{getParticipants/getParticipantsCache.dart => get_participants/get_participants_cache.dart} (80%) rename lib/api/marianumcloud/talk/{getParticipants/getParticipantsResponse.dart => get_participants/get_participants_response.dart} (96%) rename lib/api/marianumcloud/talk/{getParticipants/getParticipantsResponse.g.dart => get_participants/get_participants_response.g.dart} (98%) rename lib/api/marianumcloud/talk/{getPoll/getPollState.dart => get_poll/get_poll_state.dart} (61%) rename lib/api/marianumcloud/talk/{getPoll/getPollStateResponse.dart => get_poll/get_poll_state_response.dart} (94%) rename lib/api/marianumcloud/talk/{getPoll/getPollStateResponse.g.dart => get_poll/get_poll_state_response.g.dart} (97%) rename lib/api/marianumcloud/talk/{getReactions/getReactions.dart => get_reactions/get_reactions.dart} (61%) rename lib/api/marianumcloud/talk/{getReactions/getReactionsResponse.dart => get_reactions/get_reactions_response.dart} (92%) rename lib/api/marianumcloud/talk/{getReactions/getReactionsResponse.g.dart => get_reactions/get_reactions_response.g.dart} (97%) rename lib/api/marianumcloud/talk/{reactMessage/reactMessage.dart => react_message/react_message.dart} (80%) rename lib/api/marianumcloud/talk/{reactMessage/reactMessageParams.dart => react_message/react_message_params.dart} (83%) rename lib/api/marianumcloud/talk/{reactMessage/reactMessageParams.g.dart => react_message/react_message_params.g.dart} (93%) rename lib/api/marianumcloud/talk/room/{getRoom.dart => get_room.dart} (57%) rename lib/api/marianumcloud/talk/room/{getRoomCache.dart => get_room_cache.dart} (73%) rename lib/api/marianumcloud/talk/room/{getRoomParams.dart => get_room_params.dart} (90%) rename lib/api/marianumcloud/talk/room/{getRoomParams.g.dart => get_room_params.g.dart} (96%) rename lib/api/marianumcloud/talk/room/{getRoomResponse.dart => get_room_response.dart} (97%) rename lib/api/marianumcloud/talk/room/{getRoomResponse.g.dart => get_room_response.g.dart} (99%) rename lib/api/marianumcloud/talk/{sendMessage/sendMessage.dart => send_message/send_message.dart} (77%) rename lib/api/marianumcloud/talk/{sendMessage/sendMessageParams.dart => send_message/send_message_params.dart} (85%) rename lib/api/marianumcloud/talk/{sendMessage/sendMessageParams.g.dart => send_message/send_message_params.g.dart} (94%) rename lib/api/marianumcloud/talk/{setReadMarker/setReadMarker.dart => set_read_marker/set_read_marker.dart} (87%) rename lib/api/marianumcloud/talk/{setReadMarker/setReadMarkerParams.dart => set_read_marker/set_read_marker_params.dart} (83%) rename lib/api/marianumcloud/talk/{setReadMarker/setReadMarkerParams.g.dart => set_read_marker/set_read_marker_params.g.dart} (93%) rename lib/api/marianumcloud/talk/{talkApi.dart => talk_api.dart} (96%) rename lib/api/marianumcloud/talk/{talkError.dart => talk_error.dart} (100%) delete mode 100644 lib/api/marianumcloud/webdav/queries/downloadFile/downloadFile.dart create mode 100644 lib/api/marianumcloud/webdav/queries/download_file/download_file.dart rename lib/api/marianumcloud/webdav/queries/{downloadFile/downloadFileParams.dart => download_file/download_file_params.dart} (85%) rename lib/api/marianumcloud/webdav/queries/{downloadFile/downloadFileParams.g.dart => download_file/download_file_params.g.dart} (95%) rename lib/api/marianumcloud/webdav/queries/{downloadFile/downloadFileResponse.dart => download_file/download_file_response.dart} (89%) rename lib/api/marianumcloud/webdav/queries/{downloadFile/downloadFileResponse.g.dart => download_file/download_file_response.g.dart} (92%) delete mode 100644 lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart rename lib/api/marianumcloud/webdav/queries/{listFiles/cacheableFile.dart => list_files/cacheable_file.dart} (88%) rename lib/api/marianumcloud/webdav/queries/{listFiles/cacheableFile.g.dart => list_files/cacheable_file.g.dart} (97%) rename lib/api/marianumcloud/webdav/queries/{listFiles/listFiles.dart => list_files/list_files.dart} (67%) create mode 100644 lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart rename lib/api/marianumcloud/webdav/queries/{listFiles/listFilesParams.dart => list_files/list_files_params.dart} (83%) rename lib/api/marianumcloud/webdav/queries/{listFiles/listFilesParams.g.dart => list_files/list_files_params.g.dart} (93%) rename lib/api/marianumcloud/webdav/queries/{listFiles/listFilesResponse.dart => list_files/list_files_response.dart} (94%) rename lib/api/marianumcloud/webdav/queries/{listFiles/listFilesResponse.g.dart => list_files/list_files_response.g.dart} (95%) rename lib/api/marianumcloud/webdav/{webdavApi.dart => webdav_api.dart} (93%) rename lib/api/mhsl/breaker/{getBreakers/getBreakers.dart => get_breakers/get_breakers.dart} (73%) rename lib/api/mhsl/breaker/{getBreakers/getBreakersCache.dart => get_breakers/get_breakers_cache.dart} (75%) rename lib/api/mhsl/breaker/{getBreakers/getBreakersResponse.dart => get_breakers/get_breakers_response.dart} (93%) rename lib/api/mhsl/breaker/{getBreakers/getBreakersResponse.g.dart => get_breakers/get_breakers_response.g.dart} (97%) rename lib/api/mhsl/{customTimetableEvent/add/addCustomTimetableEvent.dart => custom_timetable_event/add/add_custom_timetable_event.dart} (85%) rename lib/api/mhsl/{customTimetableEvent/add/addCustomTimetableEventParams.dart => custom_timetable_event/add/add_custom_timetable_event_params.dart} (83%) rename lib/api/mhsl/{customTimetableEvent/add/addCustomTimetableEventParams.g.dart => custom_timetable_event/add/add_custom_timetable_event_params.g.dart} (92%) rename lib/api/mhsl/{customTimetableEvent/customTimetableEvent.dart => custom_timetable_event/custom_timetable_event.dart} (93%) rename lib/api/mhsl/{customTimetableEvent/customTimetableEvent.g.dart => custom_timetable_event/custom_timetable_event.g.dart} (97%) rename lib/api/mhsl/{customTimetableEvent/get/getCustomTimetableEvent.dart => custom_timetable_event/get/get_custom_timetable_event.dart} (80%) rename lib/api/mhsl/{customTimetableEvent/get/getCustomTimetableEventCache.dart => custom_timetable_event/get/get_custom_timetable_event_cache.dart} (72%) rename lib/api/mhsl/{customTimetableEvent/get/getCustomTimetableEventParams.dart => custom_timetable_event/get/get_custom_timetable_event_params.dart} (88%) rename lib/api/mhsl/{customTimetableEvent/get/getCustomTimetableEventParams.g.dart => custom_timetable_event/get/get_custom_timetable_event_params.g.dart} (91%) rename lib/api/mhsl/{customTimetableEvent/get/getCustomTimetableEventResponse.dart => custom_timetable_event/get/get_custom_timetable_event_response.dart} (77%) rename lib/api/mhsl/{customTimetableEvent/get/getCustomTimetableEventResponse.g.dart => custom_timetable_event/get/get_custom_timetable_event_response.g.dart} (94%) rename lib/api/mhsl/{customTimetableEvent/remove/removeCustomTimetableEvent.dart => custom_timetable_event/remove/remove_custom_timetable_event.dart} (84%) rename lib/api/mhsl/{customTimetableEvent/remove/removeCustomTimetableEventParams.dart => custom_timetable_event/remove/remove_custom_timetable_event_params.dart} (88%) rename lib/api/mhsl/{customTimetableEvent/remove/removeCustomTimetableEventParams.g.dart => custom_timetable_event/remove/remove_custom_timetable_event_params.g.dart} (91%) rename lib/api/mhsl/{customTimetableEvent/update/updateCustomTimetableEvent.dart => custom_timetable_event/update/update_custom_timetable_event.dart} (84%) rename lib/api/mhsl/{customTimetableEvent/update/updateCustomTimetableEventParams.dart => custom_timetable_event/update/update_custom_timetable_event_params.dart} (83%) rename lib/api/mhsl/{customTimetableEvent/update/updateCustomTimetableEventParams.g.dart => custom_timetable_event/update/update_custom_timetable_event_params.g.dart} (92%) rename lib/api/mhsl/{mhslApi.dart => mhsl_api.dart} (98%) rename lib/api/mhsl/notify/register/{notifyRegister.dart => notify_register.dart} (88%) rename lib/api/mhsl/notify/register/{notifyRegisterParams.dart => notify_register_params.dart} (92%) rename lib/api/mhsl/notify/register/{notifyRegisterParams.g.dart => notify_register_params.g.dart} (94%) rename lib/api/mhsl/server/feedback/{addFeedback.dart => add_feedback.dart} (85%) rename lib/api/mhsl/server/feedback/{addFeedbackParams.dart => add_feedback_params.dart} (93%) rename lib/api/mhsl/server/feedback/{addFeedbackParams.g.dart => add_feedback_params.g.dart} (95%) rename lib/api/mhsl/server/{userIndex/update/updateUserIndexParams.dart => user_index/update/update_user_index_params.dart} (93%) rename lib/api/mhsl/server/{userIndex/update/updateUserIndexParams.g.dart => user_index/update/update_user_index_params.g.dart} (95%) rename lib/api/mhsl/server/{userIndex/update/updateUserindex.dart => user_index/update/update_userindex.dart} (88%) rename lib/api/{requestCache.dart => request_cache.dart} (89%) rename lib/api/webuntis/queries/authenticate/{authenticateParams.dart => authenticate_params.dart} (85%) rename lib/api/webuntis/queries/authenticate/{authenticateParams.g.dart => authenticate_params.g.dart} (94%) rename lib/api/webuntis/queries/authenticate/{authenticateResponse.dart => authenticate_response.dart} (86%) rename lib/api/webuntis/queries/authenticate/{authenticateResponse.g.dart => authenticate_response.g.dart} (96%) rename lib/api/webuntis/queries/{getHolidays/getHolidays.dart => get_holidays/get_holidays.dart} (82%) rename lib/api/webuntis/queries/{getHolidays/getHolidaysCache.dart => get_holidays/get_holidays_cache.dart} (76%) rename lib/api/webuntis/queries/{getHolidays/getHolidaysResponse.dart => get_holidays/get_holidays_response.dart} (91%) rename lib/api/webuntis/queries/{getHolidays/getHolidaysResponse.g.dart => get_holidays/get_holidays_response.g.dart} (97%) rename lib/api/webuntis/queries/{getRooms/getRooms.dart => get_rooms/get_rooms.dart} (72%) rename lib/api/webuntis/queries/{getRooms/getRoomsCache.dart => get_rooms/get_rooms_cache.dart} (76%) rename lib/api/webuntis/queries/{getRooms/getRoomsResponse.dart => get_rooms/get_rooms_response.dart} (91%) rename lib/api/webuntis/queries/{getRooms/getRoomsResponse.g.dart => get_rooms/get_rooms_response.g.dart} (97%) rename lib/api/webuntis/queries/{getSubjects/getSubjects.dart => get_subjects/get_subjects.dart} (61%) rename lib/api/webuntis/queries/{getSubjects/getSubjectsCache.dart => get_subjects/get_subjects_cache.dart} (76%) rename lib/api/webuntis/queries/{getSubjects/getSubjectsResponse.dart => get_subjects/get_subjects_response.dart} (92%) rename lib/api/webuntis/queries/{getSubjects/getSubjectsResponse.g.dart => get_subjects/get_subjects_response.g.dart} (97%) rename lib/api/webuntis/queries/{getTimegridUnits/getTimegridUnits.dart => get_timegrid_units/get_timegrid_units.dart} (76%) rename lib/api/webuntis/queries/{getTimegridUnits/getTimegridUnitsCache.dart => get_timegrid_units/get_timegrid_units_cache.dart} (74%) rename lib/api/webuntis/queries/{getTimegridUnits/getTimegridUnitsResponse.dart => get_timegrid_units/get_timegrid_units_response.dart} (93%) rename lib/api/webuntis/queries/{getTimegridUnits/getTimegridUnitsResponse.g.dart => get_timegrid_units/get_timegrid_units_response.g.dart} (97%) rename lib/api/webuntis/queries/{getTimetable/getTimetable.dart => get_timetable/get_timetable.dart} (60%) rename lib/api/webuntis/queries/{getTimetable/getTimetableCache.dart => get_timetable/get_timetable_cache.dart} (90%) rename lib/api/webuntis/queries/{getTimetable/getTimetableParams.dart => get_timetable/get_timetable_params.dart} (97%) rename lib/api/webuntis/queries/{getTimetable/getTimetableParams.g.dart => get_timetable/get_timetable_params.g.dart} (99%) rename lib/api/webuntis/queries/{getTimetable/getTimetableResponse.dart => get_timetable/get_timetable_response.dart} (98%) rename lib/api/webuntis/queries/{getTimetable/getTimetableResponse.g.dart => get_timetable/get_timetable_response.g.dart} (99%) rename lib/api/webuntis/{webuntisApi.dart => webuntis_api.dart} (68%) rename lib/api/webuntis/{webuntisError.dart => webuntis_error.dart} (100%) rename lib/state/app/infrastructure/{dataLoader => data_loader}/data_loader.dart (81%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/bloc/loadable_state_bloc.dart (91%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/bloc/loadable_state_event.dart (100%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/bloc/loadable_state_state.dart (100%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/bloc/loadable_state_state.freezed.dart (100%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/loadable_state.dart (100%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/loadable_state.freezed.dart (100%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/loading_error.dart (100%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/loading_error.freezed.dart (100%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/view/loadable_state_background_loading.dart (100%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/view/loadable_state_consumer.dart (95%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/view/loadable_state_error_bar.dart (100%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/view/loadable_state_error_screen.dart (97%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/view/loadable_state_primary_loading.dart (100%) rename lib/state/app/infrastructure/{utilityWidgets => utility_widgets}/bloc_module.dart (100%) rename lib/state/app/infrastructure/{utilityWidgets/loadableHydratedBloc => utility_widgets/loadable_hydrated_bloc}/loadable_hydrated_bloc.dart (95%) rename lib/state/app/infrastructure/{utilityWidgets/loadableHydratedBloc => utility_widgets/loadable_hydrated_bloc}/loadable_hydrated_bloc_event.dart (91%) rename lib/state/app/infrastructure/{utilityWidgets/loadableHydratedBloc => utility_widgets/loadable_hydrated_bloc}/loadable_save_context.dart (93%) rename lib/state/app/infrastructure/{utilityWidgets/loadableHydratedBloc => utility_widgets/loadable_hydrated_bloc}/loadable_save_context.freezed.dart (100%) rename lib/state/app/infrastructure/{utilityWidgets/loadableHydratedBloc => utility_widgets/loadable_hydrated_bloc}/loadable_save_context.g.dart (100%) rename lib/state/app/modules/breaker/{dataProvider => data_provider}/breaker_data_provider.dart (64%) rename lib/state/app/modules/chat/{dataProvider => data_provider}/chat_data_provider.dart (80%) rename lib/state/app/modules/{chatList => chat_list}/bloc/chat_list_bloc.dart (77%) rename lib/state/app/modules/{chatList => chat_list}/bloc/chat_list_event.dart (50%) rename lib/state/app/modules/{chatList => chat_list}/bloc/chat_list_state.dart (83%) rename lib/state/app/modules/{chatList => chat_list}/bloc/chat_list_state.freezed.dart (100%) rename lib/state/app/modules/{chatList => chat_list}/bloc/chat_list_state.g.dart (100%) rename lib/state/app/modules/{chatList/dataProvider => chat_list/data_provider}/chat_list_data_provider.dart (67%) rename lib/state/app/modules/{chatList => chat_list}/repository/chat_list_repository.dart (86%) rename lib/state/app/modules/files/{dataProvider => data_provider}/files_data_provider.dart (82%) rename lib/state/app/modules/{gradeAverages => grade_averages}/bloc/grade_averages_bloc.dart (100%) rename lib/state/app/modules/{gradeAverages => grade_averages}/bloc/grade_averages_event.dart (100%) rename lib/state/app/modules/{gradeAverages => grade_averages}/bloc/grade_averages_state.dart (100%) rename lib/state/app/modules/{gradeAverages => grade_averages}/bloc/grade_averages_state.freezed.dart (100%) rename lib/state/app/modules/{gradeAverages => grade_averages}/bloc/grade_averages_state.g.dart (100%) rename lib/state/app/modules/holidays/{dataProvider => data_provider}/holidays_get_holidays.dart (86%) rename lib/state/app/modules/{marianumDates => marianum_dates}/bloc/marianum_dates_bloc.dart (65%) rename lib/state/app/modules/{marianumDates => marianum_dates}/bloc/marianum_dates_event.dart (70%) rename lib/state/app/modules/{marianumDates => marianum_dates}/bloc/marianum_dates_state.dart (100%) rename lib/state/app/modules/{marianumDates => marianum_dates}/bloc/marianum_dates_state.freezed.dart (100%) rename lib/state/app/modules/{marianumDates => marianum_dates}/bloc/marianum_dates_state.g.dart (100%) rename lib/state/app/modules/{marianumDates/dataProvider => marianum_dates/data_provider}/marianum_dates_get_events.dart (92%) rename lib/state/app/modules/{marianumDates => marianum_dates}/repository/marianum_dates_repository.dart (81%) rename lib/state/app/modules/{marianumMessage => marianum_message}/bloc/marianum_message_bloc.dart (80%) rename lib/state/app/modules/{marianumMessage => marianum_message}/bloc/marianum_message_event.dart (63%) rename lib/state/app/modules/{marianumMessage => marianum_message}/bloc/marianum_message_state.dart (100%) rename lib/state/app/modules/{marianumMessage => marianum_message}/bloc/marianum_message_state.freezed.dart (100%) rename lib/state/app/modules/{marianumMessage => marianum_message}/bloc/marianum_message_state.g.dart (100%) rename lib/state/app/modules/{marianumMessage/dataProvider => marianum_message/data_provider}/marianum_message_get_messages.dart (79%) rename lib/state/app/modules/{marianumMessage => marianum_message}/repository/marianum_message_repository.dart (81%) rename lib/state/app/modules/timetable/{dataProvider => data_provider}/timetable_data_provider.dart (67%) create mode 100644 lib/utils/debouncer.dart create mode 100644 lib/utils/download_manager.dart create mode 100644 lib/utils/file_downloader.dart delete mode 100644 lib/utils/file_saver.dart create mode 100644 lib/view/pages/talk/widgets/bubble.dart delete mode 100644 lib/view/pages/timetable/details/_bottom_sheet.dart create mode 100644 lib/view/pages/timetable/details/bottom_sheet.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index a40338c..5dd5f4c 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,44 +1,88 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. +# Static analysis configuration for the Flutter project. +# https://dart.dev/guides/language/analysis-options # -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. +# Base ruleset: flutter_lints (recommended Flutter defaults). +# Additional lints below catch real bugs and enforce consistent style. -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml analyzer: + language: + strict-casts: true + strict-raw-types: true errors: invalid_annotation_target: ignore + todo: ignore + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + - "lib/firebase_options.dart" + - "build/**" linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - file_names: false + # === Project conventions === prefer_relative_imports: true - unnecessary_lambdas: true prefer_single_quotes: true - prefer_if_elements_to_conditional_expressions: true - prefer_expression_function_bodies: true - omit_local_variable_types: true eol_at_end_of_file: true - cast_nullable_to_non_nullable: true - avoid_void_async: true + omit_local_variable_types: true avoid_multiple_declarations_per_line: true -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options + # === Bug catchers === + always_declare_return_types: true + avoid_empty_else: true + avoid_slow_async_io: true + avoid_type_to_string: true + avoid_void_async: true + await_only_futures: true + cancel_subscriptions: true + cast_nullable_to_non_nullable: true + close_sinks: true + empty_catches: true + hash_and_equals: true + no_adjacent_strings_in_list: true + no_duplicate_case_values: true + test_types_in_equals: true + throw_in_finally: true + unawaited_futures: true + unnecessary_statements: true + unrelated_type_equality_checks: true + use_build_context_synchronously: true + valid_regexps: true + + # === Flutter widget hygiene === + avoid_unnecessary_containers: true + sized_box_for_whitespace: true + sort_child_properties_last: true + use_colored_box: true + use_decorated_box: true + use_full_hex_values_for_flutter_colors: true + use_key_in_widget_constructors: true + + # === Code clarity === + directives_ordering: true + library_prefixes: true + no_leading_underscores_for_local_identifiers: true + prefer_conditional_assignment: true + prefer_if_elements_to_conditional_expressions: true + prefer_if_null_operators: true + prefer_initializing_formals: true + prefer_interpolation_to_compose_strings: true + prefer_is_empty: true + prefer_is_not_empty: true + prefer_is_not_operator: true + prefer_iterable_whereType: true + prefer_null_aware_operators: true + prefer_spread_collections: true + prefer_void_to_null: true + unnecessary_await_in_return: true + unnecessary_brace_in_string_interps: true + unnecessary_lambdas: true + unnecessary_null_aware_assignments: true + unnecessary_null_checks: true + unnecessary_parenthesis: true + unnecessary_string_interpolations: true + use_super_parameters: true + + # === File naming === + file_names: true diff --git a/android/app/build.gradle b/android/app/build.gradle index b62e5c5..befe6bb 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -25,7 +25,7 @@ if (flutterVersionName == null) { android { namespace "eu.mhsl.marianum.mobile.client" compileSdk flutter.compileSdkVersion - ndkVersion "27.0.12077973" + ndkVersion "28.2.13676358" compileOptions { sourceCompatibility JavaVersion.VERSION_17 diff --git a/lib/api/apiError.dart b/lib/api/api_error.dart similarity index 100% rename from lib/api/apiError.dart rename to lib/api/api_error.dart diff --git a/lib/api/apiParams.dart b/lib/api/api_params.dart similarity index 100% rename from lib/api/apiParams.dart rename to lib/api/api_params.dart diff --git a/lib/api/apiRequest.dart b/lib/api/api_request.dart similarity index 100% rename from lib/api/apiRequest.dart rename to lib/api/api_request.dart diff --git a/lib/api/apiResponse.dart b/lib/api/api_response.dart similarity index 100% rename from lib/api/apiResponse.dart rename to lib/api/api_response.dart diff --git a/lib/api/errors/error_mapper.dart b/lib/api/errors/error_mapper.dart index 2619643..313fd50 100644 --- a/lib/api/errors/error_mapper.dart +++ b/lib/api/errors/error_mapper.dart @@ -3,9 +3,9 @@ import 'dart:io'; import 'package:http/http.dart' as http; -import '../apiError.dart'; -import '../marianumcloud/talk/talkError.dart'; -import '../webuntis/webuntisError.dart'; +import '../api_error.dart'; +import '../marianumcloud/talk/talk_error.dart'; +import '../webuntis/webuntis_error.dart'; import 'app_exception.dart'; import 'network_exception.dart'; import 'parse_exception.dart'; diff --git a/lib/api/errors/talk_exception.dart b/lib/api/errors/talk_exception.dart index f46c0c7..534d1b2 100644 --- a/lib/api/errors/talk_exception.dart +++ b/lib/api/errors/talk_exception.dart @@ -1,4 +1,4 @@ -import '../marianumcloud/talk/talkError.dart'; +import '../marianumcloud/talk/talk_error.dart'; import 'app_exception.dart'; class TalkException extends AppException { diff --git a/lib/api/errors/webuntis_exception.dart b/lib/api/errors/webuntis_exception.dart index fd35d35..211f5ae 100644 --- a/lib/api/errors/webuntis_exception.dart +++ b/lib/api/errors/webuntis_exception.dart @@ -1,4 +1,4 @@ -import '../webuntis/webuntisError.dart'; +import '../webuntis/webuntis_error.dart'; import 'app_exception.dart'; class WebuntisException extends AppException { diff --git a/lib/api/holidays/getHolidays.dart b/lib/api/holidays/get_holidays.dart similarity index 93% rename from lib/api/holidays/getHolidays.dart rename to lib/api/holidays/get_holidays.dart index 8ce3325..5014c48 100644 --- a/lib/api/holidays/getHolidays.dart +++ b/lib/api/holidays/get_holidays.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import 'getHolidaysResponse.dart'; +import 'get_holidays_response.dart'; class GetHolidays { Future query() async { diff --git a/lib/api/holidays/getHolidaysCache.dart b/lib/api/holidays/get_holidays_cache.dart similarity index 83% rename from lib/api/holidays/getHolidaysCache.dart rename to lib/api/holidays/get_holidays_cache.dart index 2707916..5781b59 100644 --- a/lib/api/holidays/getHolidaysCache.dart +++ b/lib/api/holidays/get_holidays_cache.dart @@ -1,6 +1,6 @@ -import '../requestCache.dart'; -import 'getHolidays.dart'; -import 'getHolidaysResponse.dart'; +import '../request_cache.dart'; +import 'get_holidays.dart'; +import 'get_holidays_response.dart'; class GetHolidaysCache extends SimpleCache { GetHolidaysCache({super.onUpdate, super.renew}) diff --git a/lib/api/holidays/getHolidaysResponse.dart b/lib/api/holidays/get_holidays_response.dart similarity index 93% rename from lib/api/holidays/getHolidaysResponse.dart rename to lib/api/holidays/get_holidays_response.dart index 6ba00bb..7039417 100644 --- a/lib/api/holidays/getHolidaysResponse.dart +++ b/lib/api/holidays/get_holidays_response.dart @@ -1,9 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; -import '../apiResponse.dart'; +import '../api_response.dart'; -part 'getHolidaysResponse.g.dart'; +part 'get_holidays_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetHolidaysResponse extends ApiResponse { diff --git a/lib/api/holidays/getHolidaysResponse.g.dart b/lib/api/holidays/get_holidays_response.g.dart similarity index 97% rename from lib/api/holidays/getHolidaysResponse.g.dart rename to lib/api/holidays/get_holidays_response.g.dart index 4642931..593ad0b 100644 --- a/lib/api/holidays/getHolidaysResponse.g.dart +++ b/lib/api/holidays/get_holidays_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getHolidaysResponse.dart'; +part of 'get_holidays_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/autocomplete/autocompleteApi.dart b/lib/api/marianumcloud/autocomplete/autocomplete_api.dart similarity index 77% rename from lib/api/marianumcloud/autocomplete/autocompleteApi.dart rename to lib/api/marianumcloud/autocomplete/autocomplete_api.dart index 1539bd7..e1bb9e3 100644 --- a/lib/api/marianumcloud/autocomplete/autocompleteApi.dart +++ b/lib/api/marianumcloud/autocomplete/autocomplete_api.dart @@ -4,7 +4,7 @@ import 'dart:io'; import 'package:http/http.dart' as http; import '../nextcloud_ocs.dart'; -import 'autocompleteResponse.dart'; +import 'autocomplete_response.dart'; class AutocompleteApi { Future find(String query) async { @@ -22,6 +22,7 @@ class AutocompleteApi { if (response.statusCode != HttpStatus.ok) { throw Exception('Api call failed with ${response.statusCode}: ${response.body}'); } - return AutocompleteResponse.fromJson(jsonDecode(response.body)['ocs']); + final decoded = jsonDecode(response.body) as Map; + return AutocompleteResponse.fromJson(decoded['ocs'] as Map); } } diff --git a/lib/api/marianumcloud/autocomplete/autocompleteResponse.dart b/lib/api/marianumcloud/autocomplete/autocomplete_response.dart similarity index 96% rename from lib/api/marianumcloud/autocomplete/autocompleteResponse.dart rename to lib/api/marianumcloud/autocomplete/autocomplete_response.dart index 8e72772..60b4e7b 100644 --- a/lib/api/marianumcloud/autocomplete/autocompleteResponse.dart +++ b/lib/api/marianumcloud/autocomplete/autocomplete_response.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'autocompleteResponse.g.dart'; +part 'autocomplete_response.g.dart'; @JsonSerializable(explicitToJson: true) class AutocompleteResponse { diff --git a/lib/api/marianumcloud/autocomplete/autocompleteResponse.g.dart b/lib/api/marianumcloud/autocomplete/autocomplete_response.g.dart similarity index 97% rename from lib/api/marianumcloud/autocomplete/autocompleteResponse.g.dart rename to lib/api/marianumcloud/autocomplete/autocomplete_response.g.dart index 41ecf2b..65b9863 100644 --- a/lib/api/marianumcloud/autocomplete/autocompleteResponse.g.dart +++ b/lib/api/marianumcloud/autocomplete/autocomplete_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'autocompleteResponse.dart'; +part of 'autocomplete_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/files-sharing/fileSharingApi.dart b/lib/api/marianumcloud/files_sharing/file_sharing_api.dart similarity index 93% rename from lib/api/marianumcloud/files-sharing/fileSharingApi.dart rename to lib/api/marianumcloud/files_sharing/file_sharing_api.dart index 5914915..1551ada 100644 --- a/lib/api/marianumcloud/files-sharing/fileSharingApi.dart +++ b/lib/api/marianumcloud/files_sharing/file_sharing_api.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:http/http.dart' as http; import '../nextcloud_ocs.dart'; -import 'fileSharingApiParams.dart'; +import 'file_sharing_api_params.dart'; class FileSharingApi { Future share(FileSharingApiParams query) async { diff --git a/lib/api/marianumcloud/files-sharing/fileSharingApiParams.dart b/lib/api/marianumcloud/files_sharing/file_sharing_api_params.dart similarity index 93% rename from lib/api/marianumcloud/files-sharing/fileSharingApiParams.dart rename to lib/api/marianumcloud/files_sharing/file_sharing_api_params.dart index edcc6a5..4078d29 100644 --- a/lib/api/marianumcloud/files-sharing/fileSharingApiParams.dart +++ b/lib/api/marianumcloud/files_sharing/file_sharing_api_params.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'fileSharingApiParams.g.dart'; +part 'file_sharing_api_params.g.dart'; @JsonSerializable() class FileSharingApiParams { diff --git a/lib/api/marianumcloud/files-sharing/fileSharingApiParams.g.dart b/lib/api/marianumcloud/files_sharing/file_sharing_api_params.g.dart similarity index 95% rename from lib/api/marianumcloud/files-sharing/fileSharingApiParams.g.dart rename to lib/api/marianumcloud/files_sharing/file_sharing_api_params.g.dart index 9fc1e8b..472d1bc 100644 --- a/lib/api/marianumcloud/files-sharing/fileSharingApiParams.g.dart +++ b/lib/api/marianumcloud/files_sharing/file_sharing_api_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'fileSharingApiParams.dart'; +part of 'file_sharing_api_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/actions/talk_actions.dart b/lib/api/marianumcloud/talk/actions/talk_actions.dart index 88a83f3..59272cb 100644 --- a/lib/api/marianumcloud/talk/actions/talk_actions.dart +++ b/lib/api/marianumcloud/talk/actions/talk_actions.dart @@ -1,8 +1,8 @@ import 'package:http/http.dart' as http; -import '../../../apiParams.dart'; -import '../../../apiResponse.dart'; -import '../talkApi.dart'; +import '../../../api_params.dart'; +import '../../../api_response.dart'; +import '../talk_api.dart'; /// Small POST/DELETE-only Talk endpoints that have no response payload. /// Each class extends [TalkApi] with `assemble` returning `null`. They share diff --git a/lib/api/marianumcloud/talk/chat/getChat.dart b/lib/api/marianumcloud/talk/chat/get_chat.dart similarity index 62% rename from lib/api/marianumcloud/talk/chat/getChat.dart rename to lib/api/marianumcloud/talk/chat/get_chat.dart index fb64466..9009744 100644 --- a/lib/api/marianumcloud/talk/chat/getChat.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat.dart @@ -3,9 +3,9 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import '../talkApi.dart'; -import 'getChatParams.dart'; -import 'getChatResponse.dart'; +import '../talk_api.dart'; +import 'get_chat_params.dart'; +import 'get_chat_response.dart'; class GetChat extends TalkApi { String chatToken; @@ -14,7 +14,10 @@ class GetChat extends TalkApi { GetChat(this.chatToken, this.params) : super('v1/chat/$chatToken', null, getParameters: params.toJson()); @override - assemble(String raw) => GetChatResponse.fromJson(jsonDecode(raw)['ocs']); + GetChatResponse assemble(String raw) { + final decoded = jsonDecode(raw) as Map; + return GetChatResponse.fromJson(decoded['ocs'] as Map); + } @override Future request(Uri uri, Object? body, Map? headers) => http.get(uri, headers: headers); diff --git a/lib/api/marianumcloud/talk/chat/getChatCache.dart b/lib/api/marianumcloud/talk/chat/get_chat_cache.dart similarity index 83% rename from lib/api/marianumcloud/talk/chat/getChatCache.dart rename to lib/api/marianumcloud/talk/chat/get_chat_cache.dart index 92efd3b..608da9a 100644 --- a/lib/api/marianumcloud/talk/chat/getChatCache.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_cache.dart @@ -1,7 +1,7 @@ -import '../../../requestCache.dart'; -import 'getChat.dart'; -import 'getChatParams.dart'; -import 'getChatResponse.dart'; +import '../../../request_cache.dart'; +import 'get_chat.dart'; +import 'get_chat_params.dart'; +import 'get_chat_response.dart'; class GetChatCache extends SimpleCache { GetChatCache({ diff --git a/lib/api/marianumcloud/talk/chat/getChatParams.dart b/lib/api/marianumcloud/talk/chat/get_chat_params.dart similarity index 92% rename from lib/api/marianumcloud/talk/chat/getChatParams.dart rename to lib/api/marianumcloud/talk/chat/get_chat_params.dart index 08197b2..5287a3b 100644 --- a/lib/api/marianumcloud/talk/chat/getChatParams.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'getChatParams.g.dart'; +part 'get_chat_params.g.dart'; @JsonSerializable(explicitToJson: true, includeIfNull: false) class GetChatParams extends ApiParams { diff --git a/lib/api/marianumcloud/talk/chat/getChatParams.g.dart b/lib/api/marianumcloud/talk/chat/get_chat_params.g.dart similarity index 97% rename from lib/api/marianumcloud/talk/chat/getChatParams.g.dart rename to lib/api/marianumcloud/talk/chat/get_chat_params.g.dart index a2c9707..b318eb6 100644 --- a/lib/api/marianumcloud/talk/chat/getChatParams.g.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getChatParams.dart'; +part of 'get_chat_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/chat/getChatResponse.dart b/lib/api/marianumcloud/talk/chat/get_chat_response.dart similarity index 91% rename from lib/api/marianumcloud/talk/chat/getChatResponse.dart rename to lib/api/marianumcloud/talk/chat/get_chat_response.dart index 2c1db07..6470b19 100644 --- a/lib/api/marianumcloud/talk/chat/getChatResponse.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_response.dart @@ -1,10 +1,10 @@ import 'package:jiffy/jiffy.dart'; import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; -import '../room/getRoomResponse.dart'; +import '../../../api_response.dart'; +import '../room/get_room_response.dart'; -part 'getChatResponse.g.dart'; +part 'get_chat_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetChatResponse extends ApiResponse { @@ -87,10 +87,10 @@ class GetChatResponseObject { } Map? _fromJson(dynamic json) { - if(json is Map) { - var data = {}; - for (var element in json.keys) { - data.putIfAbsent(element, () => RichObjectString.fromJson(json[element])); + if (json is Map) { + final data = {}; + for (final element in json.keys) { + data.putIfAbsent(element, () => RichObjectString.fromJson(json[element] as Map)); } return data; } diff --git a/lib/api/marianumcloud/talk/chat/getChatResponse.g.dart b/lib/api/marianumcloud/talk/chat/get_chat_response.g.dart similarity index 99% rename from lib/api/marianumcloud/talk/chat/getChatResponse.g.dart rename to lib/api/marianumcloud/talk/chat/get_chat_response.g.dart index 1835359..c8f0276 100644 --- a/lib/api/marianumcloud/talk/chat/getChatResponse.g.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getChatResponse.dart'; +part of 'get_chat_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/chat/richObjectStringProcessor.dart b/lib/api/marianumcloud/talk/chat/rich_object_string_processor.dart similarity index 89% rename from lib/api/marianumcloud/talk/chat/richObjectStringProcessor.dart rename to lib/api/marianumcloud/talk/chat/rich_object_string_processor.dart index b61d064..af03502 100644 --- a/lib/api/marianumcloud/talk/chat/richObjectStringProcessor.dart +++ b/lib/api/marianumcloud/talk/chat/rich_object_string_processor.dart @@ -1,5 +1,5 @@ -import 'getChatResponse.dart'; +import 'get_chat_response.dart'; class RichObjectStringProcessor { static String parseToString(String message, Map? data) { diff --git a/lib/api/marianumcloud/talk/createRoom/createRoom.dart b/lib/api/marianumcloud/talk/create_room/create_room.dart similarity index 83% rename from lib/api/marianumcloud/talk/createRoom/createRoom.dart rename to lib/api/marianumcloud/talk/create_room/create_room.dart index 27d274d..e2183b6 100644 --- a/lib/api/marianumcloud/talk/createRoom/createRoom.dart +++ b/lib/api/marianumcloud/talk/create_room/create_room.dart @@ -2,15 +2,15 @@ import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import '../talkApi.dart'; -import 'createRoomParams.dart'; +import '../talk_api.dart'; +import 'create_room_params.dart'; class CreateRoom extends TalkApi { CreateRoomParams params; CreateRoom(this.params) : super('v4/room', params); @override - assemble(String raw) => null; + Null assemble(String raw) => null; @override Future? request(Uri uri, Object? body, Map? headers) { diff --git a/lib/api/marianumcloud/talk/createRoom/createRoomParams.dart b/lib/api/marianumcloud/talk/create_room/create_room_params.dart similarity index 89% rename from lib/api/marianumcloud/talk/createRoom/createRoomParams.dart rename to lib/api/marianumcloud/talk/create_room/create_room_params.dart index cb1d1b5..56ffe1d 100644 --- a/lib/api/marianumcloud/talk/createRoom/createRoomParams.dart +++ b/lib/api/marianumcloud/talk/create_room/create_room_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'createRoomParams.g.dart'; +part 'create_room_params.g.dart'; @JsonSerializable() class CreateRoomParams extends ApiParams { diff --git a/lib/api/marianumcloud/talk/createRoom/createRoomParams.g.dart b/lib/api/marianumcloud/talk/create_room/create_room_params.g.dart similarity index 96% rename from lib/api/marianumcloud/talk/createRoom/createRoomParams.g.dart rename to lib/api/marianumcloud/talk/create_room/create_room_params.g.dart index 8592ebb..b873df4 100644 --- a/lib/api/marianumcloud/talk/createRoom/createRoomParams.g.dart +++ b/lib/api/marianumcloud/talk/create_room/create_room_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'createRoomParams.dart'; +part of 'create_room_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessage.dart b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message.dart similarity index 80% rename from lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessage.dart rename to lib/api/marianumcloud/talk/delete_react_message/delete_react_message.dart index d586d5b..9dc886c 100644 --- a/lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessage.dart +++ b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message.dart @@ -1,9 +1,9 @@ import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import '../../../apiParams.dart'; -import '../talkApi.dart'; -import 'deleteReactMessageParams.dart'; +import '../../../api_params.dart'; +import '../talk_api.dart'; +import 'delete_react_message_params.dart'; class DeleteReactMessage extends TalkApi { String chatToken; @@ -11,7 +11,7 @@ class DeleteReactMessage extends TalkApi { DeleteReactMessage({required this.chatToken, required this.messageId, required DeleteReactMessageParams params}) : super('v1/reaction/$chatToken/$messageId', params); @override - assemble(String raw) => null; + Null assemble(String raw) => null; @override Future? request(Uri uri, ApiParams? body, Map? headers) { diff --git a/lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.dart b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart similarity index 83% rename from lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.dart rename to lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart index c40c317..d17bebc 100644 --- a/lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.dart +++ b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'deleteReactMessageParams.g.dart'; +part 'delete_react_message_params.g.dart'; @JsonSerializable() class DeleteReactMessageParams extends ApiParams { diff --git a/lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.g.dart b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.g.dart similarity index 92% rename from lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.g.dart rename to lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.g.dart index 7dc6c79..9f50520 100644 --- a/lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.g.dart +++ b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'deleteReactMessageParams.dart'; +part of 'delete_react_message_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/getParticipants/getParticipants.dart b/lib/api/marianumcloud/talk/get_participants/get_participants.dart similarity index 58% rename from lib/api/marianumcloud/talk/getParticipants/getParticipants.dart rename to lib/api/marianumcloud/talk/get_participants/get_participants.dart index ec88234..03b302a 100644 --- a/lib/api/marianumcloud/talk/getParticipants/getParticipants.dart +++ b/lib/api/marianumcloud/talk/get_participants/get_participants.dart @@ -2,15 +2,18 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import '../talkApi.dart'; -import 'getParticipantsResponse.dart'; +import '../talk_api.dart'; +import 'get_participants_response.dart'; class GetParticipants extends TalkApi { String token; GetParticipants(this.token) : super('v4/room/$token/participants', null); @override - GetParticipantsResponse assemble(String raw) => GetParticipantsResponse.fromJson(jsonDecode(raw)['ocs']); + GetParticipantsResponse assemble(String raw) { + final decoded = jsonDecode(raw) as Map; + return GetParticipantsResponse.fromJson(decoded['ocs'] as Map); + } @override Future request(Uri uri, Object? body, Map? headers) => http.get(uri, headers: headers); diff --git a/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart b/lib/api/marianumcloud/talk/get_participants/get_participants_cache.dart similarity index 80% rename from lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart rename to lib/api/marianumcloud/talk/get_participants/get_participants_cache.dart index c869b26..f40b017 100644 --- a/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart +++ b/lib/api/marianumcloud/talk/get_participants/get_participants_cache.dart @@ -1,6 +1,6 @@ -import '../../../requestCache.dart'; -import 'getParticipants.dart'; -import 'getParticipantsResponse.dart'; +import '../../../request_cache.dart'; +import 'get_participants.dart'; +import 'get_participants_response.dart'; class GetParticipantsCache extends SimpleCache { GetParticipantsCache({ diff --git a/lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart b/lib/api/marianumcloud/talk/get_participants/get_participants_response.dart similarity index 96% rename from lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart rename to lib/api/marianumcloud/talk/get_participants/get_participants_response.dart index 3d0e9ff..5f97086 100644 --- a/lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart +++ b/lib/api/marianumcloud/talk/get_participants/get_participants_response.dart @@ -1,9 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getParticipantsResponse.g.dart'; +part 'get_participants_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetParticipantsResponse extends ApiResponse { diff --git a/lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.g.dart b/lib/api/marianumcloud/talk/get_participants/get_participants_response.g.dart similarity index 98% rename from lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.g.dart rename to lib/api/marianumcloud/talk/get_participants/get_participants_response.g.dart index 424b161..f3fd7cb 100644 --- a/lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.g.dart +++ b/lib/api/marianumcloud/talk/get_participants/get_participants_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getParticipantsResponse.dart'; +part of 'get_participants_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/getPoll/getPollState.dart b/lib/api/marianumcloud/talk/get_poll/get_poll_state.dart similarity index 61% rename from lib/api/marianumcloud/talk/getPoll/getPollState.dart rename to lib/api/marianumcloud/talk/get_poll/get_poll_state.dart index 503c1d0..c4c37b7 100644 --- a/lib/api/marianumcloud/talk/getPoll/getPollState.dart +++ b/lib/api/marianumcloud/talk/get_poll/get_poll_state.dart @@ -2,8 +2,8 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import '../talkApi.dart'; -import 'getPollStateResponse.dart'; +import '../talk_api.dart'; +import 'get_poll_state_response.dart'; class GetPollState extends TalkApi { String token; @@ -11,7 +11,10 @@ class GetPollState extends TalkApi { GetPollState({required this.token, required this.pollId}) : super('v1/poll/$token/$pollId', null); @override - GetPollStateResponse assemble(String raw) => GetPollStateResponse.fromJson(jsonDecode(raw)['ocs']); + GetPollStateResponse assemble(String raw) { + final decoded = jsonDecode(raw) as Map; + return GetPollStateResponse.fromJson(decoded['ocs'] as Map); + } @override Future request(Uri uri, Object? body, Map? headers) => http.get(uri, headers: headers); diff --git a/lib/api/marianumcloud/talk/getPoll/getPollStateResponse.dart b/lib/api/marianumcloud/talk/get_poll/get_poll_state_response.dart similarity index 94% rename from lib/api/marianumcloud/talk/getPoll/getPollStateResponse.dart rename to lib/api/marianumcloud/talk/get_poll/get_poll_state_response.dart index 75d20c0..5c43a38 100644 --- a/lib/api/marianumcloud/talk/getPoll/getPollStateResponse.dart +++ b/lib/api/marianumcloud/talk/get_poll/get_poll_state_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getPollStateResponse.g.dart'; +part 'get_poll_state_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetPollStateResponse extends ApiResponse { diff --git a/lib/api/marianumcloud/talk/getPoll/getPollStateResponse.g.dart b/lib/api/marianumcloud/talk/get_poll/get_poll_state_response.g.dart similarity index 97% rename from lib/api/marianumcloud/talk/getPoll/getPollStateResponse.g.dart rename to lib/api/marianumcloud/talk/get_poll/get_poll_state_response.g.dart index 3015979..c81d2f5 100644 --- a/lib/api/marianumcloud/talk/getPoll/getPollStateResponse.g.dart +++ b/lib/api/marianumcloud/talk/get_poll/get_poll_state_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getPollStateResponse.dart'; +part of 'get_poll_state_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/getReactions/getReactions.dart b/lib/api/marianumcloud/talk/get_reactions/get_reactions.dart similarity index 61% rename from lib/api/marianumcloud/talk/getReactions/getReactions.dart rename to lib/api/marianumcloud/talk/get_reactions/get_reactions.dart index 5b9a8e3..549c788 100644 --- a/lib/api/marianumcloud/talk/getReactions/getReactions.dart +++ b/lib/api/marianumcloud/talk/get_reactions/get_reactions.dart @@ -3,9 +3,9 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import '../../../apiParams.dart'; -import '../talkApi.dart'; -import 'getReactionsResponse.dart'; +import '../../../api_params.dart'; +import '../talk_api.dart'; +import 'get_reactions_response.dart'; class GetReactions extends TalkApi { String chatToken; @@ -13,7 +13,10 @@ class GetReactions extends TalkApi { GetReactions({required this.chatToken, required this.messageId}) : super('v1/reaction/$chatToken/$messageId', null); @override - assemble(String raw) => GetReactionsResponse.fromJson(jsonDecode(raw)['ocs']); + GetReactionsResponse assemble(String raw) { + final decoded = jsonDecode(raw) as Map; + return GetReactionsResponse.fromJson(decoded['ocs'] as Map); + } @override Future? request(Uri uri, ApiParams? body, Map? headers) => http.get(uri, headers: headers); diff --git a/lib/api/marianumcloud/talk/getReactions/getReactionsResponse.dart b/lib/api/marianumcloud/talk/get_reactions/get_reactions_response.dart similarity index 92% rename from lib/api/marianumcloud/talk/getReactions/getReactionsResponse.dart rename to lib/api/marianumcloud/talk/get_reactions/get_reactions_response.dart index 5a6c9f0..052b03a 100644 --- a/lib/api/marianumcloud/talk/getReactions/getReactionsResponse.dart +++ b/lib/api/marianumcloud/talk/get_reactions/get_reactions_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getReactionsResponse.g.dart'; +part 'get_reactions_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetReactionsResponse extends ApiResponse { diff --git a/lib/api/marianumcloud/talk/getReactions/getReactionsResponse.g.dart b/lib/api/marianumcloud/talk/get_reactions/get_reactions_response.g.dart similarity index 97% rename from lib/api/marianumcloud/talk/getReactions/getReactionsResponse.g.dart rename to lib/api/marianumcloud/talk/get_reactions/get_reactions_response.g.dart index 2fa0d24..a050c59 100644 --- a/lib/api/marianumcloud/talk/getReactions/getReactionsResponse.g.dart +++ b/lib/api/marianumcloud/talk/get_reactions/get_reactions_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getReactionsResponse.dart'; +part of 'get_reactions_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/reactMessage/reactMessage.dart b/lib/api/marianumcloud/talk/react_message/react_message.dart similarity index 80% rename from lib/api/marianumcloud/talk/reactMessage/reactMessage.dart rename to lib/api/marianumcloud/talk/react_message/react_message.dart index ac76bd2..c1e93b1 100644 --- a/lib/api/marianumcloud/talk/reactMessage/reactMessage.dart +++ b/lib/api/marianumcloud/talk/react_message/react_message.dart @@ -1,9 +1,9 @@ import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import '../../../apiParams.dart'; -import '../talkApi.dart'; -import 'reactMessageParams.dart'; +import '../../../api_params.dart'; +import '../talk_api.dart'; +import 'react_message_params.dart'; class ReactMessage extends TalkApi { String chatToken; @@ -11,7 +11,7 @@ class ReactMessage extends TalkApi { ReactMessage({required this.chatToken, required this.messageId, required ReactMessageParams params}) : super('v1/reaction/$chatToken/$messageId', params); @override - assemble(String raw) => null; + Null assemble(String raw) => null; @override Future? request(Uri uri, ApiParams? body, Map? headers) { diff --git a/lib/api/marianumcloud/talk/reactMessage/reactMessageParams.dart b/lib/api/marianumcloud/talk/react_message/react_message_params.dart similarity index 83% rename from lib/api/marianumcloud/talk/reactMessage/reactMessageParams.dart rename to lib/api/marianumcloud/talk/react_message/react_message_params.dart index 0fb6cc1..22b8845 100644 --- a/lib/api/marianumcloud/talk/reactMessage/reactMessageParams.dart +++ b/lib/api/marianumcloud/talk/react_message/react_message_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'reactMessageParams.g.dart'; +part 'react_message_params.g.dart'; @JsonSerializable() class ReactMessageParams extends ApiParams { diff --git a/lib/api/marianumcloud/talk/reactMessage/reactMessageParams.g.dart b/lib/api/marianumcloud/talk/react_message/react_message_params.g.dart similarity index 93% rename from lib/api/marianumcloud/talk/reactMessage/reactMessageParams.g.dart rename to lib/api/marianumcloud/talk/react_message/react_message_params.g.dart index 328c53a..054b0c6 100644 --- a/lib/api/marianumcloud/talk/reactMessage/reactMessageParams.g.dart +++ b/lib/api/marianumcloud/talk/react_message/react_message_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'reactMessageParams.dart'; +part of 'react_message_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/room/getRoom.dart b/lib/api/marianumcloud/talk/room/get_room.dart similarity index 57% rename from lib/api/marianumcloud/talk/room/getRoom.dart rename to lib/api/marianumcloud/talk/room/get_room.dart index dd7cc52..bb7d68e 100644 --- a/lib/api/marianumcloud/talk/room/getRoom.dart +++ b/lib/api/marianumcloud/talk/room/get_room.dart @@ -2,9 +2,9 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import '../talkApi.dart'; -import 'getRoomParams.dart'; -import 'getRoomResponse.dart'; +import '../talk_api.dart'; +import 'get_room_params.dart'; +import 'get_room_response.dart'; class GetRoom extends TalkApi { @@ -14,7 +14,10 @@ class GetRoom extends TalkApi { @override - GetRoomResponse assemble(String raw) => GetRoomResponse.fromJson(jsonDecode(raw)['ocs']); + GetRoomResponse assemble(String raw) { + final decoded = jsonDecode(raw) as Map; + return GetRoomResponse.fromJson(decoded['ocs'] as Map); + } @override Future request(Uri uri, Object? body, Map? headers) => http.get(uri, headers: headers); diff --git a/lib/api/marianumcloud/talk/room/getRoomCache.dart b/lib/api/marianumcloud/talk/room/get_room_cache.dart similarity index 73% rename from lib/api/marianumcloud/talk/room/getRoomCache.dart rename to lib/api/marianumcloud/talk/room/get_room_cache.dart index 03fd785..107a58b 100644 --- a/lib/api/marianumcloud/talk/room/getRoomCache.dart +++ b/lib/api/marianumcloud/talk/room/get_room_cache.dart @@ -1,7 +1,7 @@ -import '../../../requestCache.dart'; -import 'getRoom.dart'; -import 'getRoomParams.dart'; -import 'getRoomResponse.dart'; +import '../../../request_cache.dart'; +import 'get_room.dart'; +import 'get_room_params.dart'; +import 'get_room_response.dart'; class GetRoomCache extends SimpleCache { GetRoomCache({super.onUpdate, super.onError, super.renew}) diff --git a/lib/api/marianumcloud/talk/room/getRoomParams.dart b/lib/api/marianumcloud/talk/room/get_room_params.dart similarity index 90% rename from lib/api/marianumcloud/talk/room/getRoomParams.dart rename to lib/api/marianumcloud/talk/room/get_room_params.dart index 70d371d..09e397e 100644 --- a/lib/api/marianumcloud/talk/room/getRoomParams.dart +++ b/lib/api/marianumcloud/talk/room/get_room_params.dart @@ -1,9 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'getRoomParams.g.dart'; +part 'get_room_params.g.dart'; @JsonSerializable(explicitToJson: true) class GetRoomParams extends ApiParams { diff --git a/lib/api/marianumcloud/talk/room/getRoomParams.g.dart b/lib/api/marianumcloud/talk/room/get_room_params.g.dart similarity index 96% rename from lib/api/marianumcloud/talk/room/getRoomParams.g.dart rename to lib/api/marianumcloud/talk/room/get_room_params.g.dart index 0e20511..ceaa6b1 100644 --- a/lib/api/marianumcloud/talk/room/getRoomParams.g.dart +++ b/lib/api/marianumcloud/talk/room/get_room_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getRoomParams.dart'; +part of 'get_room_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/room/getRoomResponse.dart b/lib/api/marianumcloud/talk/room/get_room_response.dart similarity index 97% rename from lib/api/marianumcloud/talk/room/getRoomResponse.dart rename to lib/api/marianumcloud/talk/room/get_room_response.dart index 36e7b6d..c2ce467 100644 --- a/lib/api/marianumcloud/talk/room/getRoomResponse.dart +++ b/lib/api/marianumcloud/talk/room/get_room_response.dart @@ -1,9 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; -import '../chat/getChatResponse.dart'; +import '../../../api_response.dart'; +import '../chat/get_chat_response.dart'; -part 'getRoomResponse.g.dart'; +part 'get_room_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetRoomResponse extends ApiResponse { diff --git a/lib/api/marianumcloud/talk/room/getRoomResponse.g.dart b/lib/api/marianumcloud/talk/room/get_room_response.g.dart similarity index 99% rename from lib/api/marianumcloud/talk/room/getRoomResponse.g.dart rename to lib/api/marianumcloud/talk/room/get_room_response.g.dart index 92491fb..49362cc 100644 --- a/lib/api/marianumcloud/talk/room/getRoomResponse.g.dart +++ b/lib/api/marianumcloud/talk/room/get_room_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getRoomResponse.dart'; +part of 'get_room_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/sendMessage/sendMessage.dart b/lib/api/marianumcloud/talk/send_message/send_message.dart similarity index 77% rename from lib/api/marianumcloud/talk/sendMessage/sendMessage.dart rename to lib/api/marianumcloud/talk/send_message/send_message.dart index 61af457..af3a012 100644 --- a/lib/api/marianumcloud/talk/sendMessage/sendMessage.dart +++ b/lib/api/marianumcloud/talk/send_message/send_message.dart @@ -1,16 +1,16 @@ import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import '../../../apiParams.dart'; -import '../talkApi.dart'; -import 'sendMessageParams.dart'; +import '../../../api_params.dart'; +import '../talk_api.dart'; +import 'send_message_params.dart'; class SendMessage extends TalkApi { String chatToken; SendMessage(this.chatToken, SendMessageParams params) : super('v1/chat/$chatToken', params); @override - assemble(String raw) => null; + Null assemble(String raw) => null; @override Future? request(Uri uri, ApiParams? body, Map? headers) { diff --git a/lib/api/marianumcloud/talk/sendMessage/sendMessageParams.dart b/lib/api/marianumcloud/talk/send_message/send_message_params.dart similarity index 85% rename from lib/api/marianumcloud/talk/sendMessage/sendMessageParams.dart rename to lib/api/marianumcloud/talk/send_message/send_message_params.dart index d467246..8ded2e2 100644 --- a/lib/api/marianumcloud/talk/sendMessage/sendMessageParams.dart +++ b/lib/api/marianumcloud/talk/send_message/send_message_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'sendMessageParams.g.dart'; +part 'send_message_params.g.dart'; @JsonSerializable(explicitToJson: true, includeIfNull: false) class SendMessageParams extends ApiParams { diff --git a/lib/api/marianumcloud/talk/sendMessage/sendMessageParams.g.dart b/lib/api/marianumcloud/talk/send_message/send_message_params.g.dart similarity index 94% rename from lib/api/marianumcloud/talk/sendMessage/sendMessageParams.g.dart rename to lib/api/marianumcloud/talk/send_message/send_message_params.g.dart index 602decc..34e1d0c 100644 --- a/lib/api/marianumcloud/talk/sendMessage/sendMessageParams.g.dart +++ b/lib/api/marianumcloud/talk/send_message/send_message_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'sendMessageParams.dart'; +part of 'send_message_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/setReadMarker/setReadMarker.dart b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart similarity index 87% rename from lib/api/marianumcloud/talk/setReadMarker/setReadMarker.dart rename to lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart index c3ae029..24389ef 100644 --- a/lib/api/marianumcloud/talk/setReadMarker/setReadMarker.dart +++ b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart @@ -2,8 +2,8 @@ import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import '../talkApi.dart'; -import 'setReadMarkerParams.dart'; +import '../talk_api.dart'; +import 'set_read_marker_params.dart'; class SetReadMarker extends TalkApi { String chatToken; @@ -15,7 +15,7 @@ class SetReadMarker extends TalkApi { } @override - assemble(String raw) => null; + Null assemble(String raw) => null; @override Future request(Uri uri, Object? body, Map? headers) { diff --git a/lib/api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dart b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart similarity index 83% rename from lib/api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dart rename to lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart index 5f037c2..50edee7 100644 --- a/lib/api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dart +++ b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'setReadMarkerParams.g.dart'; +part 'set_read_marker_params.g.dart'; @JsonSerializable() class SetReadMarkerParams extends ApiParams { diff --git a/lib/api/marianumcloud/talk/setReadMarker/setReadMarkerParams.g.dart b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.g.dart similarity index 93% rename from lib/api/marianumcloud/talk/setReadMarker/setReadMarkerParams.g.dart rename to lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.g.dart index 5c3c941..e6067b1 100644 --- a/lib/api/marianumcloud/talk/setReadMarker/setReadMarkerParams.g.dart +++ b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'setReadMarkerParams.dart'; +part of 'set_read_marker_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/talkApi.dart b/lib/api/marianumcloud/talk/talk_api.dart similarity index 96% rename from lib/api/marianumcloud/talk/talkApi.dart rename to lib/api/marianumcloud/talk/talk_api.dart index 371d9f2..9e63d35 100644 --- a/lib/api/marianumcloud/talk/talkApi.dart +++ b/lib/api/marianumcloud/talk/talk_api.dart @@ -4,9 +4,9 @@ import 'dart:io'; import 'package:http/http.dart' as http; -import '../../apiParams.dart'; -import '../../apiRequest.dart'; -import '../../apiResponse.dart'; +import '../../api_params.dart'; +import '../../api_request.dart'; +import '../../api_response.dart'; import '../../errors/auth_exception.dart'; import '../../errors/network_exception.dart'; import '../../errors/not_found_exception.dart'; diff --git a/lib/api/marianumcloud/talk/talkError.dart b/lib/api/marianumcloud/talk/talk_error.dart similarity index 100% rename from lib/api/marianumcloud/talk/talkError.dart rename to lib/api/marianumcloud/talk/talk_error.dart diff --git a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFile.dart b/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFile.dart deleted file mode 100644 index 30269c6..0000000 --- a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFile.dart +++ /dev/null @@ -1,22 +0,0 @@ - - -import '../../../../apiResponse.dart'; -import '../../webdavApi.dart'; -import 'downloadFileParams.dart'; - -class DownloadFile extends WebdavApi { - DownloadFileParams params; - - DownloadFile(this.params) : super(params); - - @override - Future run() async { - - // final file = await File(localPath).create(); - // Uint8List downloadedFile = await (await WebdavApi.webdav).download(params.webdavPath); - // file.writeAsBytesSync(downloadedFile, flush: true, mode: FileMode.write); - // - // OpenFile.open(localPath); - throw UnimplementedError(); - } -} diff --git a/lib/api/marianumcloud/webdav/queries/download_file/download_file.dart b/lib/api/marianumcloud/webdav/queries/download_file/download_file.dart new file mode 100644 index 0000000..5a4f164 --- /dev/null +++ b/lib/api/marianumcloud/webdav/queries/download_file/download_file.dart @@ -0,0 +1,16 @@ + + +import '../../../../api_response.dart'; +import '../../webdav_api.dart'; +import 'download_file_params.dart'; + +class DownloadFile extends WebdavApi { + DownloadFileParams params; + + DownloadFile(this.params) : super(params); + + @override + Future run() async { + throw UnimplementedError(); + } +} diff --git a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileParams.dart b/lib/api/marianumcloud/webdav/queries/download_file/download_file_params.dart similarity index 85% rename from lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileParams.dart rename to lib/api/marianumcloud/webdav/queries/download_file/download_file_params.dart index 11b6231..ba8b075 100644 --- a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileParams.dart +++ b/lib/api/marianumcloud/webdav/queries/download_file/download_file_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../../apiParams.dart'; +import '../../../../api_params.dart'; -part 'downloadFileParams.g.dart'; +part 'download_file_params.g.dart'; @JsonSerializable() class DownloadFileParams extends ApiParams { diff --git a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileParams.g.dart b/lib/api/marianumcloud/webdav/queries/download_file/download_file_params.g.dart similarity index 95% rename from lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileParams.g.dart rename to lib/api/marianumcloud/webdav/queries/download_file/download_file_params.g.dart index 3b56332..aa72134 100644 --- a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileParams.g.dart +++ b/lib/api/marianumcloud/webdav/queries/download_file/download_file_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'downloadFileParams.dart'; +part of 'download_file_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileResponse.dart b/lib/api/marianumcloud/webdav/queries/download_file/download_file_response.dart similarity index 89% rename from lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileResponse.dart rename to lib/api/marianumcloud/webdav/queries/download_file/download_file_response.dart index 3368cdd..76ff712 100644 --- a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileResponse.dart +++ b/lib/api/marianumcloud/webdav/queries/download_file/download_file_response.dart @@ -1,7 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; -part 'downloadFileResponse.g.dart'; +part 'download_file_response.g.dart'; @JsonSerializable() class DownloadFileResponse { diff --git a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileResponse.g.dart b/lib/api/marianumcloud/webdav/queries/download_file/download_file_response.g.dart similarity index 92% rename from lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileResponse.g.dart rename to lib/api/marianumcloud/webdav/queries/download_file/download_file_response.g.dart index 745d832..bab583f 100644 --- a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileResponse.g.dart +++ b/lib/api/marianumcloud/webdav/queries/download_file/download_file_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'downloadFileResponse.dart'; +part of 'download_file_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart b/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart deleted file mode 100644 index 3a61460..0000000 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'dart:convert'; -import 'package:crypto/crypto.dart'; - -import '../../../../requestCache.dart'; -import 'listFiles.dart'; -import 'listFilesParams.dart'; -import 'listFilesResponse.dart'; - -class ListFilesCache extends SimpleCache { - ListFilesCache({ - required void Function(ListFilesResponse) onUpdate, - super.onCacheData, - super.onNetworkData, - super.onError, - required String path, - }) : super( - cacheTime: RequestCache.cacheNothing, - loader: () => ListFiles(ListFilesParams(path)).run(), - fromJson: ListFilesResponse.fromJson, - onUpdate: onUpdate, - ) { - final cacheName = md5.convert(utf8.encode('MarianumMobile-$path')).toString(); - start('wd-folder-$cacheName'); - } -} diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart similarity index 88% rename from lib/api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart rename to lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart index b8a9918..c716dfb 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart @@ -1,7 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:nextcloud/nextcloud.dart'; -part 'cacheableFile.g.dart'; +part 'cacheable_file.g.dart'; @JsonSerializable(explicitToJson: true) class CacheableFile { @@ -15,10 +15,6 @@ class CacheableFile { DateTime? modifiedAt; String? sort; - @JsonKey(includeFromJson: false, includeToJson: false) - bool currentlyDownloading = false; - - CacheableFile({required this.path, required this.isDirectory, required this.name, this.mimeType, this.size, this.eTag, this.createdAt, this.modifiedAt}); CacheableFile.fromDavFile(WebDavFile file) { diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/cacheableFile.g.dart b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.g.dart similarity index 97% rename from lib/api/marianumcloud/webdav/queries/listFiles/cacheableFile.g.dart rename to lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.g.dart index c79547b..4e3407d 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/cacheableFile.g.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'cacheableFile.dart'; +part of 'cacheable_file.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFiles.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files.dart similarity index 67% rename from lib/api/marianumcloud/webdav/queries/listFiles/listFiles.dart rename to lib/api/marianumcloud/webdav/queries/list_files/list_files.dart index 8582989..6bebff9 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFiles.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files.dart @@ -1,10 +1,10 @@ import 'package:nextcloud/nextcloud.dart'; -import '../../webdavApi.dart'; -import 'cacheableFile.dart'; -import 'listFilesParams.dart'; -import 'listFilesResponse.dart'; +import '../../webdav_api.dart'; +import 'cacheable_file.dart'; +import 'list_files_params.dart'; +import 'list_files_response.dart'; class ListFiles extends WebdavApi { ListFilesParams params; @@ -27,16 +27,8 @@ class ListFiles extends WebdavApi { final webdav = await WebdavApi.webdav; final timeout = _isRoot ? _rootTimeout : _subfolderTimeout; final davFiles = (await webdav.propfind(PathUri.parse(params.path)).timeout(timeout)).toWebDavFiles(); - var files = davFiles.map(CacheableFile.fromDavFile).toSet(); + final files = davFiles.map(CacheableFile.fromDavFile).toSet(); - // webdav handles subdirectories wrong, this is a fix - // currently this fix is not needed anymore - // if(EndpointData().getEndpointMode() == EndpointMode.stage) { - // files = files.map((e) { // somehow - // e.path = e.path.split("mobile/cloud/remote.php/webdav")[1]; - // return e; - // }).toSet(); - // } // somehow the current working folder is also listed, it is filtered here. files.removeWhere((element) => element.path == '/${params.path}/' || element.path == '/'); diff --git a/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart new file mode 100644 index 0000000..4f17e6e --- /dev/null +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:localstore/localstore.dart'; + +import '../../../../../utils/cache_invalidation_bus.dart'; +import '../../../../request_cache.dart'; +import 'list_files.dart'; +import 'list_files_params.dart'; +import 'list_files_response.dart'; + +class ListFilesCache extends SimpleCache { + ListFilesCache({ + required void Function(ListFilesResponse) onUpdate, + super.onCacheData, + super.onNetworkData, + super.onError, + required String path, + }) : super( + cacheTime: RequestCache.cacheNothing, + loader: () => ListFiles(ListFilesParams(path)).run(), + fromJson: ListFilesResponse.fromJson, + onUpdate: onUpdate, + ) { + start(_documentId(path)); + } + + static String _documentId(String path) { + final cacheName = md5.convert(utf8.encode('MarianumMobile-$path')).toString(); + return 'wd-folder-$cacheName'; + } + + /// Drops the cached listing for [path] from local storage so the next + /// `listFiles` call cannot serve stale data, and notifies any live + /// `_FilesView` for that path via [CacheInvalidationBus] so it refetches + /// even while it is sitting in the background of the navigation stack. + static Future invalidate(String path) async { + await Localstore.instance.collection(RequestCache.collection).doc(_documentId(path)).delete(); + CacheInvalidationBus.notifyListFiles(path); + } +} diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesParams.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_params.dart similarity index 83% rename from lib/api/marianumcloud/webdav/queries/listFiles/listFilesParams.dart rename to lib/api/marianumcloud/webdav/queries/list_files/list_files_params.dart index f0adf21..c18a539 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesParams.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../../apiParams.dart'; +import '../../../../api_params.dart'; -part 'listFilesParams.g.dart'; +part 'list_files_params.g.dart'; @JsonSerializable(explicitToJson: true) class ListFilesParams extends ApiParams { diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesParams.g.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_params.g.dart similarity index 93% rename from lib/api/marianumcloud/webdav/queries/listFiles/listFilesParams.g.dart rename to lib/api/marianumcloud/webdav/queries/list_files/list_files_params.g.dart index 6a72efd..29f551f 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesParams.g.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'listFilesParams.dart'; +part of 'list_files_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart similarity index 94% rename from lib/api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart rename to lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart index 59f8d0e..1983583 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart @@ -2,10 +2,10 @@ import 'package:jiffy/jiffy.dart'; import 'package:json_annotation/json_annotation.dart'; import '../../../../../view/pages/files/files.dart'; -import '../../../../apiResponse.dart'; -import 'cacheableFile.dart'; +import '../../../../api_response.dart'; +import 'cacheable_file.dart'; -part 'listFilesResponse.g.dart'; +part 'list_files_response.g.dart'; @JsonSerializable(explicitToJson: true) class ListFilesResponse extends ApiResponse { diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesResponse.g.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.g.dart similarity index 95% rename from lib/api/marianumcloud/webdav/queries/listFiles/listFilesResponse.g.dart rename to lib/api/marianumcloud/webdav/queries/list_files/list_files_response.g.dart index 77522c2..09123cc 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesResponse.g.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'listFilesResponse.dart'; +part of 'list_files_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/webdav/webdavApi.dart b/lib/api/marianumcloud/webdav/webdav_api.dart similarity index 93% rename from lib/api/marianumcloud/webdav/webdavApi.dart rename to lib/api/marianumcloud/webdav/webdav_api.dart index 5049153..3327b62 100644 --- a/lib/api/marianumcloud/webdav/webdavApi.dart +++ b/lib/api/marianumcloud/webdav/webdav_api.dart @@ -2,8 +2,8 @@ import 'package:nextcloud/nextcloud.dart'; import '../../../model/account_data.dart'; import '../../../model/endpoint_data.dart'; -import '../../apiRequest.dart'; -import '../../apiResponse.dart'; +import '../../api_request.dart'; +import '../../api_response.dart'; abstract class WebdavApi extends ApiRequest { T genericParams; diff --git a/lib/api/mhsl/breaker/getBreakers/getBreakers.dart b/lib/api/mhsl/breaker/get_breakers/get_breakers.dart similarity index 73% rename from lib/api/mhsl/breaker/getBreakers/getBreakers.dart rename to lib/api/mhsl/breaker/get_breakers/get_breakers.dart index 63d2fe0..b8f5c93 100644 --- a/lib/api/mhsl/breaker/getBreakers/getBreakers.dart +++ b/lib/api/mhsl/breaker/get_breakers/get_breakers.dart @@ -2,14 +2,14 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import '../../mhslApi.dart'; -import 'getBreakersResponse.dart'; +import '../../mhsl_api.dart'; +import 'get_breakers_response.dart'; class GetBreakers extends MhslApi { GetBreakers() : super('breaker/'); @override - GetBreakersResponse assemble(String raw) => GetBreakersResponse.fromJson(jsonDecode(raw)); + GetBreakersResponse assemble(String raw) => GetBreakersResponse.fromJson(jsonDecode(raw) as Map); @override Future? request(Uri uri) => http.get(uri); diff --git a/lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart b/lib/api/mhsl/breaker/get_breakers/get_breakers_cache.dart similarity index 75% rename from lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart rename to lib/api/mhsl/breaker/get_breakers/get_breakers_cache.dart index d7bc0f8..8f3c180 100644 --- a/lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart +++ b/lib/api/mhsl/breaker/get_breakers/get_breakers_cache.dart @@ -1,6 +1,6 @@ -import '../../../requestCache.dart'; -import 'getBreakers.dart'; -import 'getBreakersResponse.dart'; +import '../../../request_cache.dart'; +import 'get_breakers.dart'; +import 'get_breakers_response.dart'; class GetBreakersCache extends SimpleCache { GetBreakersCache({super.onUpdate, super.renew}) diff --git a/lib/api/mhsl/breaker/getBreakers/getBreakersResponse.dart b/lib/api/mhsl/breaker/get_breakers/get_breakers_response.dart similarity index 93% rename from lib/api/mhsl/breaker/getBreakers/getBreakersResponse.dart rename to lib/api/mhsl/breaker/get_breakers/get_breakers_response.dart index 6e0cb73..aa0f3b1 100644 --- a/lib/api/mhsl/breaker/getBreakers/getBreakersResponse.dart +++ b/lib/api/mhsl/breaker/get_breakers/get_breakers_response.dart @@ -1,9 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getBreakersResponse.g.dart'; +part 'get_breakers_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetBreakersResponse extends ApiResponse { diff --git a/lib/api/mhsl/breaker/getBreakers/getBreakersResponse.g.dart b/lib/api/mhsl/breaker/get_breakers/get_breakers_response.g.dart similarity index 97% rename from lib/api/mhsl/breaker/getBreakers/getBreakersResponse.g.dart rename to lib/api/mhsl/breaker/get_breakers/get_breakers_response.g.dart index 5a2418c..30d16e6 100644 --- a/lib/api/mhsl/breaker/getBreakers/getBreakersResponse.g.dart +++ b/lib/api/mhsl/breaker/get_breakers/get_breakers_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getBreakersResponse.dart'; +part of 'get_breakers_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart similarity index 85% rename from lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart rename to lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart index 15f237f..c7fe3fc 100644 --- a/lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart +++ b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart @@ -3,8 +3,8 @@ import 'dart:convert'; import 'package:http/http.dart'; import 'package:http/http.dart' as http; -import '../../mhslApi.dart'; -import 'addCustomTimetableEventParams.dart'; +import '../../mhsl_api.dart'; +import 'add_custom_timetable_event_params.dart'; class AddCustomTimetableEvent extends MhslApi { AddCustomTimetableEventParams params; diff --git a/lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.dart b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart similarity index 83% rename from lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.dart rename to lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart index 125d3ea..a1d3b74 100644 --- a/lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.dart +++ b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../customTimetableEvent.dart'; +import '../custom_timetable_event.dart'; -part 'addCustomTimetableEventParams.g.dart'; +part 'add_custom_timetable_event_params.g.dart'; @JsonSerializable(explicitToJson: true) class AddCustomTimetableEventParams { diff --git a/lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.g.dart b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.g.dart similarity index 92% rename from lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.g.dart rename to lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.g.dart index e8d2b80..eb39f91 100644 --- a/lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.g.dart +++ b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'addCustomTimetableEventParams.dart'; +part of 'add_custom_timetable_event_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/customTimetableEvent/customTimetableEvent.dart b/lib/api/mhsl/custom_timetable_event/custom_timetable_event.dart similarity index 93% rename from lib/api/mhsl/customTimetableEvent/customTimetableEvent.dart rename to lib/api/mhsl/custom_timetable_event/custom_timetable_event.dart index d34489b..be9e4a6 100644 --- a/lib/api/mhsl/customTimetableEvent/customTimetableEvent.dart +++ b/lib/api/mhsl/custom_timetable_event/custom_timetable_event.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../mhslApi.dart'; +import '../mhsl_api.dart'; -part 'customTimetableEvent.g.dart'; +part 'custom_timetable_event.g.dart'; @JsonSerializable() class CustomTimetableEvent { diff --git a/lib/api/mhsl/customTimetableEvent/customTimetableEvent.g.dart b/lib/api/mhsl/custom_timetable_event/custom_timetable_event.g.dart similarity index 97% rename from lib/api/mhsl/customTimetableEvent/customTimetableEvent.g.dart rename to lib/api/mhsl/custom_timetable_event/custom_timetable_event.g.dart index e15d6d8..b83138b 100644 --- a/lib/api/mhsl/customTimetableEvent/customTimetableEvent.g.dart +++ b/lib/api/mhsl/custom_timetable_event/custom_timetable_event.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'customTimetableEvent.dart'; +part of 'custom_timetable_event.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEvent.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart similarity index 80% rename from lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEvent.dart rename to lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart index bca7fd0..dbdf476 100644 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEvent.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart @@ -3,9 +3,9 @@ import 'dart:convert'; import 'package:http/http.dart'; import 'package:http/http.dart' as http; -import '../../mhslApi.dart'; -import 'getCustomTimetableEventParams.dart'; -import 'getCustomTimetableEventResponse.dart'; +import '../../mhsl_api.dart'; +import 'get_custom_timetable_event_params.dart'; +import 'get_custom_timetable_event_response.dart'; class GetCustomTimetableEvent extends MhslApi { GetCustomTimetableEventParams params; diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart similarity index 72% rename from lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart rename to lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart index 5adc186..ba49152 100644 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart @@ -1,7 +1,7 @@ -import '../../../requestCache.dart'; -import 'getCustomTimetableEvent.dart'; -import 'getCustomTimetableEventParams.dart'; -import 'getCustomTimetableEventResponse.dart'; +import '../../../request_cache.dart'; +import 'get_custom_timetable_event.dart'; +import 'get_custom_timetable_event_params.dart'; +import 'get_custom_timetable_event_response.dart'; class GetCustomTimetableEventCache extends SimpleCache { GetCustomTimetableEventCache( diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart similarity index 88% rename from lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.dart rename to lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart index c4d6c79..58a9103 100644 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'getCustomTimetableEventParams.g.dart'; +part 'get_custom_timetable_event_params.g.dart'; @JsonSerializable() class GetCustomTimetableEventParams { diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.g.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.g.dart similarity index 91% rename from lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.g.dart rename to lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.g.dart index 6fc4044..01fc5a5 100644 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.g.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getCustomTimetableEventParams.dart'; +part of 'get_custom_timetable_event_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart similarity index 77% rename from lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart rename to lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart index c086b73..99684a2 100644 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart @@ -1,9 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; -import '../customTimetableEvent.dart'; +import '../../../api_response.dart'; +import '../custom_timetable_event.dart'; -part 'getCustomTimetableEventResponse.g.dart'; +part 'get_custom_timetable_event_response.g.dart'; @JsonSerializable() class GetCustomTimetableEventResponse extends ApiResponse { diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.g.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.g.dart similarity index 94% rename from lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.g.dart rename to lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.g.dart index 3e16db0..5bed445 100644 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.g.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getCustomTimetableEventResponse.dart'; +part of 'get_custom_timetable_event_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEvent.dart b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart similarity index 84% rename from lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEvent.dart rename to lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart index add1c55..436395e 100644 --- a/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEvent.dart +++ b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart @@ -3,8 +3,8 @@ import 'dart:convert'; import 'package:http/http.dart'; import 'package:http/http.dart' as http; -import '../../mhslApi.dart'; -import 'removeCustomTimetableEventParams.dart'; +import '../../mhsl_api.dart'; +import 'remove_custom_timetable_event_params.dart'; class RemoveCustomTimetableEvent extends MhslApi { RemoveCustomTimetableEventParams params; diff --git a/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.dart b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart similarity index 88% rename from lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.dart rename to lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart index 3b1d989..a84ba07 100644 --- a/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.dart +++ b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'removeCustomTimetableEventParams.g.dart'; +part 'remove_custom_timetable_event_params.g.dart'; @JsonSerializable() class RemoveCustomTimetableEventParams { diff --git a/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.g.dart b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.g.dart similarity index 91% rename from lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.g.dart rename to lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.g.dart index aa30f6f..4e85a31 100644 --- a/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.g.dart +++ b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'removeCustomTimetableEventParams.dart'; +part of 'remove_custom_timetable_event_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEvent.dart b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart similarity index 84% rename from lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEvent.dart rename to lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart index 9b1a754..4ae91d4 100644 --- a/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEvent.dart +++ b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart @@ -3,8 +3,8 @@ import 'dart:convert'; import 'package:http/http.dart'; import 'package:http/http.dart' as http; -import '../../mhslApi.dart'; -import 'updateCustomTimetableEventParams.dart'; +import '../../mhsl_api.dart'; +import 'update_custom_timetable_event_params.dart'; class UpdateCustomTimetableEvent extends MhslApi { UpdateCustomTimetableEventParams params; diff --git a/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.dart b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart similarity index 83% rename from lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.dart rename to lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart index 4a09c83..75f4dae 100644 --- a/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.dart +++ b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart @@ -1,9 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; -import '../customTimetableEvent.dart'; +import '../custom_timetable_event.dart'; -part 'updateCustomTimetableEventParams.g.dart'; +part 'update_custom_timetable_event_params.g.dart'; @JsonSerializable(explicitToJson: true) class UpdateCustomTimetableEventParams { diff --git a/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.g.dart b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.g.dart similarity index 92% rename from lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.g.dart rename to lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.g.dart index 11ec8e9..37c5d71 100644 --- a/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.g.dart +++ b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'updateCustomTimetableEventParams.dart'; +part of 'update_custom_timetable_event_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/mhslApi.dart b/lib/api/mhsl/mhsl_api.dart similarity index 98% rename from lib/api/mhsl/mhslApi.dart rename to lib/api/mhsl/mhsl_api.dart index 55f31c1..da380f3 100644 --- a/lib/api/mhsl/mhslApi.dart +++ b/lib/api/mhsl/mhsl_api.dart @@ -5,7 +5,7 @@ import 'dart:io'; import 'package:http/http.dart' as http; import 'package:jiffy/jiffy.dart'; -import '../apiRequest.dart'; +import '../api_request.dart'; import '../errors/network_exception.dart'; import '../errors/parse_exception.dart'; import '../errors/server_exception.dart'; diff --git a/lib/api/mhsl/notify/register/notifyRegister.dart b/lib/api/mhsl/notify/register/notify_register.dart similarity index 88% rename from lib/api/mhsl/notify/register/notifyRegister.dart rename to lib/api/mhsl/notify/register/notify_register.dart index a7053dc..b28c3dc 100644 --- a/lib/api/mhsl/notify/register/notifyRegister.dart +++ b/lib/api/mhsl/notify/register/notify_register.dart @@ -4,8 +4,8 @@ import 'dart:developer'; import 'package:http/http.dart' as http; -import '../../mhslApi.dart'; -import 'notifyRegisterParams.dart'; +import '../../mhsl_api.dart'; +import 'notify_register_params.dart'; class NotifyRegister extends MhslApi { NotifyRegisterParams params; diff --git a/lib/api/mhsl/notify/register/notifyRegisterParams.dart b/lib/api/mhsl/notify/register/notify_register_params.dart similarity index 92% rename from lib/api/mhsl/notify/register/notifyRegisterParams.dart rename to lib/api/mhsl/notify/register/notify_register_params.dart index 3f18319..1c92c46 100644 --- a/lib/api/mhsl/notify/register/notifyRegisterParams.dart +++ b/lib/api/mhsl/notify/register/notify_register_params.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'notifyRegisterParams.g.dart'; +part 'notify_register_params.g.dart'; @JsonSerializable() class NotifyRegisterParams { diff --git a/lib/api/mhsl/notify/register/notifyRegisterParams.g.dart b/lib/api/mhsl/notify/register/notify_register_params.g.dart similarity index 94% rename from lib/api/mhsl/notify/register/notifyRegisterParams.g.dart rename to lib/api/mhsl/notify/register/notify_register_params.g.dart index d862558..ad53ab5 100644 --- a/lib/api/mhsl/notify/register/notifyRegisterParams.g.dart +++ b/lib/api/mhsl/notify/register/notify_register_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'notifyRegisterParams.dart'; +part of 'notify_register_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/server/feedback/addFeedback.dart b/lib/api/mhsl/server/feedback/add_feedback.dart similarity index 85% rename from lib/api/mhsl/server/feedback/addFeedback.dart rename to lib/api/mhsl/server/feedback/add_feedback.dart index 54c3ce0..7f69978 100644 --- a/lib/api/mhsl/server/feedback/addFeedback.dart +++ b/lib/api/mhsl/server/feedback/add_feedback.dart @@ -3,8 +3,8 @@ import 'dart:convert'; import 'package:http/http.dart'; import 'package:http/http.dart' as http; -import '../../mhslApi.dart'; -import 'addFeedbackParams.dart'; +import '../../mhsl_api.dart'; +import 'add_feedback_params.dart'; class AddFeedback extends MhslApi { diff --git a/lib/api/mhsl/server/feedback/addFeedbackParams.dart b/lib/api/mhsl/server/feedback/add_feedback_params.dart similarity index 93% rename from lib/api/mhsl/server/feedback/addFeedbackParams.dart rename to lib/api/mhsl/server/feedback/add_feedback_params.dart index 945b00c..ecf9adb 100644 --- a/lib/api/mhsl/server/feedback/addFeedbackParams.dart +++ b/lib/api/mhsl/server/feedback/add_feedback_params.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'addFeedbackParams.g.dart'; +part 'add_feedback_params.g.dart'; @JsonSerializable() class AddFeedbackParams { diff --git a/lib/api/mhsl/server/feedback/addFeedbackParams.g.dart b/lib/api/mhsl/server/feedback/add_feedback_params.g.dart similarity index 95% rename from lib/api/mhsl/server/feedback/addFeedbackParams.g.dart rename to lib/api/mhsl/server/feedback/add_feedback_params.g.dart index ec85d22..747d43b 100644 --- a/lib/api/mhsl/server/feedback/addFeedbackParams.g.dart +++ b/lib/api/mhsl/server/feedback/add_feedback_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'addFeedbackParams.dart'; +part of 'add_feedback_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/server/userIndex/update/updateUserIndexParams.dart b/lib/api/mhsl/server/user_index/update/update_user_index_params.dart similarity index 93% rename from lib/api/mhsl/server/userIndex/update/updateUserIndexParams.dart rename to lib/api/mhsl/server/user_index/update/update_user_index_params.dart index 680edda..7fd07f4 100644 --- a/lib/api/mhsl/server/userIndex/update/updateUserIndexParams.dart +++ b/lib/api/mhsl/server/user_index/update/update_user_index_params.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'updateUserIndexParams.g.dart'; +part 'update_user_index_params.g.dart'; @JsonSerializable() class UpdateUserIndexParams { diff --git a/lib/api/mhsl/server/userIndex/update/updateUserIndexParams.g.dart b/lib/api/mhsl/server/user_index/update/update_user_index_params.g.dart similarity index 95% rename from lib/api/mhsl/server/userIndex/update/updateUserIndexParams.g.dart rename to lib/api/mhsl/server/user_index/update/update_user_index_params.g.dart index 285302a..4d8775a 100644 --- a/lib/api/mhsl/server/userIndex/update/updateUserIndexParams.g.dart +++ b/lib/api/mhsl/server/user_index/update/update_user_index_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'updateUserIndexParams.dart'; +part of 'update_user_index_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/server/userIndex/update/updateUserindex.dart b/lib/api/mhsl/server/user_index/update/update_userindex.dart similarity index 88% rename from lib/api/mhsl/server/userIndex/update/updateUserindex.dart rename to lib/api/mhsl/server/user_index/update/update_userindex.dart index c020fe3..9b728b6 100644 --- a/lib/api/mhsl/server/userIndex/update/updateUserindex.dart +++ b/lib/api/mhsl/server/user_index/update/update_userindex.dart @@ -1,4 +1,5 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:developer'; @@ -7,8 +8,8 @@ import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; import '../../../../../model/account_data.dart'; -import '../../../mhslApi.dart'; -import 'updateUserIndexParams.dart'; +import '../../../mhsl_api.dart'; +import 'update_user_index_params.dart'; class UpdateUserIndex extends MhslApi { UpdateUserIndexParams params; @@ -25,7 +26,7 @@ class UpdateUserIndex extends MhslApi { } static Future index() async { - UpdateUserIndex( + unawaited(UpdateUserIndex( UpdateUserIndexParams( username: AccountData().getUsername(), user: AccountData().getUserSecret(), @@ -33,6 +34,6 @@ class UpdateUserIndex extends MhslApi { appVersion: int.parse((await PackageInfo.fromPlatform()).buildNumber), deviceInfo: jsonEncode((await DeviceInfoPlugin().deviceInfo).data).toString(), ), - ).run(); + ).run()); } } diff --git a/lib/api/requestCache.dart b/lib/api/request_cache.dart similarity index 89% rename from lib/api/requestCache.dart rename to lib/api/request_cache.dart index df88c25..8d6fdb6 100644 --- a/lib/api/requestCache.dart +++ b/lib/api/request_cache.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:localstore/localstore.dart'; -import 'apiResponse.dart'; +import 'api_response.dart'; abstract class RequestCache { static const int cacheNothing = 0; @@ -50,12 +50,13 @@ abstract class RequestCache { try { final tableData = await Localstore.instance.collection(collection).doc(document).get(); if (tableData != null) { - final cached = onLocalData(tableData['json']); + final cached = onLocalData(tableData['json'] as String); onUpdate?.call(cached); onCacheData?.call(cached); } - if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < (tableData?['lastupdate'] ?? 0)) { + final lastUpdate = (tableData?['lastupdate'] as num?) ?? 0; + if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < lastUpdate) { if (renew == null || !renew!) return; } @@ -63,10 +64,10 @@ abstract class RequestCache { final newValue = await onLoad(); onUpdate?.call(newValue); onNetworkData?.call(newValue); - Localstore.instance.collection(collection).doc(document).set({ + unawaited(Localstore.instance.collection(collection).doc(document).set({ 'json': jsonEncode(newValue), 'lastupdate': DateTime.now().millisecondsSinceEpoch, - }); + })); } on Exception catch (e) { onError(e); } @@ -112,5 +113,5 @@ class SimpleCache extends RequestCache { Future onLoad() => _loader(); @override - T onLocalData(String json) => _fromJson(jsonDecode(json)); + T onLocalData(String json) => _fromJson(jsonDecode(json) as Map); } diff --git a/lib/api/webuntis/queries/authenticate/authenticate.dart b/lib/api/webuntis/queries/authenticate/authenticate.dart index 5f49dc2..551d815 100644 --- a/lib/api/webuntis/queries/authenticate/authenticate.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate.dart @@ -2,9 +2,9 @@ import 'dart:async'; import 'dart:convert'; import '../../../../model/account_data.dart'; -import '../../webuntisApi.dart'; -import 'authenticateParams.dart'; -import 'authenticateResponse.dart'; +import '../../webuntis_api.dart'; +import 'authenticate_params.dart'; +import 'authenticate_response.dart'; class Authenticate extends WebuntisApi { AuthenticateParams param; @@ -15,18 +15,19 @@ class Authenticate extends WebuntisApi { Future run() async { awaitingResponse = true; try { - var rawAnswer = await query(this); - AuthenticateResponse response = finalize(AuthenticateResponse.fromJson(jsonDecode(rawAnswer)['result'])); + final rawAnswer = await query(this); + final decoded = jsonDecode(rawAnswer) as Map; + final response = finalize(AuthenticateResponse.fromJson(decoded['result'] as Map)); _lastResponse = response; - if(!awaitedResponse.isCompleted) awaitedResponse.complete(); + if (!awaitedResponse.isCompleted) awaitedResponse.complete(); return response; } catch (e) { // Surface the error to anyone waiting on the current completer, then // install a fresh one so a future attempt can succeed. Without this, // any later call to getSession() would hang forever on a completer // that is already settled with no listeners (or never settles at all). - if(!awaitedResponse.isCompleted) awaitedResponse.completeError(e); - awaitedResponse = Completer(); + if (!awaitedResponse.isCompleted) awaitedResponse.completeError(e); + awaitedResponse = Completer(); rethrow; } finally { awaitingResponse = false; @@ -34,7 +35,7 @@ class Authenticate extends WebuntisApi { } static bool awaitingResponse = false; - static Completer awaitedResponse = Completer(); + static Completer awaitedResponse = Completer(); static AuthenticateResponse? _lastResponse; static Future createSession() async { diff --git a/lib/api/webuntis/queries/authenticate/authenticateParams.dart b/lib/api/webuntis/queries/authenticate/authenticate_params.dart similarity index 85% rename from lib/api/webuntis/queries/authenticate/authenticateParams.dart rename to lib/api/webuntis/queries/authenticate/authenticate_params.dart index bfa65e6..bf3b23e 100644 --- a/lib/api/webuntis/queries/authenticate/authenticateParams.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'authenticateParams.g.dart'; +part 'authenticate_params.g.dart'; @JsonSerializable() class AuthenticateParams extends ApiParams { diff --git a/lib/api/webuntis/queries/authenticate/authenticateParams.g.dart b/lib/api/webuntis/queries/authenticate/authenticate_params.g.dart similarity index 94% rename from lib/api/webuntis/queries/authenticate/authenticateParams.g.dart rename to lib/api/webuntis/queries/authenticate/authenticate_params.g.dart index ba8b65e..9765ad8 100644 --- a/lib/api/webuntis/queries/authenticate/authenticateParams.g.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'authenticateParams.dart'; +part of 'authenticate_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/authenticate/authenticateResponse.dart b/lib/api/webuntis/queries/authenticate/authenticate_response.dart similarity index 86% rename from lib/api/webuntis/queries/authenticate/authenticateResponse.dart rename to lib/api/webuntis/queries/authenticate/authenticate_response.dart index 509b1dc..0ca87db 100644 --- a/lib/api/webuntis/queries/authenticate/authenticateResponse.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'authenticateResponse.g.dart'; +part 'authenticate_response.g.dart'; @JsonSerializable() class AuthenticateResponse extends ApiResponse { diff --git a/lib/api/webuntis/queries/authenticate/authenticateResponse.g.dart b/lib/api/webuntis/queries/authenticate/authenticate_response.g.dart similarity index 96% rename from lib/api/webuntis/queries/authenticate/authenticateResponse.g.dart rename to lib/api/webuntis/queries/authenticate/authenticate_response.g.dart index c3ca62a..e7d7dd0 100644 --- a/lib/api/webuntis/queries/authenticate/authenticateResponse.g.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'authenticateResponse.dart'; +part of 'authenticate_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/getHolidays/getHolidays.dart b/lib/api/webuntis/queries/get_holidays/get_holidays.dart similarity index 82% rename from lib/api/webuntis/queries/getHolidays/getHolidays.dart rename to lib/api/webuntis/queries/get_holidays/get_holidays.dart index 145cb6e..68031ec 100644 --- a/lib/api/webuntis/queries/getHolidays/getHolidays.dart +++ b/lib/api/webuntis/queries/get_holidays/get_holidays.dart @@ -1,15 +1,15 @@ import 'dart:convert'; -import '../../webuntisApi.dart'; -import 'getHolidaysResponse.dart'; +import '../../webuntis_api.dart'; +import 'get_holidays_response.dart'; class GetHolidays extends WebuntisApi { GetHolidays() : super('getHolidays', null); @override Future run() async { - var rawAnswer = await query(this); - return finalize(GetHolidaysResponse.fromJson(jsonDecode(rawAnswer))); + final rawAnswer = await query(this); + return finalize(GetHolidaysResponse.fromJson(jsonDecode(rawAnswer) as Map)); } static GetHolidaysResponseObject? find(GetHolidaysResponse holidaysResponse, {DateTime? time}) { diff --git a/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart b/lib/api/webuntis/queries/get_holidays/get_holidays_cache.dart similarity index 76% rename from lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart rename to lib/api/webuntis/queries/get_holidays/get_holidays_cache.dart index d6a2ff4..a974eb1 100644 --- a/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart +++ b/lib/api/webuntis/queries/get_holidays/get_holidays_cache.dart @@ -1,6 +1,6 @@ -import '../../../requestCache.dart'; -import 'getHolidays.dart'; -import 'getHolidaysResponse.dart'; +import '../../../request_cache.dart'; +import 'get_holidays.dart'; +import 'get_holidays_response.dart'; class GetHolidaysCache extends SimpleCache { GetHolidaysCache({super.onUpdate, super.onError, super.renew}) diff --git a/lib/api/webuntis/queries/getHolidays/getHolidaysResponse.dart b/lib/api/webuntis/queries/get_holidays/get_holidays_response.dart similarity index 91% rename from lib/api/webuntis/queries/getHolidays/getHolidaysResponse.dart rename to lib/api/webuntis/queries/get_holidays/get_holidays_response.dart index f087c4a..8fa2624 100644 --- a/lib/api/webuntis/queries/getHolidays/getHolidaysResponse.dart +++ b/lib/api/webuntis/queries/get_holidays/get_holidays_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getHolidaysResponse.g.dart'; +part 'get_holidays_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetHolidaysResponse extends ApiResponse { diff --git a/lib/api/webuntis/queries/getHolidays/getHolidaysResponse.g.dart b/lib/api/webuntis/queries/get_holidays/get_holidays_response.g.dart similarity index 97% rename from lib/api/webuntis/queries/getHolidays/getHolidaysResponse.g.dart rename to lib/api/webuntis/queries/get_holidays/get_holidays_response.g.dart index 323b3fb..e373642 100644 --- a/lib/api/webuntis/queries/getHolidays/getHolidaysResponse.g.dart +++ b/lib/api/webuntis/queries/get_holidays/get_holidays_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getHolidaysResponse.dart'; +part of 'get_holidays_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/getRooms/getRooms.dart b/lib/api/webuntis/queries/get_rooms/get_rooms.dart similarity index 72% rename from lib/api/webuntis/queries/getRooms/getRooms.dart rename to lib/api/webuntis/queries/get_rooms/get_rooms.dart index 45c53c5..4b7bf86 100644 --- a/lib/api/webuntis/queries/getRooms/getRooms.dart +++ b/lib/api/webuntis/queries/get_rooms/get_rooms.dart @@ -1,18 +1,18 @@ import 'dart:convert'; import 'dart:developer'; -import '../../webuntisApi.dart'; -import 'getRoomsResponse.dart'; +import '../../webuntis_api.dart'; +import 'get_rooms_response.dart'; class GetRooms extends WebuntisApi { GetRooms() : super('getRooms', null); @override Future run() async { - var rawAnswer = await query(this); + final rawAnswer = await query(this); try { - return finalize(GetRoomsResponse.fromJson(jsonDecode(rawAnswer))); - } catch(e, trace) { + return finalize(GetRoomsResponse.fromJson(jsonDecode(rawAnswer) as Map)); + } catch (e, trace) { log(trace.toString()); log('Failed to parse getRoom data with server response: $rawAnswer'); } diff --git a/lib/api/webuntis/queries/getRooms/getRoomsCache.dart b/lib/api/webuntis/queries/get_rooms/get_rooms_cache.dart similarity index 76% rename from lib/api/webuntis/queries/getRooms/getRoomsCache.dart rename to lib/api/webuntis/queries/get_rooms/get_rooms_cache.dart index 4f8e064..a07a449 100644 --- a/lib/api/webuntis/queries/getRooms/getRoomsCache.dart +++ b/lib/api/webuntis/queries/get_rooms/get_rooms_cache.dart @@ -1,6 +1,6 @@ -import '../../../requestCache.dart'; -import 'getRooms.dart'; -import 'getRoomsResponse.dart'; +import '../../../request_cache.dart'; +import 'get_rooms.dart'; +import 'get_rooms_response.dart'; class GetRoomsCache extends SimpleCache { GetRoomsCache({super.onUpdate, super.onError, super.renew}) diff --git a/lib/api/webuntis/queries/getRooms/getRoomsResponse.dart b/lib/api/webuntis/queries/get_rooms/get_rooms_response.dart similarity index 91% rename from lib/api/webuntis/queries/getRooms/getRoomsResponse.dart rename to lib/api/webuntis/queries/get_rooms/get_rooms_response.dart index fe4dc84..614406d 100644 --- a/lib/api/webuntis/queries/getRooms/getRoomsResponse.dart +++ b/lib/api/webuntis/queries/get_rooms/get_rooms_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getRoomsResponse.g.dart'; +part 'get_rooms_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetRoomsResponse extends ApiResponse { diff --git a/lib/api/webuntis/queries/getRooms/getRoomsResponse.g.dart b/lib/api/webuntis/queries/get_rooms/get_rooms_response.g.dart similarity index 97% rename from lib/api/webuntis/queries/getRooms/getRoomsResponse.g.dart rename to lib/api/webuntis/queries/get_rooms/get_rooms_response.g.dart index a88ade3..2bf9998 100644 --- a/lib/api/webuntis/queries/getRooms/getRoomsResponse.g.dart +++ b/lib/api/webuntis/queries/get_rooms/get_rooms_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getRoomsResponse.dart'; +part of 'get_rooms_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/getSubjects/getSubjects.dart b/lib/api/webuntis/queries/get_subjects/get_subjects.dart similarity index 61% rename from lib/api/webuntis/queries/getSubjects/getSubjects.dart rename to lib/api/webuntis/queries/get_subjects/get_subjects.dart index 8505381..75a4f1b 100644 --- a/lib/api/webuntis/queries/getSubjects/getSubjects.dart +++ b/lib/api/webuntis/queries/get_subjects/get_subjects.dart @@ -1,14 +1,14 @@ import 'dart:convert'; -import '../../webuntisApi.dart'; -import 'getSubjectsResponse.dart'; +import '../../webuntis_api.dart'; +import 'get_subjects_response.dart'; class GetSubjects extends WebuntisApi { GetSubjects() : super('getSubjects', null); @override Future run() async { - var rawAnswer = await query(this); - return finalize(GetSubjectsResponse.fromJson(jsonDecode(rawAnswer))); + final rawAnswer = await query(this); + return finalize(GetSubjectsResponse.fromJson(jsonDecode(rawAnswer) as Map)); } } diff --git a/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart b/lib/api/webuntis/queries/get_subjects/get_subjects_cache.dart similarity index 76% rename from lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart rename to lib/api/webuntis/queries/get_subjects/get_subjects_cache.dart index 5eeb8d3..c513054 100644 --- a/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart +++ b/lib/api/webuntis/queries/get_subjects/get_subjects_cache.dart @@ -1,6 +1,6 @@ -import '../../../requestCache.dart'; -import 'getSubjects.dart'; -import 'getSubjectsResponse.dart'; +import '../../../request_cache.dart'; +import 'get_subjects.dart'; +import 'get_subjects_response.dart'; class GetSubjectsCache extends SimpleCache { GetSubjectsCache({super.onUpdate, super.onError, super.renew}) diff --git a/lib/api/webuntis/queries/getSubjects/getSubjectsResponse.dart b/lib/api/webuntis/queries/get_subjects/get_subjects_response.dart similarity index 92% rename from lib/api/webuntis/queries/getSubjects/getSubjectsResponse.dart rename to lib/api/webuntis/queries/get_subjects/get_subjects_response.dart index cfd2cf1..255b5ad 100644 --- a/lib/api/webuntis/queries/getSubjects/getSubjectsResponse.dart +++ b/lib/api/webuntis/queries/get_subjects/get_subjects_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getSubjectsResponse.g.dart'; +part 'get_subjects_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetSubjectsResponse extends ApiResponse { diff --git a/lib/api/webuntis/queries/getSubjects/getSubjectsResponse.g.dart b/lib/api/webuntis/queries/get_subjects/get_subjects_response.g.dart similarity index 97% rename from lib/api/webuntis/queries/getSubjects/getSubjectsResponse.g.dart rename to lib/api/webuntis/queries/get_subjects/get_subjects_response.g.dart index 35bbb8b..9ce84d5 100644 --- a/lib/api/webuntis/queries/getSubjects/getSubjectsResponse.g.dart +++ b/lib/api/webuntis/queries/get_subjects/get_subjects_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getSubjectsResponse.dart'; +part of 'get_subjects_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnits.dart b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart similarity index 76% rename from lib/api/webuntis/queries/getTimegridUnits/getTimegridUnits.dart rename to lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart index 0e9c38f..9f910e1 100644 --- a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnits.dart +++ b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart @@ -1,17 +1,17 @@ import 'dart:convert'; import 'dart:developer'; -import '../../webuntisApi.dart'; -import 'getTimegridUnitsResponse.dart'; +import '../../webuntis_api.dart'; +import 'get_timegrid_units_response.dart'; class GetTimegridUnits extends WebuntisApi { GetTimegridUnits() : super('getTimegridUnits', null); @override Future run() async { - var rawAnswer = await query(this); + final rawAnswer = await query(this); try { - return finalize(GetTimegridUnitsResponse.fromJson(jsonDecode(rawAnswer))); + return finalize(GetTimegridUnitsResponse.fromJson(jsonDecode(rawAnswer) as Map)); } catch (e, trace) { log(trace.toString()); log('Failed to parse getTimegridUnits data with server response: $rawAnswer'); diff --git a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart similarity index 74% rename from lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart rename to lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart index 200aa9c..811ed86 100644 --- a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart +++ b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart @@ -1,6 +1,6 @@ -import '../../../requestCache.dart'; -import 'getTimegridUnits.dart'; -import 'getTimegridUnitsResponse.dart'; +import '../../../request_cache.dart'; +import 'get_timegrid_units.dart'; +import 'get_timegrid_units_response.dart'; class GetTimegridUnitsCache extends SimpleCache { GetTimegridUnitsCache({super.onUpdate, super.renew}) diff --git a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart similarity index 93% rename from lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart rename to lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart index a730567..5b458aa 100644 --- a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart +++ b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getTimegridUnitsResponse.g.dart'; +part 'get_timegrid_units_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetTimegridUnitsResponse extends ApiResponse { diff --git a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.g.dart b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.g.dart similarity index 97% rename from lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.g.dart rename to lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.g.dart index b6fc909..250b0fd 100644 --- a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.g.dart +++ b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getTimegridUnitsResponse.dart'; +part of 'get_timegrid_units_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/getTimetable/getTimetable.dart b/lib/api/webuntis/queries/get_timetable/get_timetable.dart similarity index 60% rename from lib/api/webuntis/queries/getTimetable/getTimetable.dart rename to lib/api/webuntis/queries/get_timetable/get_timetable.dart index e9da26d..d451d3c 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetable.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable.dart @@ -1,8 +1,8 @@ import 'dart:convert'; -import '../../webuntisApi.dart'; -import 'getTimetableParams.dart'; -import 'getTimetableResponse.dart'; +import '../../webuntis_api.dart'; +import 'get_timetable_params.dart'; +import 'get_timetable_response.dart'; class GetTimetable extends WebuntisApi { GetTimetableParams params; @@ -11,8 +11,8 @@ class GetTimetable extends WebuntisApi { @override Future run() async { - var rawAnswer = await query(this); - return finalize(GetTimetableResponse.fromJson(jsonDecode(rawAnswer))); + final rawAnswer = await query(this); + return finalize(GetTimetableResponse.fromJson(jsonDecode(rawAnswer) as Map)); } } diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_cache.dart similarity index 90% rename from lib/api/webuntis/queries/getTimetable/getTimetableCache.dart rename to lib/api/webuntis/queries/get_timetable/get_timetable_cache.dart index 8a8cd7e..56a73c9 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable_cache.dart @@ -1,8 +1,8 @@ -import '../../../requestCache.dart'; +import '../../../request_cache.dart'; import '../authenticate/authenticate.dart'; -import 'getTimetable.dart'; -import 'getTimetableParams.dart'; -import 'getTimetableResponse.dart'; +import 'get_timetable.dart'; +import 'get_timetable_params.dart'; +import 'get_timetable_response.dart'; class GetTimetableCache extends SimpleCache { GetTimetableCache({ diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableParams.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_params.dart similarity index 97% rename from lib/api/webuntis/queries/getTimetable/getTimetableParams.dart rename to lib/api/webuntis/queries/get_timetable/get_timetable_params.dart index 48ba379..9286863 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetableParams.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'getTimetableParams.g.dart'; +part 'get_timetable_params.g.dart'; @JsonSerializable(explicitToJson: true) class GetTimetableParams extends ApiParams { diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableParams.g.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_params.g.dart similarity index 99% rename from lib/api/webuntis/queries/getTimetable/getTimetableParams.g.dart rename to lib/api/webuntis/queries/get_timetable/get_timetable_params.g.dart index 92e2a1d..2d7ff35 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetableParams.g.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getTimetableParams.dart'; +part of 'get_timetable_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableResponse.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_response.dart similarity index 98% rename from lib/api/webuntis/queries/getTimetable/getTimetableResponse.dart rename to lib/api/webuntis/queries/get_timetable/get_timetable_response.dart index fc6663c..05e1ea1 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetableResponse.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getTimetableResponse.g.dart'; +part 'get_timetable_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetTimetableResponse extends ApiResponse { diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableResponse.g.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_response.g.dart similarity index 99% rename from lib/api/webuntis/queries/getTimetable/getTimetableResponse.g.dart rename to lib/api/webuntis/queries/get_timetable/get_timetable_response.g.dart index effce44..9a3b20b 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetableResponse.g.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getTimetableResponse.dart'; +part of 'get_timetable_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/webuntisApi.dart b/lib/api/webuntis/webuntis_api.dart similarity index 68% rename from lib/api/webuntis/webuntisApi.dart rename to lib/api/webuntis/webuntis_api.dart index 846c1e6..690bf94 100644 --- a/lib/api/webuntis/webuntisApi.dart +++ b/lib/api/webuntis/webuntis_api.dart @@ -5,13 +5,13 @@ import 'dart:io'; import 'package:http/http.dart' as http; import '../../model/endpoint_data.dart'; -import '../apiParams.dart'; -import '../apiRequest.dart'; -import '../apiResponse.dart'; +import '../api_params.dart'; +import '../api_request.dart'; +import '../api_response.dart'; import '../errors/network_exception.dart'; import '../errors/parse_exception.dart'; import 'queries/authenticate/authenticate.dart'; -import 'webuntisError.dart'; +import 'webuntis_error.dart'; abstract class WebuntisApi extends ApiRequest { Uri endpoint = Uri.parse('https://${EndpointData().webuntis().full()}/WebUntis/jsonrpc.do?school=marianum-fulda'); @@ -25,34 +25,36 @@ abstract class WebuntisApi extends ApiRequest { Future query(WebuntisApi untis, {bool retry = false}) async { - var query = '{"id":"ID","method":"$method","params":${untis._body()},"jsonrpc":"2.0"}'; + final body = '{"id":"ID","method":"$method","params":${untis._body()},"jsonrpc":"2.0"}'; var sessionId = '0'; - if(authenticatedResponse) { + if (authenticatedResponse) { sessionId = (await Authenticate.getSession()).sessionId; } - var data = await post(query, {'Cookie': 'JSESSIONID=$sessionId'}); + final data = await post(body, {'Cookie': 'JSESSIONID=$sessionId'}); response = data; - final dynamic jsonData; + final Map jsonData; try { - jsonData = jsonDecode(data.body); + jsonData = jsonDecode(data.body) as Map; } on FormatException catch (e) { throw ParseException(technicalDetails: 'WebUntis JSON decode: ${e.message}'); } - if(jsonData['error'] != null) { - if(jsonData['error']['code'] == -8520) { - if(retry) throw WebuntisError('Authentication was tried (probably session timeout), but was not successful!', -8520); + final error = jsonData['error'] as Map?; + if (error != null) { + final code = error['code'] as int; + if (code == -8520) { + if (retry) throw WebuntisError('Authentication was tried (probably session timeout), but was not successful!', -8520); await Authenticate.createSession(); - return await this.query(untis, retry: true); + return query(untis, retry: true); } else { - throw WebuntisError(jsonData['error']['message'], jsonData['error']['code']); + throw WebuntisError(error['message'] as String, code); } } return data.body; } - dynamic finalize(dynamic response) { + T finalize(T response) { response.rawResponse = this.response!; return response; } diff --git a/lib/api/webuntis/webuntisError.dart b/lib/api/webuntis/webuntis_error.dart similarity index 100% rename from lib/api/webuntis/webuntisError.dart rename to lib/api/webuntis/webuntis_error.dart diff --git a/lib/app.dart b/lib/app.dart index e483fb6..5ac8709 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,25 +1,25 @@ import 'dart:async'; import 'dart:developer'; -import 'package:easy_debounce/easy_throttle.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; -import 'api/mhsl/server/userIndex/update/updateUserindex.dart'; +import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart'; +import 'api/mhsl/server/user_index/update/update_userindex.dart'; import 'main.dart'; -import 'widget/breaker/breaker.dart'; import 'model/data_cleaner.dart'; import 'notification/notification_controller.dart'; import 'notification/notification_tasks.dart'; import 'notification/notify_updater.dart'; import 'state/app/modules/app_modules.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; -import 'state/app/modules/chatList/bloc/chat_list_bloc.dart'; +import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; +import 'utils/debouncer.dart'; import 'view/pages/overhang.dart'; +import 'widget/breaker/breaker.dart'; class App extends StatefulWidget { const App({super.key}); @@ -36,7 +36,7 @@ class _AppState extends State with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { log('AppLifecycle: $state'); if (state == AppLifecycleState.resumed) { - EasyThrottle.throttle('appLifecycleState', const Duration(seconds: 10), () { + Debouncer.throttle('appLifecycleState', const Duration(seconds: 10), () { if (!mounted) return; log('Refreshing due to LifecycleChange'); NotificationTasks.updateProviders(context); diff --git a/lib/main.dart b/lib/main.dart index 411a05c..a8f88c7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,16 +16,15 @@ import 'package:loader_overlay/loader_overlay.dart'; import 'package:path_provider/path_provider.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; +import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart'; import 'app.dart'; import 'firebase_options.dart'; import 'model/account_data.dart'; -import 'widget/breaker/breaker.dart'; import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_state.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; import 'state/app/modules/chat/bloc/chat_bloc.dart'; -import 'state/app/modules/chatList/bloc/chat_list_bloc.dart'; +import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; import 'storage/settings.dart'; @@ -33,6 +32,7 @@ import 'theming/dark_app_theme.dart'; import 'theming/light_app_theme.dart'; import 'view/login/login.dart'; import 'widget/app_progress_indicator.dart'; +import 'widget/breaker/breaker.dart'; Future main() async { log('MarianumMobile started'); diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart index 66db8c8..712301a 100644 --- a/lib/model/account_data.dart +++ b/lib/model/account_data.dart @@ -15,9 +15,7 @@ class AccountData { static const _usernameField = 'username'; static const _passwordField = 'password'; - static const FlutterSecureStorage _secureStorage = FlutterSecureStorage( - aOptions: AndroidOptions(encryptedSharedPreferences: true), - ); + static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(); static final AccountData _instance = AccountData._construct(); Completer _populated = Completer(); diff --git a/lib/model/data_cleaner.dart b/lib/model/data_cleaner.dart index f24fb04..4094023 100644 --- a/lib/model/data_cleaner.dart +++ b/lib/model/data_cleaner.dart @@ -1,13 +1,13 @@ import 'package:localstore/localstore.dart'; -import '../api/requestCache.dart'; +import '../api/request_cache.dart'; class DataCleaner { static Future cleanOldCache() async { - var cacheData = await Localstore.instance.collection(RequestCache.collection).get(); + final cacheData = await Localstore.instance.collection(RequestCache.collection).get(); cacheData?.forEach((key, value) async { - var lastUpdate = DateTime.fromMillisecondsSinceEpoch(value['lastupdate']); - if(DateTime.now().subtract(const Duration(days: 200)).isAfter(lastUpdate)) { + final lastUpdate = DateTime.fromMillisecondsSinceEpoch(value['lastupdate'] as int); + if (DateTime.now().subtract(const Duration(days: 200)).isAfter(lastUpdate)) { await Localstore.instance.collection(RequestCache.collection).doc(key.split('/').last).delete(); } }); diff --git a/lib/notification/notification_controller.dart b/lib/notification/notification_controller.dart index 0a1631f..a9de28b 100644 --- a/lib/notification/notification_controller.dart +++ b/lib/notification/notification_controller.dart @@ -6,36 +6,10 @@ import '../widget/debug/json_viewer.dart'; import 'notification_tasks.dart'; class NotificationController { + // Notification display is handled by the Firebase SDK using server-generated payloads. @pragma('vm:entry-point') static Future onBackgroundMessageHandler(RemoteMessage message) async { NotificationTasks.updateBadgeCount(message); - return; // Displaying the notification is curently done via the Firebase SDK itself. The Message is server-generated. - - // - // await Firebase.initializeApp(); - // AccountData().waitForPopulation().then((value) { - // log("User account status: $value"); - // if(value) { - // GetRoom( - // GetRoomParams( - // includeStatus: false, - // ), - // ).run().then((value) { - // var messageCount = value.data.map((e) => e.unreadMessages).reduce((a, b) => a + b); - // var chatCount = value.data.where((e) => e.unreadMessages > 0).length; - // var people = value.data.where((e) => e.unreadMessages > 0).map((e) => e.displayName.split(" ")[0]); - // - // final NotificationService service = NotificationService(); - // service.initializeNotifications().then((value) { - // service.showNotification( - // title: "Du hast $messageCount ungelesene Nachrichten!", - // body: "In $chatCount Chats, von ${people.join(", ")}", - // badgeCount: messageCount, - // ); - // }); - // }); - // } - // }); } static Future onForegroundMessageHandler(RemoteMessage message, BuildContext context) async { diff --git a/lib/notification/notification_tasks.dart b/lib/notification/notification_tasks.dart index e5e43ff..42c310e 100644 --- a/lib/notification/notification_tasks.dart +++ b/lib/notification/notification_tasks.dart @@ -5,11 +5,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../routing/app_routes.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart'; -import '../state/app/modules/chatList/bloc/chat_list_bloc.dart'; +import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; class NotificationTasks { static void updateBadgeCount(RemoteMessage notification) { - FlutterAppBadge.count(int.parse(notification.data['unreadCount'] ?? '0')); + FlutterAppBadge.count(int.parse((notification.data['unreadCount'] as String?) ?? '0')); } static void updateProviders(BuildContext context) { diff --git a/lib/notification/notify_updater.dart b/lib/notification/notify_updater.dart index 584b9f7..dc3ae15 100644 --- a/lib/notification/notify_updater.dart +++ b/lib/notification/notify_updater.dart @@ -1,8 +1,10 @@ +import 'dart:async'; + import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; -import '../api/mhsl/notify/register/notifyRegister.dart'; -import '../api/mhsl/notify/register/notifyRegisterParams.dart'; +import '../api/mhsl/notify/register/notify_register.dart'; +import '../api/mhsl/notify/register/notify_register_params.dart'; import '../model/account_data.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart'; import '../widget/confirm_dialog.dart'; @@ -17,9 +19,9 @@ class NotifyUpdater { 'Für mehr Informationen drücke lange auf die Einstellungsoption!', confirmButton: 'Aktivieren', onConfirm: () { - FirebaseMessaging.instance.requestPermission(provisional: false); + unawaited(FirebaseMessaging.instance.requestPermission(provisional: false)); settings.val(write: true).notificationSettings.enabled = true; - NotifyUpdater.registerToServer(); + unawaited(NotifyUpdater.registerToServer()); }, ); @@ -29,12 +31,12 @@ class NotifyUpdater { throw Exception('Failed to register push notification because there is no FBC token!'); } - NotifyRegister( + unawaited(NotifyRegister( NotifyRegisterParams( username: AccountData().getUsername(), password: AccountData().getPassword(), fcmToken: fcmToken, ), - ).run(); + ).run()); } } diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 94b85d9..082d5ac 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -3,23 +3,23 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import '../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../api/marianumcloud/talk/room/get_room_response.dart'; import '../main.dart'; import '../model/account_data.dart'; import '../state/app/modules/app_modules.dart'; -import '../state/app/modules/chatList/bloc/chat_list_bloc.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart'; -import '../state/app/modules/marianumMessage/bloc/marianum_message_state.dart'; +import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import '../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; import '../view/pages/files/files.dart'; import '../view/pages/marianum_message/marianum_message_view.dart'; import '../view/pages/more/feedback/feedback_dialog.dart'; import '../view/pages/more/roomplan/roomplan.dart'; import '../view/pages/more/share/qr_share_view.dart'; +import '../view/pages/settings/settings.dart'; import '../view/pages/talk/chat_view.dart'; import '../view/pages/talk/details/message_reactions.dart'; import '../view/pages/talk/talk_navigator.dart'; import '../view/pages/timetable/custom_events/custom_events_view.dart'; -import '../view/pages/settings/settings.dart'; import '../widget/debug/cache_view.dart'; import '../widget/file_viewer.dart'; import '../widget/user_avatar.dart'; diff --git a/lib/state/app/basis/dataloader/holiday_data_loader.dart b/lib/state/app/basis/dataloader/holiday_data_loader.dart index 19c345f..e384f00 100644 --- a/lib/state/app/basis/dataloader/holiday_data_loader.dart +++ b/lib/state/app/basis/dataloader/holiday_data_loader.dart @@ -1,6 +1,6 @@ import 'package:dio/dio.dart'; -import '../../infrastructure/dataLoader/data_loader.dart'; +import '../../infrastructure/data_loader/data_loader.dart'; abstract class HolidayDataLoader extends DataLoader { HolidayDataLoader() : super(Dio(BaseOptions( diff --git a/lib/state/app/basis/dataloader/mhsl_data_loader.dart b/lib/state/app/basis/dataloader/mhsl_data_loader.dart index 6b4baab..fa29cf4 100644 --- a/lib/state/app/basis/dataloader/mhsl_data_loader.dart +++ b/lib/state/app/basis/dataloader/mhsl_data_loader.dart @@ -1,6 +1,6 @@ import 'package:dio/dio.dart'; -import '../../infrastructure/dataLoader/data_loader.dart'; +import '../../infrastructure/data_loader/data_loader.dart'; abstract class MhslDataLoader extends DataLoader { MhslDataLoader() : super(Dio(BaseOptions( diff --git a/lib/state/app/infrastructure/dataLoader/data_loader.dart b/lib/state/app/infrastructure/data_loader/data_loader.dart similarity index 81% rename from lib/state/app/infrastructure/dataLoader/data_loader.dart rename to lib/state/app/infrastructure/data_loader/data_loader.dart index 64c1aa7..ceec932 100644 --- a/lib/state/app/infrastructure/dataLoader/data_loader.dart +++ b/lib/state/app/infrastructure/data_loader/data_loader.dart @@ -6,9 +6,9 @@ import 'package:dio/dio.dart'; abstract class DataLoader { final Dio dio; DataLoader(this.dio) { - dio.options.connectTimeout = const Duration(seconds: 10).inMilliseconds; - dio.options.sendTimeout = const Duration(seconds: 30).inMilliseconds; - dio.options.receiveTimeout = const Duration(seconds: 30).inMilliseconds; + dio.options.connectTimeout = const Duration(seconds: 10); + dio.options.sendTimeout = const Duration(seconds: 30); + dio.options.receiveTimeout = const Duration(seconds: 30); } Future run() async { @@ -26,7 +26,7 @@ abstract class DataLoader { )); } catch(trace, e) { log(trace.toString()); - throw(e); + throw e; } } diff --git a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_bloc.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart similarity index 91% rename from lib/state/app/infrastructure/loadableState/bloc/loadable_state_bloc.dart rename to lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart index ef0e167..625e1dd 100644 --- a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_bloc.dart +++ b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart @@ -1,8 +1,8 @@ import 'dart:async'; -import 'package:bloc/bloc.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:jiffy/jiffy.dart'; import 'loadable_state_event.dart'; @@ -21,7 +21,7 @@ class LoadableStateBloc extends Bloc { } }); - emitConnectivity(List result) => add(ConnectivityChanged(LoadableStateState(connections: result))); + void emitConnectivity(List result) => add(ConnectivityChanged(LoadableStateState(connections: result))); Connectivity().checkConnectivity().then(emitConnectivity); _updateStream = Connectivity().onConnectivityChanged.listen(emitConnectivity); diff --git a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_event.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/bloc/loadable_state_event.dart rename to lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart diff --git a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_state.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_state.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/bloc/loadable_state_state.dart rename to lib/state/app/infrastructure/loadable_state/bloc/loadable_state_state.dart diff --git a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_state.freezed.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_state.freezed.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/bloc/loadable_state_state.freezed.dart rename to lib/state/app/infrastructure/loadable_state/bloc/loadable_state_state.freezed.dart diff --git a/lib/state/app/infrastructure/loadableState/loadable_state.dart b/lib/state/app/infrastructure/loadable_state/loadable_state.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/loadable_state.dart rename to lib/state/app/infrastructure/loadable_state/loadable_state.dart diff --git a/lib/state/app/infrastructure/loadableState/loadable_state.freezed.dart b/lib/state/app/infrastructure/loadable_state/loadable_state.freezed.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/loadable_state.freezed.dart rename to lib/state/app/infrastructure/loadable_state/loadable_state.freezed.dart diff --git a/lib/state/app/infrastructure/loadableState/loading_error.dart b/lib/state/app/infrastructure/loadable_state/loading_error.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/loading_error.dart rename to lib/state/app/infrastructure/loadable_state/loading_error.dart diff --git a/lib/state/app/infrastructure/loadableState/loading_error.freezed.dart b/lib/state/app/infrastructure/loadable_state/loading_error.freezed.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/loading_error.freezed.dart rename to lib/state/app/infrastructure/loadable_state/loading_error.freezed.dart diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_background_loading.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_background_loading.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/view/loadable_state_background_loading.dart rename to lib/state/app/infrastructure/loadable_state/view/loadable_state_background_loading.dart diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart similarity index 95% rename from lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart rename to lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart index cce2287..8867571 100644 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart @@ -1,10 +1,9 @@ -import 'package:bloc/bloc.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../../widget/conditional_wrapper.dart'; -import '../../utilityWidgets/bloc_module.dart'; -import '../../utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../utility_widgets/bloc_module.dart'; +import '../../utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../bloc/loadable_state_bloc.dart'; import '../bloc/loadable_state_state.dart'; import '../loadable_state.dart'; diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_error_bar.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/view/loadable_state_error_bar.dart rename to lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_error_screen.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart similarity index 97% rename from lib/state/app/infrastructure/loadableState/view/loadable_state_error_screen.dart rename to lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart index 4d118bc..e56eb29 100644 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_error_screen.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../../widget/info_dialog.dart'; import '../bloc/loadable_state_bloc.dart'; diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_primary_loading.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/view/loadable_state_primary_loading.dart rename to lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart diff --git a/lib/state/app/infrastructure/utilityWidgets/bloc_module.dart b/lib/state/app/infrastructure/utility_widgets/bloc_module.dart similarity index 100% rename from lib/state/app/infrastructure/utilityWidgets/bloc_module.dart rename to lib/state/app/infrastructure/utility_widgets/bloc_module.dart diff --git a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart similarity index 95% rename from lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart rename to lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart index b6807b5..f241431 100644 --- a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart @@ -3,10 +3,10 @@ import 'dart:developer'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../api/errors/error_mapper.dart'; -import '../../loadableState/loading_error.dart'; +import '../../loadable_state/loadable_state.dart'; +import '../../loadable_state/loading_error.dart'; import '../../repository/repository.dart'; import 'loadable_hydrated_bloc_event.dart'; -import '../../loadableState/loadable_state.dart'; import 'loadable_save_context.dart'; abstract class LoadableHydratedBloc< @@ -90,7 +90,7 @@ abstract class LoadableHydratedBloc< } @override - fromJson(Map json) { + LoadableState fromJson(Map json) { var rawData = LoadableSaveContext.unwrap(json); return LoadableState( isLoading: true, diff --git a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart similarity index 91% rename from lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart rename to lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart index 55b8e6a..1485c60 100644 --- a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart @@ -1,4 +1,4 @@ -import '../../loadableState/loading_error.dart'; +import '../../loadable_state/loading_error.dart'; class LoadableHydratedBlocEvent {} class Emit extends LoadableHydratedBlocEvent { diff --git a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.dart similarity index 93% rename from lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.dart rename to lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.dart index 095f2b6..8d7dc2d 100644 --- a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.dart @@ -19,5 +19,5 @@ abstract class LoadableSaveContext with _$LoadableSaveContext { {dataKey: data, metaKey: LoadableSaveContext(timestamp: lastFetch).toJson()}; static ({Map data, LoadableSaveContext meta}) unwrap(Map data) => - (data: data[dataKey] as Map, meta: LoadableSaveContext.fromJson(data[metaKey])); + (data: data[dataKey] as Map, meta: LoadableSaveContext.fromJson(data[metaKey] as Map)); } diff --git a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.freezed.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.freezed.dart similarity index 100% rename from lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.freezed.dart rename to lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.freezed.dart diff --git a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.g.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.g.dart similarity index 100% rename from lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.g.dart rename to lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.g.dart diff --git a/lib/state/app/modules/app_modules.dart b/lib/state/app/modules/app_modules.dart index b93c698..1d960f0 100644 --- a/lib/state/app/modules/app_modules.dart +++ b/lib/state/app/modules/app_modules.dart @@ -1,10 +1,9 @@ -import 'package:flutter/material.dart'; -import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - import 'package:badges/badges.dart' as badges; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import '../../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; +import '../../../api/mhsl/breaker/get_breakers/get_breakers_response.dart'; import '../../../routing/app_routes.dart'; import '../../../view/pages/files/files.dart'; import '../../../view/pages/grade_averages/grade_averages_view.dart'; @@ -16,9 +15,9 @@ import '../../../view/pages/talk/chat_list.dart'; import '../../../view/pages/timetable/timetable.dart'; import '../../../widget/breaker/breaker.dart'; import '../../../widget/centered_leading.dart'; -import '../infrastructure/loadableState/loadable_state.dart'; -import 'chatList/bloc/chat_list_bloc.dart'; -import 'chatList/bloc/chat_list_state.dart'; +import '../infrastructure/loadable_state/loadable_state.dart'; +import 'chat_list/bloc/chat_list_bloc.dart'; +import 'chat_list/bloc/chat_list_state.dart'; import 'settings/bloc/settings_cubit.dart'; class AppModule { @@ -30,8 +29,8 @@ class AppModule { AppModule(this.module, {required this.name, required this.icon, this.breakerArea = BreakerArea.global, required this.create}); - static Map modules(BuildContext context, { showFiltered = false }) { - var settings = context.read(); + static Map modules(BuildContext context, {bool showFiltered = false}) { + final settings = context.read(); var available = { Modules.timetable: AppModule( Modules.timetable, @@ -109,7 +108,7 @@ class AppModule { ), }; - if(!showFiltered) available.removeWhere((key, value) => settings.val().modulesSettings.hiddenModules.contains(key)); + if (!showFiltered) available.removeWhere((key, value) => settings.val().modulesSettings.hiddenModules.contains(key)); return { for (var element in settings.val().modulesSettings.moduleOrder.where((element) => available.containsKey(element))) element : available[element]! }; } diff --git a/lib/state/app/modules/breaker/bloc/breaker_bloc.dart b/lib/state/app/modules/breaker/bloc/breaker_bloc.dart index 0381513..86bb6fc 100644 --- a/lib/state/app/modules/breaker/bloc/breaker_bloc.dart +++ b/lib/state/app/modules/breaker/bloc/breaker_bloc.dart @@ -1,9 +1,9 @@ import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import '../../../../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../../../api/mhsl/breaker/get_breakers/get_breakers_response.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 '../repository/breaker_repository.dart'; import 'breaker_event.dart'; import 'breaker_state.dart'; diff --git a/lib/state/app/modules/breaker/bloc/breaker_event.dart b/lib/state/app/modules/breaker/bloc/breaker_event.dart index 5c9ed7e..e5b6030 100644 --- a/lib/state/app/modules/breaker/bloc/breaker_event.dart +++ b/lib/state/app/modules/breaker/bloc/breaker_event.dart @@ -1,4 +1,4 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'breaker_state.dart'; sealed class BreakerEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/breaker/bloc/breaker_state.dart b/lib/state/app/modules/breaker/bloc/breaker_state.dart index 140cc45..60c1685 100644 --- a/lib/state/app/modules/breaker/bloc/breaker_state.dart +++ b/lib/state/app/modules/breaker/bloc/breaker_state.dart @@ -1,6 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import '../../../../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; +import '../../../../../api/mhsl/breaker/get_breakers/get_breakers_response.dart'; part 'breaker_state.freezed.dart'; part 'breaker_state.g.dart'; diff --git a/lib/state/app/modules/breaker/dataProvider/breaker_data_provider.dart b/lib/state/app/modules/breaker/data_provider/breaker_data_provider.dart similarity index 64% rename from lib/state/app/modules/breaker/dataProvider/breaker_data_provider.dart rename to lib/state/app/modules/breaker/data_provider/breaker_data_provider.dart index 5623e71..d07fa83 100644 --- a/lib/state/app/modules/breaker/dataProvider/breaker_data_provider.dart +++ b/lib/state/app/modules/breaker/data_provider/breaker_data_provider.dart @@ -1,7 +1,7 @@ import 'dart:async'; -import '../../../../../api/mhsl/breaker/getBreakers/getBreakersCache.dart'; -import '../../../../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; +import '../../../../../api/mhsl/breaker/get_breakers/get_breakers_cache.dart'; +import '../../../../../api/mhsl/breaker/get_breakers/get_breakers_response.dart'; class BreakerDataProvider { Future getBreakers() { diff --git a/lib/state/app/modules/breaker/repository/breaker_repository.dart b/lib/state/app/modules/breaker/repository/breaker_repository.dart index 42bb070..7bc37ac 100644 --- a/lib/state/app/modules/breaker/repository/breaker_repository.dart +++ b/lib/state/app/modules/breaker/repository/breaker_repository.dart @@ -1,6 +1,6 @@ import '../../../infrastructure/repository/repository.dart'; import '../bloc/breaker_state.dart'; -import '../dataProvider/breaker_data_provider.dart'; +import '../data_provider/breaker_data_provider.dart'; class BreakerRepository extends Repository { final BreakerDataProvider _provider; diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index 5dfb29a..ee1823f 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -1,8 +1,8 @@ import '../../../../../api/errors/error_mapper.dart'; -import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; -import '../../../infrastructure/loadableState/loading_error.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../infrastructure/loadable_state/loading_error.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 '../repository/chat_repository.dart'; import 'chat_event.dart'; import 'chat_state.dart'; diff --git a/lib/state/app/modules/chat/bloc/chat_event.dart b/lib/state/app/modules/chat/bloc/chat_event.dart index 460817d..015577f 100644 --- a/lib/state/app/modules/chat/bloc/chat_event.dart +++ b/lib/state/app/modules/chat/bloc/chat_event.dart @@ -1,4 +1,4 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'chat_state.dart'; sealed class ChatEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/chat/bloc/chat_state.dart b/lib/state/app/modules/chat/bloc/chat_state.dart index 221b84d..5145b5b 100644 --- a/lib/state/app/modules/chat/bloc/chat_state.dart +++ b/lib/state/app/modules/chat/bloc/chat_state.dart @@ -1,6 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; +import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; part 'chat_state.freezed.dart'; part 'chat_state.g.dart'; diff --git a/lib/state/app/modules/chat/dataProvider/chat_data_provider.dart b/lib/state/app/modules/chat/data_provider/chat_data_provider.dart similarity index 80% rename from lib/state/app/modules/chat/dataProvider/chat_data_provider.dart rename to lib/state/app/modules/chat/data_provider/chat_data_provider.dart index 25bdccc..8271b61 100644 --- a/lib/state/app/modules/chat/dataProvider/chat_data_provider.dart +++ b/lib/state/app/modules/chat/data_provider/chat_data_provider.dart @@ -1,5 +1,5 @@ -import '../../../../../api/marianumcloud/talk/chat/getChatCache.dart'; -import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; +import '../../../../../api/marianumcloud/talk/chat/get_chat_cache.dart'; +import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; class ChatDataProvider { Future getChat({ diff --git a/lib/state/app/modules/chat/repository/chat_repository.dart b/lib/state/app/modules/chat/repository/chat_repository.dart index 54e4356..38c3833 100644 --- a/lib/state/app/modules/chat/repository/chat_repository.dart +++ b/lib/state/app/modules/chat/repository/chat_repository.dart @@ -1,6 +1,6 @@ import '../../../infrastructure/repository/repository.dart'; import '../bloc/chat_state.dart'; -import '../dataProvider/chat_data_provider.dart'; +import '../data_provider/chat_data_provider.dart'; class ChatRepository extends Repository { final ChatDataProvider _provider; diff --git a/lib/state/app/modules/chatList/bloc/chat_list_bloc.dart b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart similarity index 77% rename from lib/state/app/modules/chatList/bloc/chat_list_bloc.dart rename to lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart index dd69801..b1687c5 100644 --- a/lib/state/app/modules/chatList/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart @@ -1,9 +1,12 @@ +import 'dart:developer'; + import 'package:flutter_app_badge/flutter_app_badge.dart'; import '../../../../../api/errors/error_mapper.dart'; -import '../../../infrastructure/loadableState/loading_error.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../infrastructure/loadable_state/loading_error.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 '../repository/chat_list_repository.dart'; import 'chat_list_event.dart'; import 'chat_list_state.dart'; @@ -72,10 +75,12 @@ class ChatListBloc extends LoadableHydratedBloc e.unreadMessages).fold(0, (a, b) => a + b as int); + final unread = rooms.data.fold(0, (a, room) => a + room.unreadMessages); FlutterAppBadge.count(unread); - } catch (_) {} + } on Object catch (e) { + log('Failed to update app badge: $e'); + } } } diff --git a/lib/state/app/modules/chatList/bloc/chat_list_event.dart b/lib/state/app/modules/chat_list/bloc/chat_list_event.dart similarity index 50% rename from lib/state/app/modules/chatList/bloc/chat_list_event.dart rename to lib/state/app/modules/chat_list/bloc/chat_list_event.dart index 614898d..302bb02 100644 --- a/lib/state/app/modules/chatList/bloc/chat_list_event.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_event.dart @@ -1,4 +1,4 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'chat_list_state.dart'; sealed class ChatListEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/chatList/bloc/chat_list_state.dart b/lib/state/app/modules/chat_list/bloc/chat_list_state.dart similarity index 83% rename from lib/state/app/modules/chatList/bloc/chat_list_state.dart rename to lib/state/app/modules/chat_list/bloc/chat_list_state.dart index 12ad303..25210cf 100644 --- a/lib/state/app/modules/chatList/bloc/chat_list_state.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_state.dart @@ -1,6 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import '../../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; part 'chat_list_state.freezed.dart'; part 'chat_list_state.g.dart'; diff --git a/lib/state/app/modules/chatList/bloc/chat_list_state.freezed.dart b/lib/state/app/modules/chat_list/bloc/chat_list_state.freezed.dart similarity index 100% rename from lib/state/app/modules/chatList/bloc/chat_list_state.freezed.dart rename to lib/state/app/modules/chat_list/bloc/chat_list_state.freezed.dart diff --git a/lib/state/app/modules/chatList/bloc/chat_list_state.g.dart b/lib/state/app/modules/chat_list/bloc/chat_list_state.g.dart similarity index 100% rename from lib/state/app/modules/chatList/bloc/chat_list_state.g.dart rename to lib/state/app/modules/chat_list/bloc/chat_list_state.g.dart diff --git a/lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart b/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart similarity index 67% rename from lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart rename to lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart index 3bf5c62..6549df4 100644 --- a/lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart +++ b/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart @@ -1,7 +1,7 @@ -import '../../../../../api/marianumcloud/talk/room/getRoomCache.dart'; -import '../../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; -import '../../../../../api/marianumcloud/talk/createRoom/createRoom.dart'; -import '../../../../../api/marianumcloud/talk/createRoom/createRoomParams.dart'; +import '../../../../../api/marianumcloud/talk/create_room/create_room.dart'; +import '../../../../../api/marianumcloud/talk/create_room/create_room_params.dart'; +import '../../../../../api/marianumcloud/talk/room/get_room_cache.dart'; +import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; class ChatListDataProvider { Future getRooms({ diff --git a/lib/state/app/modules/chatList/repository/chat_list_repository.dart b/lib/state/app/modules/chat_list/repository/chat_list_repository.dart similarity index 86% rename from lib/state/app/modules/chatList/repository/chat_list_repository.dart rename to lib/state/app/modules/chat_list/repository/chat_list_repository.dart index 5a10ce6..9589cb3 100644 --- a/lib/state/app/modules/chatList/repository/chat_list_repository.dart +++ b/lib/state/app/modules/chat_list/repository/chat_list_repository.dart @@ -1,6 +1,6 @@ import '../../../infrastructure/repository/repository.dart'; import '../bloc/chat_list_state.dart'; -import '../dataProvider/chat_list_data_provider.dart'; +import '../data_provider/chat_list_data_provider.dart'; class ChatListRepository extends Repository { final ChatListDataProvider _provider; diff --git a/lib/state/app/modules/files/bloc/files_bloc.dart b/lib/state/app/modules/files/bloc/files_bloc.dart index 3483819..fbe72e3 100644 --- a/lib/state/app/modules/files/bloc/files_bloc.dart +++ b/lib/state/app/modules/files/bloc/files_bloc.dart @@ -1,8 +1,8 @@ import '../../../../../api/errors/error_mapper.dart'; -import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart'; -import '../../../infrastructure/loadableState/loading_error.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; +import '../../../infrastructure/loadable_state/loading_error.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 '../repository/files_repository.dart'; import 'files_event.dart'; import 'files_state.dart'; diff --git a/lib/state/app/modules/files/bloc/files_event.dart b/lib/state/app/modules/files/bloc/files_event.dart index 5b6a3a1..2757b8b 100644 --- a/lib/state/app/modules/files/bloc/files_event.dart +++ b/lib/state/app/modules/files/bloc/files_event.dart @@ -1,4 +1,4 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'files_state.dart'; sealed class FilesEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/files/bloc/files_state.dart b/lib/state/app/modules/files/bloc/files_state.dart index d13b079..448241f 100644 --- a/lib/state/app/modules/files/bloc/files_state.dart +++ b/lib/state/app/modules/files/bloc/files_state.dart @@ -1,6 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart'; +import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; part 'files_state.freezed.dart'; part 'files_state.g.dart'; diff --git a/lib/state/app/modules/files/dataProvider/files_data_provider.dart b/lib/state/app/modules/files/data_provider/files_data_provider.dart similarity index 82% rename from lib/state/app/modules/files/dataProvider/files_data_provider.dart rename to lib/state/app/modules/files/data_provider/files_data_provider.dart index 8b1fef4..708cb29 100644 --- a/lib/state/app/modules/files/dataProvider/files_data_provider.dart +++ b/lib/state/app/modules/files/data_provider/files_data_provider.dart @@ -1,8 +1,8 @@ import 'package:nextcloud/nextcloud.dart'; -import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart'; -import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart'; -import '../../../../../api/marianumcloud/webdav/webdavApi.dart'; +import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; +import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; +import '../../../../../api/marianumcloud/webdav/webdav_api.dart'; class FilesDataProvider { /// Lists files at [path]. Cached payload is delivered via [onCacheData] as diff --git a/lib/state/app/modules/files/repository/files_repository.dart b/lib/state/app/modules/files/repository/files_repository.dart index 341734e..c7e129c 100644 --- a/lib/state/app/modules/files/repository/files_repository.dart +++ b/lib/state/app/modules/files/repository/files_repository.dart @@ -1,6 +1,6 @@ import '../../../infrastructure/repository/repository.dart'; import '../bloc/files_state.dart'; -import '../dataProvider/files_data_provider.dart'; +import '../data_provider/files_data_provider.dart'; class FilesRepository extends Repository { final FilesDataProvider _provider; diff --git a/lib/state/app/modules/gradeAverages/bloc/grade_averages_bloc.dart b/lib/state/app/modules/grade_averages/bloc/grade_averages_bloc.dart similarity index 100% rename from lib/state/app/modules/gradeAverages/bloc/grade_averages_bloc.dart rename to lib/state/app/modules/grade_averages/bloc/grade_averages_bloc.dart diff --git a/lib/state/app/modules/gradeAverages/bloc/grade_averages_event.dart b/lib/state/app/modules/grade_averages/bloc/grade_averages_event.dart similarity index 100% rename from lib/state/app/modules/gradeAverages/bloc/grade_averages_event.dart rename to lib/state/app/modules/grade_averages/bloc/grade_averages_event.dart diff --git a/lib/state/app/modules/gradeAverages/bloc/grade_averages_state.dart b/lib/state/app/modules/grade_averages/bloc/grade_averages_state.dart similarity index 100% rename from lib/state/app/modules/gradeAverages/bloc/grade_averages_state.dart rename to lib/state/app/modules/grade_averages/bloc/grade_averages_state.dart diff --git a/lib/state/app/modules/gradeAverages/bloc/grade_averages_state.freezed.dart b/lib/state/app/modules/grade_averages/bloc/grade_averages_state.freezed.dart similarity index 100% rename from lib/state/app/modules/gradeAverages/bloc/grade_averages_state.freezed.dart rename to lib/state/app/modules/grade_averages/bloc/grade_averages_state.freezed.dart diff --git a/lib/state/app/modules/gradeAverages/bloc/grade_averages_state.g.dart b/lib/state/app/modules/grade_averages/bloc/grade_averages_state.g.dart similarity index 100% rename from lib/state/app/modules/gradeAverages/bloc/grade_averages_state.g.dart rename to lib/state/app/modules/grade_averages/bloc/grade_averages_state.g.dart diff --git a/lib/state/app/modules/holidays/bloc/holidays_bloc.dart b/lib/state/app/modules/holidays/bloc/holidays_bloc.dart index 3f82d04..2e3b96b 100644 --- a/lib/state/app/modules/holidays/bloc/holidays_bloc.dart +++ b/lib/state/app/modules/holidays/bloc/holidays_bloc.dart @@ -1,5 +1,5 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.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 '../repository/holidays_repository.dart'; import 'holidays_event.dart'; import 'holidays_state.dart'; @@ -22,16 +22,16 @@ class HolidaysBloc extends LoadableHydratedBloc const HolidaysState(showPastHolidays: false, holidays: [], showDisclaimer: true); + HolidaysState fromNothing() => const HolidaysState(showPastHolidays: false, holidays: [], showDisclaimer: true); @override - fromStorage(Map json) => HolidaysState.fromJson(json); + HolidaysState fromStorage(Map json) => HolidaysState.fromJson(json); @override Future gatherData() async { var holidays = await repo.getHolidays(); add(DataGathered((state) => state.copyWith(holidays: holidays))); } @override - repository() => HolidaysRepository(); + HolidaysRepository repository() => HolidaysRepository(); @override Map? toStorage(state) => state.toJson(); } diff --git a/lib/state/app/modules/holidays/bloc/holidays_event.dart b/lib/state/app/modules/holidays/bloc/holidays_event.dart index 8565250..4be4a68 100644 --- a/lib/state/app/modules/holidays/bloc/holidays_event.dart +++ b/lib/state/app/modules/holidays/bloc/holidays_event.dart @@ -1,4 +1,4 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'holidays_state.dart'; sealed class HolidaysEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/holidays/bloc/holidays_state.dart b/lib/state/app/modules/holidays/bloc/holidays_state.dart index 1a7eef0..eec02b2 100644 --- a/lib/state/app/modules/holidays/bloc/holidays_state.dart +++ b/lib/state/app/modules/holidays/bloc/holidays_state.dart @@ -1,5 +1,5 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; part 'holidays_state.freezed.dart'; part 'holidays_state.g.dart'; diff --git a/lib/state/app/modules/holidays/dataProvider/holidays_get_holidays.dart b/lib/state/app/modules/holidays/data_provider/holidays_get_holidays.dart similarity index 86% rename from lib/state/app/modules/holidays/dataProvider/holidays_get_holidays.dart rename to lib/state/app/modules/holidays/data_provider/holidays_get_holidays.dart index cd52128..ad663da 100644 --- a/lib/state/app/modules/holidays/dataProvider/holidays_get_holidays.dart +++ b/lib/state/app/modules/holidays/data_provider/holidays_get_holidays.dart @@ -1,7 +1,7 @@ import 'package:dio/dio.dart'; import '../../../basis/dataloader/holiday_data_loader.dart'; -import '../../../infrastructure/dataLoader/data_loader.dart'; +import '../../../infrastructure/data_loader/data_loader.dart'; import '../bloc/holidays_state.dart'; class HolidaysGetHolidays extends HolidayDataLoader> { diff --git a/lib/state/app/modules/holidays/repository/holidays_repository.dart b/lib/state/app/modules/holidays/repository/holidays_repository.dart index 72ec949..7e964c6 100644 --- a/lib/state/app/modules/holidays/repository/holidays_repository.dart +++ b/lib/state/app/modules/holidays/repository/holidays_repository.dart @@ -1,6 +1,6 @@ import '../../../infrastructure/repository/repository.dart'; import '../bloc/holidays_state.dart'; -import '../dataProvider/holidays_get_holidays.dart'; +import '../data_provider/holidays_get_holidays.dart'; class HolidaysRepository extends Repository { Future> getHolidays() => HolidaysGetHolidays().run(); diff --git a/lib/state/app/modules/marianumDates/bloc/marianum_dates_bloc.dart b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart similarity index 65% rename from lib/state/app/modules/marianumDates/bloc/marianum_dates_bloc.dart rename to lib/state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart index 3f7961b..241370d 100644 --- a/lib/state/app/modules/marianumDates/bloc/marianum_dates_bloc.dart +++ b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart @@ -1,5 +1,5 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.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 '../repository/marianum_dates_repository.dart'; import 'marianum_dates_event.dart'; import 'marianum_dates_state.dart'; @@ -18,16 +18,16 @@ class MarianumDatesBloc extends LoadableHydratedBloc const MarianumDatesState(showPastEvents: false, events: []); + MarianumDatesState fromNothing() => const MarianumDatesState(showPastEvents: false, events: []); @override - fromStorage(Map json) => MarianumDatesState.fromJson(json); + MarianumDatesState fromStorage(Map json) => MarianumDatesState.fromJson(json); @override Future gatherData() async { final events = await repo.getEvents(); add(DataGathered((state) => state.copyWith(events: events))); } @override - repository() => MarianumDatesRepository(); + MarianumDatesRepository repository() => MarianumDatesRepository(); @override Map? toStorage(state) => state.toJson(); } diff --git a/lib/state/app/modules/marianumDates/bloc/marianum_dates_event.dart b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_event.dart similarity index 70% rename from lib/state/app/modules/marianumDates/bloc/marianum_dates_event.dart rename to lib/state/app/modules/marianum_dates/bloc/marianum_dates_event.dart index 34b5b8d..1bfcb88 100644 --- a/lib/state/app/modules/marianumDates/bloc/marianum_dates_event.dart +++ b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_event.dart @@ -1,4 +1,4 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'marianum_dates_state.dart'; sealed class MarianumDatesEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/marianumDates/bloc/marianum_dates_state.dart b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.dart similarity index 100% rename from lib/state/app/modules/marianumDates/bloc/marianum_dates_state.dart rename to lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.dart index d3a7d14..26eb18f 100644 --- a/lib/state/app/modules/marianumDates/bloc/marianum_dates_state.dart +++ b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.dart @@ -1,5 +1,5 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; part 'marianum_dates_state.freezed.dart'; part 'marianum_dates_state.g.dart'; diff --git a/lib/state/app/modules/marianumDates/bloc/marianum_dates_state.freezed.dart b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.freezed.dart similarity index 100% rename from lib/state/app/modules/marianumDates/bloc/marianum_dates_state.freezed.dart rename to lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.freezed.dart diff --git a/lib/state/app/modules/marianumDates/bloc/marianum_dates_state.g.dart b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.g.dart similarity index 100% rename from lib/state/app/modules/marianumDates/bloc/marianum_dates_state.g.dart rename to lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.g.dart diff --git a/lib/state/app/modules/marianumDates/dataProvider/marianum_dates_get_events.dart b/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart similarity index 92% rename from lib/state/app/modules/marianumDates/dataProvider/marianum_dates_get_events.dart rename to lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart index 2ab5ecd..fc0c177 100644 --- a/lib/state/app/modules/marianumDates/dataProvider/marianum_dates_get_events.dart +++ b/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart @@ -7,8 +7,8 @@ class MarianumDatesGetEvents { static const String url = 'https://public-cal.marianumlan.de/cal_public/ad4c5da8-7466-9c72-89cb-8b8d9a5cf26c'; final Dio _dio = Dio(BaseOptions( - connectTimeout: const Duration(seconds: 10).inMilliseconds, - receiveTimeout: const Duration(seconds: 30).inMilliseconds, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), )); Future> run() async { diff --git a/lib/state/app/modules/marianumDates/repository/marianum_dates_repository.dart b/lib/state/app/modules/marianum_dates/repository/marianum_dates_repository.dart similarity index 81% rename from lib/state/app/modules/marianumDates/repository/marianum_dates_repository.dart rename to lib/state/app/modules/marianum_dates/repository/marianum_dates_repository.dart index 416b4d5..ead14c9 100644 --- a/lib/state/app/modules/marianumDates/repository/marianum_dates_repository.dart +++ b/lib/state/app/modules/marianum_dates/repository/marianum_dates_repository.dart @@ -1,6 +1,6 @@ import '../../../infrastructure/repository/repository.dart'; import '../bloc/marianum_dates_state.dart'; -import '../dataProvider/marianum_dates_get_events.dart'; +import '../data_provider/marianum_dates_get_events.dart'; class MarianumDatesRepository extends Repository { Future> getEvents() => MarianumDatesGetEvents().run(); diff --git a/lib/state/app/modules/marianumMessage/bloc/marianum_message_bloc.dart b/lib/state/app/modules/marianum_message/bloc/marianum_message_bloc.dart similarity index 80% rename from lib/state/app/modules/marianumMessage/bloc/marianum_message_bloc.dart rename to lib/state/app/modules/marianum_message/bloc/marianum_message_bloc.dart index 97c3b95..daca62d 100644 --- a/lib/state/app/modules/marianumMessage/bloc/marianum_message_bloc.dart +++ b/lib/state/app/modules/marianum_message/bloc/marianum_message_bloc.dart @@ -1,5 +1,5 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.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 '../repository/marianum_message_repository.dart'; import 'marianum_message_event.dart'; import 'marianum_message_state.dart'; diff --git a/lib/state/app/modules/marianumMessage/bloc/marianum_message_event.dart b/lib/state/app/modules/marianum_message/bloc/marianum_message_event.dart similarity index 63% rename from lib/state/app/modules/marianumMessage/bloc/marianum_message_event.dart rename to lib/state/app/modules/marianum_message/bloc/marianum_message_event.dart index 43cbf2a..f71d5c7 100644 --- a/lib/state/app/modules/marianumMessage/bloc/marianum_message_event.dart +++ b/lib/state/app/modules/marianum_message/bloc/marianum_message_event.dart @@ -1,4 +1,4 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'marianum_message_state.dart'; sealed class MarianumMessageEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/marianumMessage/bloc/marianum_message_state.dart b/lib/state/app/modules/marianum_message/bloc/marianum_message_state.dart similarity index 100% rename from lib/state/app/modules/marianumMessage/bloc/marianum_message_state.dart rename to lib/state/app/modules/marianum_message/bloc/marianum_message_state.dart diff --git a/lib/state/app/modules/marianumMessage/bloc/marianum_message_state.freezed.dart b/lib/state/app/modules/marianum_message/bloc/marianum_message_state.freezed.dart similarity index 100% rename from lib/state/app/modules/marianumMessage/bloc/marianum_message_state.freezed.dart rename to lib/state/app/modules/marianum_message/bloc/marianum_message_state.freezed.dart diff --git a/lib/state/app/modules/marianumMessage/bloc/marianum_message_state.g.dart b/lib/state/app/modules/marianum_message/bloc/marianum_message_state.g.dart similarity index 100% rename from lib/state/app/modules/marianumMessage/bloc/marianum_message_state.g.dart rename to lib/state/app/modules/marianum_message/bloc/marianum_message_state.g.dart diff --git a/lib/state/app/modules/marianumMessage/dataProvider/marianum_message_get_messages.dart b/lib/state/app/modules/marianum_message/data_provider/marianum_message_get_messages.dart similarity index 79% rename from lib/state/app/modules/marianumMessage/dataProvider/marianum_message_get_messages.dart rename to lib/state/app/modules/marianum_message/data_provider/marianum_message_get_messages.dart index f8c4b24..a74dda4 100644 --- a/lib/state/app/modules/marianumMessage/dataProvider/marianum_message_get_messages.dart +++ b/lib/state/app/modules/marianum_message/data_provider/marianum_message_get_messages.dart @@ -1,12 +1,12 @@ import 'package:dio/dio.dart'; -import '../../../infrastructure/dataLoader/data_loader.dart'; import '../../../basis/dataloader/mhsl_data_loader.dart'; +import '../../../infrastructure/data_loader/data_loader.dart'; import '../bloc/marianum_message_state.dart'; class MarianumMessageGetMessages extends MhslDataLoader { @override Future> fetch() async => dio.get('/message/messages.json'); @override - MarianumMessageList assemble(DataLoaderResult data) => MarianumMessageList.fromJson(data.json); + MarianumMessageList assemble(DataLoaderResult data) => MarianumMessageList.fromJson(data.asMap()); } diff --git a/lib/state/app/modules/marianumMessage/repository/marianum_message_repository.dart b/lib/state/app/modules/marianum_message/repository/marianum_message_repository.dart similarity index 81% rename from lib/state/app/modules/marianumMessage/repository/marianum_message_repository.dart rename to lib/state/app/modules/marianum_message/repository/marianum_message_repository.dart index 3148b92..9a6d9bc 100644 --- a/lib/state/app/modules/marianumMessage/repository/marianum_message_repository.dart +++ b/lib/state/app/modules/marianum_message/repository/marianum_message_repository.dart @@ -1,6 +1,6 @@ import '../../../infrastructure/repository/repository.dart'; import '../bloc/marianum_message_state.dart'; -import '../dataProvider/marianum_message_get_messages.dart'; +import '../data_provider/marianum_message_get_messages.dart'; class MarianumMessageRepository extends Repository { Future getMessages() => MarianumMessageGetMessages().run(); diff --git a/lib/state/app/modules/settings/bloc/settings_cubit.dart b/lib/state/app/modules/settings/bloc/settings_cubit.dart index efc372f..ad38792 100644 --- a/lib/state/app/modules/settings/bloc/settings_cubit.dart +++ b/lib/state/app/modules/settings/bloc/settings_cubit.dart @@ -1,10 +1,10 @@ import 'dart:async'; import 'dart:developer'; -import 'package:easy_debounce/easy_debounce.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../storage/settings.dart'; +import '../../../../../utils/debouncer.dart'; import '../../../../../view/pages/settings/data/default_settings.dart'; import '../../app_modules.dart'; @@ -27,7 +27,7 @@ class SettingsCubit extends HydratedCubit { _emitFreshInstance(); }); } - EasyDebounce.debounce(_debounceTag, const Duration(milliseconds: 500), _emitFreshInstance); + Debouncer.debounce(_debounceTag, const Duration(milliseconds: 500), _emitFreshInstance); } return state; } @@ -77,7 +77,7 @@ class SettingsCubit extends HydratedCubit { oldMap.forEach((key, value) { if (merged.containsKey(key)) { if (value is Map && merged[key] is Map) { - merged[key] = _mergeSettings(value, merged[key]); + merged[key] = _mergeSettings(value, merged[key] as Map); } else { merged[key] = value; } diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart index 1b142e9..1c7dc5b 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -1,9 +1,9 @@ import 'package:intl/intl.dart'; -import '../../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; -import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../../../../../api/webuntis/queries/get_timetable/get_timetable_response.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 '../repository/timetable_repository.dart'; import 'timetable_event.dart'; import 'timetable_state.dart'; diff --git a/lib/state/app/modules/timetable/bloc/timetable_event.dart b/lib/state/app/modules/timetable/bloc/timetable_event.dart index de90c8e..871f2bc 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_event.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_event.dart @@ -1,4 +1,4 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'timetable_state.dart'; sealed class TimetableEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.dart b/lib/state/app/modules/timetable/bloc/timetable_state.dart index 1d1b2ea..af26bee 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.dart @@ -1,11 +1,11 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import '../../../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart'; -import '../../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; -import '../../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; -import '../../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; -import '../../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart'; -import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; +import '../../../../../api/webuntis/queries/get_holidays/get_holidays_response.dart'; +import '../../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart'; +import '../../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart'; +import '../../../../../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart'; +import '../../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; part 'timetable_state.freezed.dart'; part 'timetable_state.g.dart'; diff --git a/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart similarity index 67% rename from lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart rename to lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart index 8e4766b..8859d1d 100644 --- a/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart +++ b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart @@ -1,25 +1,25 @@ import 'package:intl/intl.dart'; -import '../../../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart'; -import '../../../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.dart'; -import '../../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; -import '../../../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart'; -import '../../../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.dart'; -import '../../../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart'; -import '../../../../../api/mhsl/customTimetableEvent/remove/removeCustomTimetableEvent.dart'; -import '../../../../../api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.dart'; -import '../../../../../api/mhsl/customTimetableEvent/update/updateCustomTimetableEvent.dart'; -import '../../../../../api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.dart'; -import '../../../../../api/webuntis/queries/getHolidays/getHolidaysCache.dart'; -import '../../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; -import '../../../../../api/webuntis/queries/getRooms/getRoomsCache.dart'; -import '../../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; -import '../../../../../api/webuntis/queries/getSubjects/getSubjectsCache.dart'; -import '../../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; -import '../../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart'; -import '../../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart'; -import '../../../../../api/webuntis/queries/getTimetable/getTimetableCache.dart'; -import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../../../api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart'; +import '../../../../../api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart'; +import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart'; +import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart'; +import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; +import '../../../../../api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart'; +import '../../../../../api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart'; +import '../../../../../api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart'; +import '../../../../../api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart'; +import '../../../../../api/webuntis/queries/get_holidays/get_holidays_cache.dart'; +import '../../../../../api/webuntis/queries/get_holidays/get_holidays_response.dart'; +import '../../../../../api/webuntis/queries/get_rooms/get_rooms_cache.dart'; +import '../../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart'; +import '../../../../../api/webuntis/queries/get_subjects/get_subjects_cache.dart'; +import '../../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart'; +import '../../../../../api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart'; +import '../../../../../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart'; +import '../../../../../api/webuntis/queries/get_timetable/get_timetable_cache.dart'; +import '../../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; import '../../../../../model/account_data.dart'; class TimetableDataProvider { diff --git a/lib/state/app/modules/timetable/repository/timetable_repository.dart b/lib/state/app/modules/timetable/repository/timetable_repository.dart index 43ac3a7..36cb6e3 100644 --- a/lib/state/app/modules/timetable/repository/timetable_repository.dart +++ b/lib/state/app/modules/timetable/repository/timetable_repository.dart @@ -1,6 +1,6 @@ import '../../../infrastructure/repository/repository.dart'; import '../bloc/timetable_state.dart'; -import '../dataProvider/timetable_data_provider.dart'; +import '../data_provider/timetable_data_provider.dart'; class TimetableRepository extends Repository { final TimetableDataProvider _provider; diff --git a/lib/storage/settings.dart b/lib/storage/settings.dart index af3abbb..4e42830 100644 --- a/lib/storage/settings.dart +++ b/lib/storage/settings.dart @@ -4,8 +4,8 @@ import 'package:json_annotation/json_annotation.dart'; import 'dev_tools_settings.dart'; import 'file_settings.dart'; import 'file_view_settings.dart'; -import 'modules_settings.dart'; import 'holidays_settings.dart'; +import 'modules_settings.dart'; import 'notification_settings.dart'; import 'talk_settings.dart'; import 'timetable_settings.dart'; diff --git a/lib/utils/debouncer.dart b/lib/utils/debouncer.dart new file mode 100644 index 0000000..ce3fb5e --- /dev/null +++ b/lib/utils/debouncer.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +/// Static map-based debouncer/throttler. Replaces the `easy_debounce` package +/// with a minimal in-house implementation: each unique [tag] tracks its own +/// pending Timer and gating flag. +class Debouncer { + Debouncer._(); + + static final Map _debounceTimers = {}; + static final Map _throttleTimers = {}; + + /// Coalesces calls under [tag]: the [action] runs once [delay] has elapsed + /// without further calls for the same tag. + static void debounce(String tag, Duration delay, void Function() action) { + _debounceTimers[tag]?.cancel(); + _debounceTimers[tag] = Timer(delay, () { + _debounceTimers.remove(tag); + action(); + }); + } + + /// Runs [action] immediately and ignores subsequent calls under the same + /// [tag] until [duration] has elapsed. + static void throttle(String tag, Duration duration, void Function() action) { + if (_throttleTimers.containsKey(tag)) return; + _throttleTimers[tag] = Timer(duration, () => _throttleTimers.remove(tag)); + action(); + } + + static void cancel(String tag) { + _debounceTimers.remove(tag)?.cancel(); + _throttleTimers.remove(tag)?.cancel(); + } +} diff --git a/lib/utils/download_manager.dart b/lib/utils/download_manager.dart new file mode 100644 index 0000000..ba9c0dd --- /dev/null +++ b/lib/utils/download_manager.dart @@ -0,0 +1,146 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../api/marianumcloud/webdav/webdav_api.dart'; +import '../model/account_data.dart'; +import 'file_downloader.dart'; + +/// Snapshot of a single download's lifecycle. UI widgets rebuild whenever the +/// owning [DownloadJob.status] notifier emits a new instance. +sealed class DownloadStatus { + const DownloadStatus(); +} + +class DownloadInProgress extends DownloadStatus { + const DownloadInProgress(this.percent); + final double percent; +} + +class DownloadDone extends DownloadStatus { + const DownloadDone(this.localPath); + final String localPath; +} + +class DownloadCancelled extends DownloadStatus { + const DownloadCancelled(); +} + +class DownloadFailed extends DownloadStatus { + const DownloadFailed(this.message); + final String message; +} + +/// Tracks a single in-flight or finished download. Survives widget dispose so +/// that re-entering the screen reattaches to the same job. +class DownloadJob { + DownloadJob({ + required this.remotePath, + required this.name, + required this.localPath, + required FileDownloader downloader, + }) : _downloader = downloader; + + final String remotePath; + final String name; + final String localPath; + final FileDownloader _downloader; + + final ValueNotifier status = ValueNotifier(const DownloadInProgress(0)); + bool _disposed = false; + + bool get isFinished => + status.value is DownloadDone || + status.value is DownloadFailed || + status.value is DownloadCancelled; + + void cancel() { + if (isFinished) return; + _downloader.cancel(); + status.value = const DownloadCancelled(); + } + + void _dispose() { + if (_disposed) return; + _disposed = true; + status.dispose(); + } +} + +/// Central in-memory registry for downloads. Keyed by remote path so a file +/// triggered from multiple screens (Files + Chat) reuses one job. +/// +/// Not persistent across app restarts — restarting abandons in-flight +/// downloads and partial files are cleaned on next start attempt. +class DownloadManager { + DownloadManager._(); + static final DownloadManager instance = DownloadManager._(); + + final Map _jobs = {}; + + /// Active or recently finished job for [remotePath], or null if none. + DownloadJob? jobFor(String remotePath) => _jobs[remotePath]; + + /// Returns the existing job if a download is in progress for [remotePath], + /// otherwise starts a new one. Caller listens on [DownloadJob.status]. + Future start({required String remotePath, required String name}) async { + final existing = _jobs[remotePath]; + if (existing != null && !existing.isFinished) return existing; + if (existing != null) { + _jobs.remove(remotePath); + scheduleMicrotask(existing._dispose); + } + + final tempDir = await getTemporaryDirectory(); + final encodedPath = Uri.encodeComponent(remotePath).replaceAll('%2F', '/'); + final localPath = '${tempDir.path}${Platform.pathSeparator}$name'; + + final downloader = FileDownloader(); + final job = DownloadJob( + remotePath: remotePath, + name: name, + localPath: localPath, + downloader: downloader, + ); + _jobs[remotePath] = job; + + downloader.run( + client: Dio(BaseOptions(headers: AccountData().authHeaders())), + url: '${WebdavApi.buildWebdavUrl()}$encodedPath', + savePath: localPath, + onProgress: (percent) { + if (job.isFinished) return; + job.status.value = DownloadInProgress(percent); + }, + onDone: () { + if (job.isFinished) return; + job.status.value = DownloadDone(localPath); + }, + onError: (error) { + if (job.isFinished) return; + try { + File(localPath).deleteSync(); + } on FileSystemException { + // partial file may not exist — ignore + } + job.status.value = DownloadFailed(error.toString()); + }, + ); + + return job; + } + + /// Removes a finished job from the registry. Safe to call from a status + /// listener: actual disposal of the underlying notifier is deferred to the + /// next microtask so the in-flight `notifyListeners` cycle can finish before + /// the notifier is destroyed. Active (unfinished) jobs are left untouched. + void clear(String remotePath) { + final job = _jobs[remotePath]; + if (job == null || !job.isFinished) return; + _jobs.remove(remotePath); + scheduleMicrotask(job._dispose); + } +} diff --git a/lib/utils/file_downloader.dart b/lib/utils/file_downloader.dart new file mode 100644 index 0000000..d6b8152 --- /dev/null +++ b/lib/utils/file_downloader.dart @@ -0,0 +1,47 @@ +import 'package:dio/dio.dart'; + +/// Lightweight cancel handle around a single `Dio.download` call. The download +/// itself is started by [run]; the handle returns synchronously so callers can +/// install it into shared state before the first progress event can fire. +class FileDownloader { + FileDownloader(); + + final CancelToken _cancelToken = CancelToken(); + bool _cancelled = false; + + bool get isCancelled => _cancelled; + + void cancel() { + if (_cancelled) return; + _cancelled = true; + _cancelToken.cancel('user cancelled'); + } + + /// Kicks off the download. Returns immediately; the download progresses in + /// the background and events are delivered via callbacks. Callbacks are not + /// invoked once [cancel] has been called. + void run({ + required Dio client, + required String url, + required String savePath, + required void Function(double percent) onProgress, + required void Function() onDone, + required void Function(Object error) onError, + }) { + client.download( + url, + savePath, + cancelToken: _cancelToken, + onReceiveProgress: (received, total) { + if (_cancelled || total <= 0) return; + onProgress((received / total) * 100); + }, + ).then((_) { + if (_cancelled) return; + onDone(); + }).catchError((Object error) { + if (_cancelled) return; + onError(error); + }).ignore(); + } +} diff --git a/lib/utils/file_saver.dart b/lib/utils/file_saver.dart deleted file mode 100644 index 1a1a88c..0000000 --- a/lib/utils/file_saver.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'dart:io'; - -import 'package:permission_handler/permission_handler.dart'; - -// only tested on android! -class FileSaver { - static Future getExternalDocumentPath() async { - var permission = await Permission.storage.status; - if(!permission.isGranted) { - await Permission.storage.request(); - } - var directory = Directory('/storage/emulated/0/Download'); - final externalPath = directory.path; - await Directory(externalPath).create(recursive: true); - return externalPath; - } - - static Future writeBytes(List bytes, String name) async { - final path = await getExternalDocumentPath(); - var file = File('$path/$name'); - return file.writeAsBytes(bytes); - } -} diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index 73df505..a2d22cc 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -5,8 +5,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_login/flutter_login.dart'; -import '../../api/marianumcloud/talk/room/getRoom.dart'; -import '../../api/marianumcloud/talk/room/getRoomParams.dart'; +import '../../api/marianumcloud/talk/room/get_room.dart'; +import '../../api/marianumcloud/talk/room/get_room_params.dart'; import '../../model/account_data.dart'; import '../../state/app/modules/account/bloc/account_bloc.dart'; import '../../state/app/modules/account/bloc/account_state.dart'; diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index 212c8fc..d673417 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -1,21 +1,26 @@ -import 'dart:io'; +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:nextcloud/nextcloud.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import '../../../api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart'; -import '../../../state/app/infrastructure/loadableState/loadable_state.dart'; -import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; -import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; +import '../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import '../../../api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; +import '../../../api/marianumcloud/webdav/webdav_api.dart'; +import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +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 '../../../utils/file_clipboard.dart'; import '../../../widget/async_action_button.dart'; import '../../../widget/file_pick.dart'; import '../../../widget/placeholder_view.dart'; -import 'widgets/file_element.dart'; import 'files_upload_dialog.dart'; +import 'widgets/file_element.dart'; class BetterSortOption { String displayName; @@ -78,6 +83,11 @@ class _FilesViewState extends State<_FilesView> { late final SettingsCubit settings; late SortOption currentSort; late bool currentSortDirection; + late final StreamSubscription _invalidationSub; + + // 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('/'); @override void initState() { @@ -85,12 +95,25 @@ class _FilesViewState extends State<_FilesView> { settings = context.read(); currentSort = settings.val().fileSettings.sortBy; currentSortDirection = settings.val().fileSettings.ascending; + _invalidationSub = CacheInvalidationBus.listFilesStream.listen(_onInvalidation); + } + + void _onInvalidation(String invalidatedPath) { + if (!mounted) return; + if (invalidatedPath != _myPathString) return; + context.read().refresh(); + } + + @override + void dispose() { + _invalidationSub.cancel(); + super.dispose(); } Future mediaUpload(List? paths) async { if (paths == null) return; final bloc = context.read(); - pushScreen( + unawaited(pushScreen( context, withNavBar: false, screen: FilesUploadDialog( @@ -98,7 +121,7 @@ class _FilesViewState extends State<_FilesView> { remotePath: widget.path.join('/'), onUploadFinished: (_) => bloc.refresh(), ), - ); + )); } @override @@ -163,28 +186,39 @@ class _FilesViewState extends State<_FilesView> { onPressed: () => _showAddDialog(context, bloc), child: const Icon(Icons.add), ), - body: LoadableStateConsumer( - isReady: (state) => state.listing != null, - child: (state, _) { - final listing = state.listing!; - if (listing.files.isEmpty) { - return const PlaceholderView(icon: Icons.folder_off_rounded, text: 'Der Ordner ist leer'); - } - final files = listing.sortBy( - sortOption: currentSort, - foldersToTop: context.watch().val().fileSettings.sortFoldersToTop, - reversed: currentSortDirection, - ); - return ListView.builder( - padding: EdgeInsets.zero, - itemCount: files.length, - itemBuilder: (context, index) => FileElement(files[index], widget.path, bloc.refresh), - ); - }, + body: Column( + children: [ + _ClipboardBanner(currentFolder: _currentFolderPath, onPasteDone: bloc.refresh), + Expanded( + child: LoadableStateConsumer( + isReady: (state) => state.listing != null, + child: (state, _) { + final listing = state.listing!; + if (listing.files.isEmpty) { + return const PlaceholderView(icon: Icons.folder_off_rounded, text: 'Der Ordner ist leer'); + } + final files = listing.sortBy( + sortOption: currentSort, + foldersToTop: context.watch().val().fileSettings.sortFoldersToTop, + reversed: currentSortDirection, + ); + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: files.length, + itemBuilder: (context, index) => FileElement(files[index], widget.path, bloc.refresh), + ); + }, + ), + ), + ], ), ); } + // 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('/')}/'; + void _showAddDialog(BuildContext context, FilesBloc bloc) { showDialog( context: context, @@ -205,18 +239,25 @@ class _FilesViewState extends State<_FilesView> { Navigator.of(dialogCtx).pop(); }, ), - Visibility( - visible: !Platform.isIOS, - child: ListTile( - leading: const Icon(Icons.add_a_photo_outlined), - title: const Text('Aus Gallerie hochladen'), - onTap: () { - FilePick.multipleGalleryPick().then((value) { - if (value != null) mediaUpload(value.map((e) => e.path).toList()); - }); - Navigator.of(dialogCtx).pop(); - }, - ), + ListTile( + leading: const Icon(Icons.add_a_photo_outlined), + title: const Text('Aus Galerie hochladen'), + onTap: () { + FilePick.multipleGalleryPick().then((value) { + if (value != null) mediaUpload(value.map((e) => e.path).toList()); + }); + Navigator.of(dialogCtx).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.camera_alt_outlined), + title: const Text('Foto aufnehmen'), + onTap: () { + FilePick.cameraPick().then((image) { + if (image != null) mediaUpload([image.path]); + }); + Navigator.of(dialogCtx).pop(); + }, ), ]), ); @@ -248,3 +289,142 @@ class _FilesViewState extends State<_FilesView> { ); } } + +class _ClipboardBanner extends StatefulWidget { + const _ClipboardBanner({required this.currentFolder, required this.onPasteDone}); + final String currentFolder; + final void Function() onPasteDone; + + @override + State<_ClipboardBanner> createState() => _ClipboardBannerState(); +} + +class _ClipboardBannerState extends State<_ClipboardBanner> { + 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 + // - every entry already lives in the current folder (paste would be a no-op) + bool get _canPaste { + final cb = FileClipboard.instance; + if (cb.isEmpty) return false; + final dst = _normalised(widget.currentFolder); + var atLeastOneActionable = false; + for (final f in cb.files) { + if (f.isDirectory) { + final src = _normalised(f.path); + if (dst == src || dst.startsWith(src)) return false; + } + final destination = _joinPath(widget.currentFolder, f.name, isDirectory: f.isDirectory); + if (destination != f.path) atLeastOneActionable = true; + } + 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; + setState(() => _busy = true); + final operation = cb.operation; + final errors = []; + final invalidatedSourceFolders = {}; + try { + final webdav = await WebdavApi.webdav; + for (final file in cb.files) { + final destination = _joinPath(widget.currentFolder, file.name, isDirectory: file.isDirectory); + if (destination == file.path) continue; + try { + if (operation == FileClipboardOperation.cut) { + await webdav.move(PathUri.parse(file.path), PathUri.parse(destination)); + invalidatedSourceFolders.add(_parentCacheKey(file.path)); + } else { + await webdav.copy(PathUri.parse(file.path), PathUri.parse(destination)); + } + } on Object catch (e) { + errors.add('${file.name}: $e'); + } + } + // 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); + } + if (operation == FileClipboardOperation.cut) cb.clear(); + widget.onPasteDone(); + } finally { + if (mounted) setState(() => _busy = false); + } + if (errors.isNotEmpty && mounted) { + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Einfügen teilweise fehlgeschlagen'), + content: SingleChildScrollView(child: Text(errors.join('\n\n'))), + actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('OK'))], + ), + ); + } + } + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: FileClipboard.instance, + builder: (context, _) { + final cb = FileClipboard.instance; + if (cb.isEmpty) return const SizedBox.shrink(); + final cut = cb.operation == FileClipboardOperation.cut; + final count = cb.files.length; + final label = count == 1 ? '"${cb.files.first.name}"' : '$count Elemente'; + return Material( + color: Theme.of(context).colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Icon(cut ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + cut ? '$label verschieben' : '$label kopieren', + overflow: TextOverflow.ellipsis, + ), + ), + TextButton( + onPressed: _busy || !_canPaste ? null : _paste, + child: _busy + ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Hier einfügen'), + ), + IconButton( + tooltip: 'Verwerfen', + icon: const Icon(Icons.close, size: 20), + onPressed: _busy ? null : cb.clear, + ), + ], + ), + ), + ); + }, + ); +} diff --git a/lib/view/pages/files/files_upload_dialog.dart b/lib/view/pages/files/files_upload_dialog.dart index 4c9b1c6..44fe7cf 100644 --- a/lib/view/pages/files/files_upload_dialog.dart +++ b/lib/view/pages/files/files_upload_dialog.dart @@ -3,9 +3,8 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:loader_overlay/loader_overlay.dart'; import 'package:nextcloud/nextcloud.dart'; -import 'package:uuid/uuid.dart'; -import '../../../api/marianumcloud/webdav/webdavApi.dart'; +import '../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../widget/confirm_dialog.dart'; import '../../../widget/focus_behaviour.dart'; @@ -107,14 +106,14 @@ class _FilesUploadDialogState extends State { _showUploadError('Verbindung fehlgeschlagen: $e'); return; } - var conflictingFiles = _uploadableFiles.where((file) { - var fileName = file.fileName; - return result.any((element) => Uri.decodeComponent(element.href!).endsWith('/$fileName')); + final conflictingFiles = _uploadableFiles.where((file) { + final fileName = file.fileName; + return result.any((element) => Uri.decodeComponent((element as WebDavResponse).href!).endsWith('/$fileName')); }).toList(); - if(conflictingFiles.isNotEmpty) { + if (conflictingFiles.isNotEmpty) { if (!mounted) return; - bool replaceFiles = await showDialog( + final replaceFiles = await showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( @@ -160,7 +159,7 @@ class _FilesUploadDialogState extends State { ) ); - if(!replaceFiles) { + if (replaceFiles != true) { setState(() { _isUploading = false; _overallProgressValue = 0.0; @@ -179,7 +178,10 @@ class _FilesUploadDialogState extends State { var fileName = file.fileName; var filePath = file.filePath; - if(widget.uniqueNames) fileName = '${fileName.split('.').first}-${const Uuid().v4()}.${fileName.split('.').last}'; + if (widget.uniqueNames) { + final unique = DateTime.now().microsecondsSinceEpoch.toRadixString(36); + fileName = '${fileName.split('.').first}-$unique.${fileName.split('.').last}'; + } var fullRemotePath = '${widget.remotePath}/$fileName'; @@ -187,7 +189,7 @@ class _FilesUploadDialogState extends State { _infoText = '${_uploadableFiles.indexOf(file) + 1}/${_uploadableFiles.length}'; }); - final dynamic uploadTask; + final HttpClientResponse uploadTask; try { uploadTask = await webdavClient.putFile( File(filePath), diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart index 26439a0..3718c14 100644 --- a/lib/view/pages/files/widgets/file_element.dart +++ b/lib/view/pages/files/widgets/file_element.dart @@ -1,24 +1,18 @@ -import 'dart:io'; - -import 'package:dio/dio.dart'; import 'package:filesize/filesize.dart'; -import 'package:flowder/flowder.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; -import 'package:open_filex/open_filex.dart'; -import '../../../../widget/info_dialog.dart'; import 'package:nextcloud/nextcloud.dart'; -import 'package:path_provider/path_provider.dart'; -import '../../../../api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart'; -import '../../../../api/marianumcloud/webdav/webdavApi.dart'; -import '../../../../model/account_data.dart'; +import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import '../../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../../model/endpoint_data.dart'; import '../../../../routing/app_routes.dart'; +import '../../../../utils/download_manager.dart'; +import '../../../../utils/file_clipboard.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; -import '../../../../widget/unimplemented_dialog.dart'; +import '../../../../widget/info_dialog.dart'; +import 'file_details_sheet.dart'; class FileElement extends StatefulWidget { final CacheableFile file; @@ -26,57 +20,117 @@ class FileElement extends StatefulWidget { final void Function() refetch; const FileElement(this.file, this.path, this.refetch, {super.key}); - static Future download(BuildContext context, String remotePath, String name, Function(double) onProgress, Function(OpenResult) onDone) async { - var paths = await getTemporaryDirectory(); - - var encodedPath = Uri.encodeComponent(remotePath); - encodedPath = encodedPath.replaceAll('%2F', '/'); - - var local = paths.path + Platform.pathSeparator + name; - - var options = DownloaderUtils( - progressCallback: (current, total) { - final progress = (current / total) * 100; - onProgress(progress); - }, - file: File(local), - progress: ProgressImplementation(), - deleteOnCancel: true, - client: Dio(BaseOptions(headers: AccountData().authHeaders())), - onDone: () { - AppRoutes.openFileViewer(context, local); - onDone(OpenResult(message: 'File viewer opened', type: ResultType.done)); - }, - ); - - return await Flowder.download( - '${WebdavApi.buildWebdavUrl()}$encodedPath', - options, - ); - } - @override State createState() => _FileElementState(); } class _FileElementState extends State { - double percent = 0; - Future? downloadCore; + DownloadJob? _job; - Widget getSubtitle() { - if(widget.file.currentlyDownloading) { + @override + void initState() { + super.initState(); + _attachJob(DownloadManager.instance.jobFor(widget.file.path)); + } + + @override + void didUpdateWidget(covariant FileElement oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.file.path != widget.file.path) { + _detachJob(); + _attachJob(DownloadManager.instance.jobFor(widget.file.path)); + } + } + + @override + void dispose() { + _detachJob(); + super.dispose(); + } + + void _attachJob(DownloadJob? job) { + _job = job; + if (job == null) return; + job.status.addListener(_onStatusChange); + if (job.isFinished) { + WidgetsBinding.instance.addPostFrameCallback((_) => _onStatusChange()); + } + } + + void _detachJob() { + _job?.status.removeListener(_onStatusChange); + _job = null; + } + + void _onStatusChange() { + if (!mounted) return; + final job = _job; + if (job == null) return; + final status = job.status.value; + if (status is DownloadDone) { + DownloadManager.instance.clear(widget.file.path); + _detachJob(); + AppRoutes.openFileViewer(context, status.localPath); + setState(() {}); + } else if (status is DownloadFailed) { + final message = status.message; + DownloadManager.instance.clear(widget.file.path); + _detachJob(); + setState(() {}); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Download'), + content: Text(message), + ), + ); + } else if (status is DownloadCancelled) { + DownloadManager.instance.clear(widget.file.path); + _detachJob(); + setState(() {}); + } else { + setState(() {}); + } + } + + Future _startDownload() async { + final job = await DownloadManager.instance.start( + remotePath: widget.file.path, + name: widget.file.name, + ); + if (!mounted) return; + if (_job == job) return; + _detachJob(); + _attachJob(job); + setState(() {}); + } + + void _confirmCancel() { + showDialog( + context: context, + builder: (dialogContext) => ConfirmDialog( + title: 'Download abbrechen?', + content: 'Möchtest du den Download abbrechen?', + cancelButton: 'Nein', + confirmButton: 'Ja, Abbrechen', + onConfirm: () => _job?.cancel(), + ), + ); + } + + Widget _subtitle() { + final status = _job?.status.value; + if (status is DownloadInProgress) { return Row( children: [ Container( margin: const EdgeInsets.only(right: 10), child: const Text('Download:'), ), - Expanded( - child: LinearProgressIndicator(value: percent/100), - ), + Expanded(child: LinearProgressIndicator(value: status.percent / 100)), Container( margin: const EdgeInsets.only(left: 10), - child: Text('${percent.round()}%'), + child: Text('${status.percent.round()}%'), ), ], ); @@ -86,101 +140,173 @@ class _FileElementState extends State { : Text('${filesize(widget.file.size)}, ${Jiffy.parseFromDateTime(widget.file.modifiedAt ?? DateTime.now()).fromNow()}'); } + void _onTap() { + if (widget.file.isDirectory) { + AppRoutes.openFolder(context, widget.path.toList()..add(widget.file.name)); + return; + } + if (EndpointData().getEndpointMode() == EndpointMode.stage) { + InfoDialog.show(context, 'Virtuelle Dateien im Staging Prozess können nicht heruntergeladen werden!'); + return; + } + final status = _job?.status.value; + if (status is DownloadInProgress) { + _confirmCancel(); + return; + } + _startDownload(); + } + + // 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'; + + Future _rename() async { + final controller = TextEditingController(text: widget.file.name); + final newName = await showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: const Text('Umbenennen'), + content: TextField( + controller: controller, + decoration: const InputDecoration(labelText: 'Neuer Name'), + autofocus: true, + ), + actions: [ + TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Abbrechen')), + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(controller.text.trim()), + child: const Text('Umbenennen'), + ), + ], + ), + ); + if (newName == null || newName.isEmpty || newName == widget.file.name) return; + + final parent = _parentPathOf(widget.file.path); + final destination = _joinPath(parent, newName, isDirectory: widget.file.isDirectory); + await _runWebdavOp(() async { + final webdav = await WebdavApi.webdav; + await webdav.move(PathUri.parse(widget.file.path), PathUri.parse(destination)); + }, errorTitle: 'Umbenennen fehlgeschlagen'); + } + + void _putOnClipboard({required bool copy}) { + if (copy) { + FileClipboard.instance.copy([widget.file]); + } else { + FileClipboard.instance.cut([widget.file]); + } + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('"${widget.file.name}" zum ${copy ? "Kopieren" : "Verschieben"} bereitgelegt'), + duration: const Duration(seconds: 2), + )); + } + + Future _delete() async { + await showDialog( + context: context, + builder: (context) => ConfirmDialog( + title: 'Element löschen?', + content: 'Das Element wird unwiederruflich gelöscht.', + confirmButton: 'Löschen', + onConfirmAsync: () async { + final webdav = await WebdavApi.webdav; + await webdav.delete(PathUri.parse(widget.file.path)); + widget.refetch(); + }, + ), + ); + } + + Future _runWebdavOp(Future Function() action, {required String errorTitle}) async { + try { + await action(); + widget.refetch(); + } on Object catch (e) { + if (!mounted) return; + await showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: Text(errorTitle), + content: Text(e.toString()), + actions: [TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('OK'))], + ), + ); + } + } + + void _showActionSheet() { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetCtx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const CenteredLeading(Icon(Icons.info_outline)), + title: const Text('Info'), + onTap: () { + Navigator.of(sheetCtx).pop(); + showFileDetailsSheet(context, widget.file); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.drive_file_rename_outline)), + title: const Text('Umbenennen'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _rename(); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.drive_file_move_outline)), + title: const Text('Verschieben'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _putOnClipboard(copy: false); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.copy_outlined)), + title: const Text('Kopieren'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _putOnClipboard(copy: true); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.delete_outline)), + title: const Text('Löschen'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _delete(); + }, + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) => ListTile( - leading: CenteredLeading( - Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined) - ), - title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis), - subtitle: getSubtitle(), - trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null), - onTap: () { - if(widget.file.isDirectory) { - AppRoutes.openFolder(context, widget.path.toList()..add(widget.file.name)); - } else { - if(EndpointData().getEndpointMode() == EndpointMode.stage) { - InfoDialog.show(context, 'Virtuelle Dateien im Staging Prozess können nicht heruntergeladen werden!'); - return; - } - if(widget.file.currentlyDownloading) { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Download abbrechen?', - content: 'Möchtest du den Download abbrechen?', - cancelButton: 'Nein', - confirmButton: 'Ja, Abbrechen', - onConfirm: () { - downloadCore?.then((value) { - if(!value.isCancelled) value.cancel(); - }); - setState(() { - widget.file.currentlyDownloading = false; - percent = 0; - downloadCore = null; - }); - }, - ), - ); - - return; - } - - setState(() { - widget.file.currentlyDownloading = true; - }); - - downloadCore = FileElement.download(context, widget.file.path, widget.file.name, (progress) { - setState(() => percent = progress); - }, (result) { - if(result.type != ResultType.done) { - showDialog(context: context, builder: (context) => AlertDialog( - title: const Text('Download'), - content: Text(result.message), - )); - } - - setState(() { - widget.file.currentlyDownloading = false; - percent = 0; - }); - }); - - } - }, - onLongPress: () { - showDialog(context: context, builder: (context) => SimpleDialog( - children: [ - ListTile( - leading: const Icon(Icons.delete_outline), - title: const Text('Löschen'), - onTap: () { - Navigator.of(context).pop(); - showDialog(context: context, builder: (context) => ConfirmDialog( - title: 'Element löschen?', - content: 'Das Element wird unwiederruflich gelöscht.', - confirmButton: 'Löschen', - onConfirmAsync: () async { - final webdav = await WebdavApi.webdav; - await webdav.delete(PathUri.parse(widget.file.path)); - widget.refetch(); - }, - )); - }, - ), - Visibility( - visible: !kReleaseMode, - child: ListTile( - leading: const Icon(Icons.share_outlined), - title: const Text('Teilen'), - onTap: () { - Navigator.of(context).pop(); - UnimplementedDialog.show(context); - }, - ), - ), - ], - )); - }, - ); + leading: CenteredLeading( + Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined), + ), + title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis), + subtitle: _subtitle(), + trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null), + onTap: _onTap, + onLongPress: _showActionSheet, + ); } diff --git a/lib/view/pages/grade_averages/grade_averages_list_view.dart b/lib/view/pages/grade_averages/grade_averages_list_view.dart index d4152b2..48c0def 100644 --- a/lib/view/pages/grade_averages/grade_averages_list_view.dart +++ b/lib/view/pages/grade_averages/grade_averages_list_view.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../state/app/modules/gradeAverages/bloc/grade_averages_bloc.dart'; -import '../../../state/app/modules/gradeAverages/bloc/grade_averages_event.dart'; +import '../../../state/app/modules/grade_averages/bloc/grade_averages_bloc.dart'; +import '../../../state/app/modules/grade_averages/bloc/grade_averages_event.dart'; class GradeAveragesListView extends StatelessWidget { const GradeAveragesListView({super.key}); diff --git a/lib/view/pages/grade_averages/grade_averages_view.dart b/lib/view/pages/grade_averages/grade_averages_view.dart index 1a49e0f..828536a 100644 --- a/lib/view/pages/grade_averages/grade_averages_view.dart +++ b/lib/view/pages/grade_averages/grade_averages_view.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../state/app/modules/gradeAverages/bloc/grade_averages_bloc.dart'; -import '../../../state/app/modules/gradeAverages/bloc/grade_averages_event.dart'; -import '../../../state/app/modules/gradeAverages/bloc/grade_averages_state.dart'; +import '../../../state/app/modules/grade_averages/bloc/grade_averages_bloc.dart'; +import '../../../state/app/modules/grade_averages/bloc/grade_averages_event.dart'; +import '../../../state/app/modules/grade_averages/bloc/grade_averages_state.dart'; import '../../../widget/confirm_dialog.dart'; import 'grade_averages_list_view.dart'; diff --git a/lib/view/pages/holidays/holidays_view.dart b/lib/view/pages/holidays/holidays_view.dart index 6e9515c..4fa4fac 100644 --- a/lib/view/pages/holidays/holidays_view.dart +++ b/lib/view/pages/holidays/holidays_view.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; -import '../../../state/app/infrastructure/loadableState/loadable_state.dart'; -import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; -import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; +import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; import '../../../state/app/modules/holidays/bloc/holidays_bloc.dart'; import '../../../state/app/modules/holidays/bloc/holidays_event.dart'; import '../../../state/app/modules/holidays/bloc/holidays_state.dart'; diff --git a/lib/view/pages/marianum_dates/marianum_dates_view.dart b/lib/view/pages/marianum_dates/marianum_dates_view.dart index 081d9e8..74e1a84 100644 --- a/lib/view/pages/marianum_dates/marianum_dates_view.dart +++ b/lib/view/pages/marianum_dates/marianum_dates_view.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; -import '../../../state/app/infrastructure/loadableState/loadable_state.dart'; -import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; -import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; -import '../../../state/app/modules/marianumDates/bloc/marianum_dates_bloc.dart'; -import '../../../state/app/modules/marianumDates/bloc/marianum_dates_event.dart'; -import '../../../state/app/modules/marianumDates/bloc/marianum_dates_state.dart'; +import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; +import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart'; +import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_event.dart'; +import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart'; import '../../../widget/animated_time.dart'; import '../../../widget/centered_leading.dart'; import '../../../widget/debug/debug_tile.dart'; diff --git a/lib/view/pages/marianum_message/marianum_message_list_view.dart b/lib/view/pages/marianum_message/marianum_message_list_view.dart index 7164219..637e160 100644 --- a/lib/view/pages/marianum_message/marianum_message_list_view.dart +++ b/lib/view/pages/marianum_message/marianum_message_list_view.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import '../../../routing/app_routes.dart'; -import '../../../state/app/infrastructure/loadableState/loadable_state.dart'; -import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; -import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; -import '../../../state/app/modules/marianumMessage/bloc/marianum_message_bloc.dart'; -import '../../../state/app/modules/marianumMessage/bloc/marianum_message_state.dart'; +import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; +import '../../../state/app/modules/marianum_message/bloc/marianum_message_bloc.dart'; +import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; class MarianumMessageListView extends StatelessWidget { const MarianumMessageListView({super.key}); diff --git a/lib/view/pages/marianum_message/marianum_message_view.dart b/lib/view/pages/marianum_message/marianum_message_view.dart index f95712a..cb518d8 100644 --- a/lib/view/pages/marianum_message/marianum_message_view.dart +++ b/lib/view/pages/marianum_message/marianum_message_view.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../../../state/app/modules/marianumMessage/bloc/marianum_message_state.dart'; +import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; import '../../../widget/confirm_dialog.dart'; class MessageView extends StatefulWidget { diff --git a/lib/view/pages/more/feedback/feedback_dialog.dart b/lib/view/pages/more/feedback/feedback_dialog.dart index 373883c..42d082d 100644 --- a/lib/view/pages/more/feedback/feedback_dialog.dart +++ b/lib/view/pages/more/feedback/feedback_dialog.dart @@ -1,16 +1,17 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; +import 'package:badges/badges.dart' as badges; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:image_picker/image_picker.dart'; import 'package:loader_overlay/loader_overlay.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:badges/badges.dart' as badges; -import '../../../../api/mhsl/server/feedback/addFeedback.dart'; -import '../../../../api/mhsl/server/feedback/addFeedbackParams.dart'; +import '../../../../api/mhsl/server/feedback/add_feedback.dart'; +import '../../../../api/mhsl/server/feedback/add_feedback_params.dart'; import '../../../../model/account_data.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../widget/file_pick.dart'; @@ -129,7 +130,8 @@ class _FeedbackDialogState extends State { child: IconButton( onPressed: () async { context.loaderOverlay.show(); - var imageData = await (await FilePick.galleryPick())?.readAsBytes(); + final picked = await FilePick.multipleGalleryPick(); + final imageData = await picked?.first.readAsBytes(); if(context.mounted) context.loaderOverlay.hide(); setState(() { _image = imageData; @@ -148,26 +150,26 @@ class _FeedbackDialogState extends State { return; } context.loaderOverlay.show(); - AddFeedback( + unawaited(AddFeedback( AddFeedbackParams( user: AccountData().getUserSecret(), feedback: _feedbackInput.text, screenshot: _image != null ? base64Encode(_image!) : null, appVersion: int.parse((await PackageInfo.fromPlatform()).buildNumber), - ) + ), ).run().then((value) { if (!context.mounted) return; Navigator.of(context).pop(); InfoDialog.show(context, 'Danke für dein Feedback!'); context.loaderOverlay.hide(); - }).catchError((error, trace) { + }).catchError((Object error, StackTrace trace) { if (!mounted) return; setState(() { _error = error.toString(); }); if (!context.mounted) return; context.loaderOverlay.hide(); - }); + })); }, child: const Text('Senden'), ) diff --git a/lib/view/pages/overhang.dart b/lib/view/pages/overhang.dart index f27445f..2489fa4 100644 --- a/lib/view/pages/overhang.dart +++ b/lib/view/pages/overhang.dart @@ -2,18 +2,18 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:in_app_review/in_app_review.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../extensions/render_not_null.dart'; +import 'package:in_app_review/in_app_review.dart'; +import '../../extensions/render_not_null.dart'; import '../../routing/app_routes.dart'; import '../../state/app/modules/app_modules.dart'; import '../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../storage/settings.dart' as model; import '../../widget/centered_leading.dart'; import '../../widget/info_dialog.dart'; -import 'settings/data/default_settings.dart'; import 'more/share/select_share_type_dialog.dart'; +import 'settings/data/default_settings.dart'; class Overhang extends StatefulWidget { const Overhang({super.key}); @@ -50,7 +50,11 @@ class _OverhangState extends State { final settings = context.read(); void changeVisibility(Modules module) { var hidden = settings.val(write: true).modulesSettings.hiddenModules; - hidden.contains(module) ? hidden.remove(module) : (hidden.length < 3 ? hidden.add(module) : null); + if (hidden.contains(module)) { + hidden.remove(module); + } else if (hidden.length < 3) { + hidden.add(module); + } } return ReorderableListView( diff --git a/lib/view/pages/settings/data/default_settings.dart b/lib/view/pages/settings/data/default_settings.dart index 49640a1..63e060a 100644 --- a/lib/view/pages/settings/data/default_settings.dart +++ b/lib/view/pages/settings/data/default_settings.dart @@ -3,16 +3,16 @@ import 'dart:io'; import 'package:flutter/material.dart'; import '../../../../state/app/modules/app_modules.dart'; -import '../../../../storage/settings.dart'; import '../../../../storage/dev_tools_settings.dart'; import '../../../../storage/file_settings.dart'; import '../../../../storage/file_view_settings.dart'; -import '../../../../storage/modules_settings.dart'; import '../../../../storage/holidays_settings.dart'; +import '../../../../storage/modules_settings.dart'; import '../../../../storage/notification_settings.dart'; +import '../../../../storage/settings.dart'; import '../../../../storage/talk_settings.dart'; -import '../../../../view/pages/timetable/data/timetable_name_mode.dart'; import '../../../../storage/timetable_settings.dart'; +import '../../../../view/pages/timetable/data/timetable_name_mode.dart'; import '../../files/files.dart'; class DefaultSettings { diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index 6cc6846..7067c22 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -31,9 +31,10 @@ class AccountSection extends StatelessWidget { await prefs.clear(); PaintingBinding.instance.imageCache.clear(); if (!context.mounted) return; - context.read().reset(); - const CacheView().clear(); - AccountData().removeData(context: context); + await context.read().reset(); + await const CacheView().clear(); + if (!context.mounted) return; + await AccountData().removeData(context: context); }, ), ); diff --git a/lib/view/pages/settings/sections/dev_tools_section.dart b/lib/view/pages/settings/sections/dev_tools_section.dart index 95d8efd..7cada5f 100644 --- a/lib/view/pages/settings/sections/dev_tools_section.dart +++ b/lib/view/pages/settings/sections/dev_tools_section.dart @@ -1,8 +1,8 @@ import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart index 7037624..4a01136 100644 --- a/lib/view/pages/talk/chat_list.dart +++ b/lib/view/pages/talk/chat_list.dart @@ -3,20 +3,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_split_view/flutter_split_view.dart'; -import '../../../routing/app_routes.dart'; -import '../../../state/app/infrastructure/loadableState/loadable_state.dart'; -import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; -import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; -import '../../../state/app/modules/chatList/bloc/chat_list_bloc.dart'; -import '../../../state/app/modules/chatList/bloc/chat_list_state.dart'; import '../../../notification/notify_updater.dart'; +import '../../../routing/app_routes.dart'; +import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; +import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../widget/confirm_dialog.dart'; import '../../../widget/placeholder_view.dart'; -import 'widgets/chat_tile.dart'; -import 'widgets/split_view_placeholder.dart'; import 'join_chat.dart'; import 'search_chat.dart'; +import 'widgets/chat_tile.dart'; +import 'widgets/split_view_placeholder.dart'; class ChatList extends StatelessWidget { const ChatList({super.key}); diff --git a/lib/view/pages/talk/chat_view.dart b/lib/view/pages/talk/chat_view.dart index e332150..0b8c78c 100644 --- a/lib/view/pages/talk/chat_view.dart +++ b/lib/view/pages/talk/chat_view.dart @@ -1,19 +1,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../api/marianumcloud/talk/chat/getChatResponse.dart'; -import '../../../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../extensions/date_time.dart'; -import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../state/app/modules/chat/bloc/chat_state.dart'; import '../../../theming/app_theme.dart'; import '../../../widget/clickable_app_bar.dart'; import '../../../widget/user_avatar.dart'; import 'details/chat_info.dart'; +import 'talk_navigator.dart'; import 'widgets/chat_bubble.dart'; import 'widgets/chat_textfield.dart'; -import 'talk_navigator.dart'; class ChatView extends StatefulWidget { final GetRoomResponseObject room; @@ -99,7 +99,7 @@ class _ChatViewState extends State { ), ), ), - body: Container( + body: DecoratedBox( decoration: BoxDecoration( image: DecorationImage( image: const AssetImage('assets/background/chat.png'), @@ -122,7 +122,7 @@ class _ChatViewState extends State { ), ), ), - Container( + ColoredBox( color: Theme.of(context).colorScheme.surface, child: TalkNavigator.isSecondaryVisible(context) ? ChatTextfield(widget.room.token, selfId: widget.selfId) diff --git a/lib/view/pages/talk/data/chat_bubble_styles.dart b/lib/view/pages/talk/data/chat_bubble_styles.dart index bad0822..33db5e7 100644 --- a/lib/view/pages/talk/data/chat_bubble_styles.dart +++ b/lib/view/pages/talk/data/chat_bubble_styles.dart @@ -1,7 +1,7 @@ -import 'package:bubble/bubble.dart'; import 'package:flutter/material.dart'; import '../../../../theming/app_theme.dart'; +import '../widgets/bubble.dart'; extension ColorExtensions on Color { Color invert() { diff --git a/lib/view/pages/talk/data/chat_message.dart b/lib/view/pages/talk/data/chat_message.dart index 358e289..66b5aa1 100644 --- a/lib/view/pages/talk/data/chat_message.dart +++ b/lib/view/pages/talk/data/chat_message.dart @@ -3,8 +3,8 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; -import '../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; -import '../../../../api/marianumcloud/talk/chat/richObjectStringProcessor.dart'; +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; import '../../../../model/account_data.dart'; import '../../../../model/endpoint_data.dart'; import '../../../../utils/url_opener.dart'; diff --git a/lib/view/pages/talk/details/chat_info.dart b/lib/view/pages/talk/details/chat_info.dart index 74afa79..820ee1a 100644 --- a/lib/view/pages/talk/details/chat_info.dart +++ b/lib/view/pages/talk/details/chat_info.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import '../../../../api/marianumcloud/talk/getParticipants/getParticipantsCache.dart'; -import '../../../../api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart'; -import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../../../../api/marianumcloud/talk/get_participants/get_participants_cache.dart'; +import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart'; +import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../widget/large_profile_picture_view.dart'; import '../../../../widget/loading_spinner.dart'; import '../../../../widget/user_avatar.dart'; diff --git a/lib/view/pages/talk/details/message_reactions.dart b/lib/view/pages/talk/details/message_reactions.dart index 29158f3..7f87faf 100644 --- a/lib/view/pages/talk/details/message_reactions.dart +++ b/lib/view/pages/talk/details/message_reactions.dart @@ -1,8 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import '../../../../api/marianumcloud/talk/getReactions/getReactions.dart'; -import '../../../../api/marianumcloud/talk/getReactions/getReactionsResponse.dart'; +import '../../../../api/marianumcloud/talk/get_reactions/get_reactions.dart'; +import '../../../../api/marianumcloud/talk/get_reactions/get_reactions_response.dart'; import '../../../../model/account_data.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/loading_spinner.dart'; diff --git a/lib/view/pages/talk/details/participants_list_view.dart b/lib/view/pages/talk/details/participants_list_view.dart index e58d1e5..e2ec3d7 100644 --- a/lib/view/pages/talk/details/participants_list_view.dart +++ b/lib/view/pages/talk/details/participants_list_view.dart @@ -1,7 +1,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import '../../../../api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart'; +import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart'; import '../../../../widget/user_avatar.dart'; class ParticipantsListView extends StatelessWidget { @@ -10,7 +10,7 @@ class ParticipantsListView extends StatelessWidget { @override Widget build(BuildContext context) { - lastname(participant) => participant.displayName.toString().split(' ').last; + String lastname(participant) => participant.displayName.toString().split(' ').last; final participants = participantsResponse.data .sorted((a, b) { diff --git a/lib/view/pages/talk/join_chat.dart b/lib/view/pages/talk/join_chat.dart index f937bc3..56e99bb 100644 --- a/lib/view/pages/talk/join_chat.dart +++ b/lib/view/pages/talk/join_chat.dart @@ -3,8 +3,8 @@ import 'package:async/async.dart'; import 'package:flutter/material.dart'; import '../../../api/errors/error_mapper.dart'; -import '../../../api/marianumcloud/autocomplete/autocompleteApi.dart'; -import '../../../api/marianumcloud/autocomplete/autocompleteResponse.dart'; +import '../../../api/marianumcloud/autocomplete/autocomplete_api.dart'; +import '../../../api/marianumcloud/autocomplete/autocomplete_response.dart'; import '../../../model/endpoint_data.dart'; import '../../../widget/app_progress_indicator.dart'; import '../../../widget/placeholder_view.dart'; diff --git a/lib/view/pages/talk/search_chat.dart b/lib/view/pages/talk/search_chat.dart index 68976fc..0a58622 100644 --- a/lib/view/pages/talk/search_chat.dart +++ b/lib/view/pages/talk/search_chat.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; -import '../../../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../../../api/marianumcloud/talk/room/get_room_response.dart'; import 'widgets/chat_tile.dart'; -class SearchChat extends SearchDelegate { +class SearchChat extends SearchDelegate { List chats; SearchChat(this.chats); diff --git a/lib/view/pages/talk/widgets/answer_reference.dart b/lib/view/pages/talk/widgets/answer_reference.dart index 825ad18..8171d14 100644 --- a/lib/view/pages/talk/widgets/answer_reference.dart +++ b/lib/view/pages/talk/widgets/answer_reference.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import '../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; -import '../../../../api/marianumcloud/talk/chat/richObjectStringProcessor.dart'; +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; import '../data/chat_bubble_styles.dart'; class AnswerReference extends StatelessWidget { diff --git a/lib/view/pages/talk/widgets/bubble.dart b/lib/view/pages/talk/widgets/bubble.dart new file mode 100644 index 0000000..408564d --- /dev/null +++ b/lib/view/pages/talk/widgets/bubble.dart @@ -0,0 +1,87 @@ +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.elevation = 0, + this.margin = const BubbleEdges.only(), + this.padding = const BubbleEdges.all(8), + this.alignment = Alignment.centerLeft, + this.nip = BubbleNip.none, + this.borderRadius = 12, + }); + + final Color? color; + final double borderWidth; + final double elevation; + final BubbleEdges margin; + final BubbleEdges padding; + final Alignment alignment; + final BubbleNip nip; + final double borderRadius; +} + +/// Lightweight chat bubble. Replaces the abandoned `bubble` package: renders a +/// rounded container with optional shadow / border. The nip is conveyed by +/// flattening one corner so the bubble visually anchors to the speaker side. +class Bubble extends StatelessWidget { + const Bubble({required this.child, required this.style, super.key}); + + final Widget child; + final BubbleStyle style; + + BorderRadius _radius() { + final r = Radius.circular(style.borderRadius); + final flat = Radius.zero; + switch (style.nip) { + case BubbleNip.leftTop: + return BorderRadius.only(topLeft: flat, topRight: r, bottomLeft: r, bottomRight: r); + case BubbleNip.rightBottom: + return BorderRadius.only(topLeft: r, topRight: r, bottomLeft: r, bottomRight: flat); + case BubbleNip.none: + return BorderRadius.all(r); + } + } + + @override + Widget build(BuildContext context) { + final radius = _radius(); + return Align( + alignment: style.alignment, + child: Container( + margin: style.margin.toEdgeInsets(), + decoration: BoxDecoration( + color: style.color, + borderRadius: radius, + border: style.borderWidth > 0 + ? Border.all(color: Theme.of(context).dividerColor, width: style.borderWidth) + : null, + boxShadow: style.elevation > 0 + ? [BoxShadow(color: Colors.black26, blurRadius: style.elevation * 2, offset: Offset(0, style.elevation))] + : null, + ), + padding: style.padding.toEdgeInsets(), + child: child, + ), + ); + } +} diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index ede8081..1ea1dab 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -1,25 +1,24 @@ -import 'package:bubble/bubble.dart'; -import 'package:flowder/flowder.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:jiffy/jiffy.dart'; -import 'package:open_filex/open_filex.dart'; -import '../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; -import '../../../../api/marianumcloud/talk/deleteReactMessage/deleteReactMessage.dart'; -import '../../../../api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.dart'; -import '../../../../api/marianumcloud/talk/getPoll/getPollState.dart'; -import '../../../../api/marianumcloud/talk/reactMessage/reactMessage.dart'; -import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart'; -import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message.dart'; +import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart'; +import '../../../../api/marianumcloud/talk/get_poll/get_poll_state.dart'; +import '../../../../api/marianumcloud/talk/react_message/react_message.dart'; +import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart'; +import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../extensions/text.dart'; +import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../../../../utils/download_manager.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/loading_spinner.dart'; -import '../../files/widgets/file_element.dart'; import '../data/chat_bubble_styles.dart'; import '../data/chat_message.dart'; import 'answer_reference.dart'; +import 'bubble.dart'; import 'chat_message_options_dialog.dart'; import 'poll_options_list.dart'; @@ -53,12 +52,95 @@ class ChatBubble extends StatefulWidget { class _ChatBubbleState extends State with SingleTickerProviderStateMixin { late ChatMessage message; - double downloadProgress = 0; - Future? downloadCore; + DownloadJob? _job; late Offset _position = const Offset(0, 0); late Offset _dragStartPosition = Offset.zero; + @override + void initState() { + super.initState(); + final filePath = widget.bubbleData.messageParameters?['file']?.path; + if (filePath != null) _attachJob(DownloadManager.instance.jobFor(filePath)); + } + + @override + void dispose() { + _detachJob(); + super.dispose(); + } + + void _attachJob(DownloadJob? job) { + _job = job; + if (job == null) return; + job.status.addListener(_onStatusChange); + if (job.isFinished) { + WidgetsBinding.instance.addPostFrameCallback((_) => _onStatusChange()); + } + } + + void _detachJob() { + _job?.status.removeListener(_onStatusChange); + _job = null; + } + + void _onStatusChange() { + if (!mounted) return; + final job = _job; + if (job == null) return; + final status = job.status.value; + if (status is DownloadDone) { + DownloadManager.instance.clear(job.remotePath); + _detachJob(); + AppRoutes.openFileViewer(context, status.localPath); + setState(() {}); + } else if (status is DownloadFailed) { + final message = status.message; + DownloadManager.instance.clear(job.remotePath); + _detachJob(); + setState(() {}); + showDialog(context: context, builder: (context) => AlertDialog(content: Text(message))); + } else if (status is DownloadCancelled) { + DownloadManager.instance.clear(job.remotePath); + _detachJob(); + setState(() {}); + } else { + setState(() {}); + } + } + + Future _startFileDownload() async { + final file = message.file; + final filePath = file?.path; + if (file == null || filePath == null) return; + final job = await DownloadManager.instance.start(remotePath: filePath, name: file.name); + if (!mounted) return; + if (_job == job) return; + _detachJob(); + _attachJob(job); + setState(() {}); + } + + void _confirmCancel() { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Download abbrechen?'), + content: const Text('Möchtest du den Download abbrechen?'), + actions: [ + TextButton(onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Nein')), + TextButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + _job?.cancel(); + }, + child: const Text('Ja, Abbrechen'), + ), + ], + ), + ); + } + BubbleStyle getStyle() { var styles = ChatBubbleStyles(context); if(widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment) { @@ -162,53 +244,12 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM )); } - if(message.file == null) return; - - if(downloadProgress > 0) { - showDialog(context: context, builder: (context) => AlertDialog( - title: const Text('Download abbrechen?'), - content: const Text('Möchtest du den Download abbrechen?'), - actions: [ - TextButton(onPressed: () { - Navigator.of(context).pop(); - }, child: const Text('Nein')), - TextButton(onPressed: () { - downloadCore?.then((value) { - if(!value.isCancelled) value.cancel(); - if (!context.mounted) return; - Navigator.of(context).pop(); - }); - setState(() { - downloadProgress = 0; - downloadCore = null; - }); - }, child: const Text('Ja, Abbrechen')) - ], - )); - - return; + if (message.file == null) return; + if (_job?.status.value is DownloadInProgress) { + _confirmCancel(); + } else { + _startFileDownload(); } - - setState(() { - downloadProgress = 1; - }); - downloadCore = FileElement.download(context, message.file!.path!, message.file!.name, (progress) { - if(progress > 1) { - setState(() { - downloadProgress = progress; - }); - } - }, (result) { - setState(() { - downloadProgress = 0; - }); - - if(result.type != ResultType.done) { - showDialog(context: context, builder: (context) => AlertDialog( - content: Text(result.message), - )); - } - }); }, child: Transform.translate( offset: _position, @@ -270,15 +311,18 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM ) ), ), - Visibility( - visible: downloadProgress > 0, - child: Positioned( + if (_job?.status.value is DownloadInProgress) + Positioned( bottom: 0, right: 0, left: 0, - child: LinearProgressIndicator(value: downloadProgress == 1 ? null : downloadProgress/100), + child: LinearProgressIndicator( + value: () { + final s = _job!.status.value as DownloadInProgress; + return s.percent <= 0 ? null : s.percent / 100; + }(), + ), ), - ), ], ), ), 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 7f7fa39..f5c5802 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -4,11 +4,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; import '../../../../api/marianumcloud/talk/actions/talk_actions.dart'; -import '../../../../api/marianumcloud/talk/reactMessage/reactMessage.dart'; -import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart'; -import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../api/marianumcloud/talk/react_message/react_message.dart'; +import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart'; +import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../widget/app_progress_indicator.dart'; diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index 266958d..a821cf1 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -5,11 +6,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nextcloud/nextcloud.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import '../../../../api/marianumcloud/files-sharing/fileSharingApi.dart'; -import '../../../../api/marianumcloud/files-sharing/fileSharingApiParams.dart'; -import '../../../../api/marianumcloud/talk/sendMessage/sendMessage.dart'; -import '../../../../api/marianumcloud/talk/sendMessage/sendMessageParams.dart'; -import '../../../../api/marianumcloud/webdav/webdavApi.dart'; +import '../../../../api/marianumcloud/files_sharing/file_sharing_api.dart'; +import '../../../../api/marianumcloud/files_sharing/file_sharing_api_params.dart'; +import '../../../../api/marianumcloud/talk/send_message/send_message.dart'; +import '../../../../api/marianumcloud/talk/send_message/send_message_params.dart'; +import '../../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../widget/async_action_button.dart'; @@ -51,10 +52,10 @@ class _ChatTextfieldState extends State { if (paths == null) return; const shareFolder = 'MarianumMobile'; - WebdavApi.webdav.then((webdav) => webdav.mkcol(PathUri.parse('/$shareFolder'))); + unawaited(WebdavApi.webdav.then((webdav) => webdav.mkcol(PathUri.parse('/$shareFolder')))); if (!mounted) return; - pushScreen( + unawaited(pushScreen( context, withNavBar: false, screen: FilesUploadDialog( @@ -63,7 +64,7 @@ class _ChatTextfieldState extends State { onUploadFinished: (uploaded) => share(shareFolder, uploaded), uniqueNames: true, ), - ); + )); } void _setDraft(String text) { diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index b4f9c8d..0f44672 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -5,13 +5,13 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:jiffy/jiffy.dart'; import '../../../../api/marianumcloud/talk/actions/talk_actions.dart'; -import '../../../../api/marianumcloud/talk/chat/richObjectStringProcessor.dart'; -import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; -import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarker.dart'; -import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dart'; +import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.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 '../../../../model/account_data.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; -import '../../../../state/app/modules/chatList/bloc/chat_list_bloc.dart'; +import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/debug/debug_tile.dart'; diff --git a/lib/view/pages/talk/widgets/poll_options_list.dart b/lib/view/pages/talk/widgets/poll_options_list.dart index 4bade65..44f4162 100644 --- a/lib/view/pages/talk/widgets/poll_options_list.dart +++ b/lib/view/pages/talk/widgets/poll_options_list.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; -import '../../../../api/marianumcloud/talk/getPoll/getPollStateResponse.dart'; +import '../../../../api/marianumcloud/talk/get_poll/get_poll_state_response.dart'; import '../../../../utils/url_opener.dart'; class PollOptionsList extends StatefulWidget { 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 34479ca..caa6e62 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 @@ -6,7 +6,7 @@ import 'package:jiffy/jiffy.dart'; import 'package:rrule_generator/rrule_generator.dart'; import 'package:time_range_picker/time_range_picker.dart'; -import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../extensions/date_time.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../widget/focus_behaviour.dart'; @@ -151,6 +151,7 @@ class _CustomEventEditDialogState extends State { selectedColor: Theme.of(context).primaryColor, ticks: 24, ); + if (range is! TimeRange) return; setState(() { _startTime = range.startTime; _endTime = range.endTime; diff --git a/lib/view/pages/timetable/custom_events/custom_events_view.dart b/lib/view/pages/timetable/custom_events/custom_events_view.dart index a1c6595..a7c4272 100644 --- a/lib/view/pages/timetable/custom_events/custom_events_view.dart +++ b/lib/view/pages/timetable/custom_events/custom_events_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; -import '../../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; +import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../../widget/centered_leading.dart'; diff --git a/lib/view/pages/timetable/data/arbitrary_appointment.dart b/lib/view/pages/timetable/data/arbitrary_appointment.dart index d9bb91a..6d2320a 100644 --- a/lib/view/pages/timetable/data/arbitrary_appointment.dart +++ b/lib/view/pages/timetable/data/arbitrary_appointment.dart @@ -1,5 +1,5 @@ -import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; -import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; sealed class ArbitraryAppointment { const ArbitraryAppointment(); diff --git a/lib/view/pages/timetable/data/lesson_period_schedule.dart b/lib/view/pages/timetable/data/lesson_period_schedule.dart index b163226..01b3f47 100644 --- a/lib/view/pages/timetable/data/lesson_period_schedule.dart +++ b/lib/view/pages/timetable/data/lesson_period_schedule.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart'; +import '../../../../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; class LessonPeriod { diff --git a/lib/view/pages/timetable/data/lesson_status.dart b/lib/view/pages/timetable/data/lesson_status.dart index 933f3cb..39eeb37 100644 --- a/lib/view/pages/timetable/data/lesson_status.dart +++ b/lib/view/pages/timetable/data/lesson_status.dart @@ -1,4 +1,4 @@ -import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; enum LessonStatus { cancelled, diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index 3d14aa8..29cebf2 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -1,16 +1,16 @@ import 'package:collection/collection.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; -import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; -import '../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; -import '../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; -import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart'; +import '../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart'; +import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; import '../../../../storage/timetable_settings.dart'; -import 'timetable_name_mode.dart'; import '../custom_events/custom_event_colors.dart'; import 'arbitrary_appointment.dart'; import 'lesson_color.dart'; import 'lesson_status.dart'; +import 'timetable_name_mode.dart'; import 'webuntis_time.dart'; class TimetableAppointmentFactory { diff --git a/lib/view/pages/timetable/details/_bottom_sheet.dart b/lib/view/pages/timetable/details/_bottom_sheet.dart deleted file mode 100644 index c50f3f0..0000000 --- a/lib/view/pages/timetable/details/_bottom_sheet.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:bottom_sheet/bottom_sheet.dart'; -import 'package:flutter/material.dart'; - -void showAppointmentBottomSheet( - BuildContext context, { - required Widget Function(BuildContext context) header, - required SliverChildListDelegate Function(BuildContext context) body, -}) { - showStickyFlexibleBottomSheet( - minHeight: 0, - initHeight: 0.4, - maxHeight: 0.7, - anchors: [0, 0.4, 0.7], - isSafeArea: true, - maxHeaderHeight: 100, - context: context, - headerBuilder: (context, _) => header(context), - bodyBuilder: (context, _) => body(context), - ); -} diff --git a/lib/view/pages/timetable/details/bottom_sheet.dart b/lib/view/pages/timetable/details/bottom_sheet.dart new file mode 100644 index 0000000..d834a09 --- /dev/null +++ b/lib/view/pages/timetable/details/bottom_sheet.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +void showAppointmentBottomSheet( + BuildContext context, { + required Widget Function(BuildContext context) header, + required SliverChildListDelegate Function(BuildContext context) body, +}) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (sheetContext) => DraggableScrollableSheet( + expand: false, + initialChildSize: 0.4, + minChildSize: 0.2, + maxChildSize: 0.7, + snap: true, + snapSizes: const [0.4], + builder: (_, scrollController) => CustomScrollView( + controller: scrollController, + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: _StickyHeader(child: header(sheetContext)), + ), + SliverList(delegate: body(sheetContext)), + ], + ), + ), + ); +} + +class _StickyHeader extends SliverPersistentHeaderDelegate { + _StickyHeader({required this.child}); + final Widget child; + + @override + double get minExtent => 100; + @override + double get maxExtent => 100; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => Material( + color: Theme.of(context).colorScheme.surface, + child: SizedBox.expand(child: child), + ); + + @override + bool shouldRebuild(covariant _StickyHeader oldDelegate) => oldDelegate.child != child; +} diff --git a/lib/view/pages/timetable/details/custom_event_sheet.dart b/lib/view/pages/timetable/details/custom_event_sheet.dart index 3675abf..ce52d8c 100644 --- a/lib/view/pages/timetable/details/custom_event_sheet.dart +++ b/lib/view/pages/timetable/details/custom_event_sheet.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; import 'package:rrule/rrule.dart'; -import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/debug/debug_tile.dart'; import '../custom_events/custom_event_edit_dialog.dart'; -import '_bottom_sheet.dart'; +import 'bottom_sheet.dart'; import 'delete_custom_event.dart'; class CustomEventSheet { diff --git a/lib/view/pages/timetable/details/delete_custom_event.dart b/lib/view/pages/timetable/details/delete_custom_event.dart index 7d29e86..7361a70 100644 --- a/lib/view/pages/timetable/details/delete_custom_event.dart +++ b/lib/view/pages/timetable/details/delete_custom_event.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../widget/confirm_dialog.dart'; diff --git a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart index 7adfee6..13885c9 100644 --- a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart +++ b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart @@ -3,15 +3,15 @@ import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; -import '../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; -import '../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; -import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart'; +import '../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart'; +import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/unimplemented_dialog.dart'; -import '_bottom_sheet.dart'; +import 'bottom_sheet.dart'; class WebuntisLessonSheet { static void show(BuildContext context, TimetableBloc bloc, Appointment appointment, GetTimetableResponseObject lesson) { diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index ee4ed3a..cd119e6 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -4,10 +4,10 @@ import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../extensions/date_time.dart'; import '../../../routing/app_routes.dart'; -import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +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/settings/bloc/settings_cubit.dart'; import '../../../storage/timetable_settings.dart'; import 'custom_events/custom_event_edit_dialog.dart'; import 'data/arbitrary_appointment.dart'; diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart index e34a5d5..d185f5c 100644 --- a/lib/view/pages/timetable/widgets/appointment_tile.dart +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -56,7 +56,7 @@ class AppointmentTile extends StatelessWidget { ), if (crossedOut) Positioned.fill( - child: Container( + child: DecoratedBox( decoration: BoxDecoration( border: Border.all(width: 2, color: Colors.red.withAlpha(200)), borderRadius: const BorderRadius.all(Radius.circular(7)), diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 18ed146..85ce55e 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -392,7 +392,7 @@ class _PeriodLabel extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { final showTimes = constraints.maxHeight >= 38; - return Container( + return DecoratedBox( decoration: BoxDecoration( border: Border(top: BorderSide(color: dividerColor, width: 0.5)), ), @@ -561,7 +561,7 @@ class _DayColumn extends StatelessWidget { return GestureDetector( behavior: HitTestBehavior.translucent, onLongPressStart: (details) => _handleLongPress(details, dayAppointments), - child: Container( + child: DecoratedBox( decoration: BoxDecoration( color: isToday ? theme.colorScheme.primary.withAlpha(14) : null, border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)), diff --git a/lib/view/pages/timetable/widgets/special_regions_builder.dart b/lib/view/pages/timetable/widgets/special_regions_builder.dart index 2007f46..bffdfb7 100644 --- a/lib/view/pages/timetable/widgets/special_regions_builder.dart +++ b/lib/view/pages/timetable/widgets/special_regions_builder.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; -import '../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; +import '../../../../api/webuntis/queries/get_holidays/get_holidays_response.dart'; import '../../../../extensions/date_time.dart'; import '../data/calendar_layout.dart'; import '../data/lesson_period_schedule.dart'; diff --git a/lib/widget/animated_time.dart b/lib/widget/animated_time.dart index ea9991e..2c00b18 100644 --- a/lib/widget/animated_time.dart +++ b/lib/widget/animated_time.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:animated_digit/animated_digit.dart'; import 'package:flutter/material.dart'; class AnimatedTime extends StatefulWidget { @@ -42,14 +41,18 @@ class _AnimatedTimeState extends State { ], ); - AnimatedDigitWidget buildWidget(int value) => AnimatedDigitWidget( - value: value, - duration: const Duration(milliseconds: 100), - textStyle: TextStyle( - fontSize: 15, - color: Theme.of(context).colorScheme.onSurface, - ), - ); + Widget buildWidget(int value) => AnimatedSwitcher( + duration: const Duration(milliseconds: 100), + transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child), + child: Text( + '$value', + key: ValueKey(value), + style: TextStyle( + fontSize: 15, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ); @override void dispose() { diff --git a/lib/widget/breaker/breaker.dart b/lib/widget/breaker/breaker.dart index 9c9186e..006844e 100644 --- a/lib/widget/breaker/breaker.dart +++ b/lib/widget/breaker/breaker.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; +import '../../api/mhsl/breaker/get_breakers/get_breakers_response.dart'; import '../../state/app/modules/breaker/bloc/breaker_bloc.dart'; import '../../widget/placeholder_view.dart'; diff --git a/lib/widget/debug/cache_view.dart b/lib/widget/debug/cache_view.dart index c92132b..beb46ce 100644 --- a/lib/widget/debug/cache_view.dart +++ b/lib/widget/debug/cache_view.dart @@ -7,7 +7,7 @@ import 'package:jiffy/jiffy.dart'; import 'package:localstore/localstore.dart'; import '../../../widget/placeholder_view.dart'; -import '../../api/requestCache.dart'; +import '../../api/request_cache.dart'; import 'json_viewer.dart'; class CacheView extends StatefulWidget { @@ -21,9 +21,9 @@ class CacheView extends StatefulWidget { } Future totalSize() async { - var data = await Localstore.instance.collection(RequestCache.collection).get(); - if(data!.length <= 1) return jsonEncode(data.values.first).length * 8; - return data.values.reduce((a, b) => jsonEncode(a).length + jsonEncode(b).length) * 8; + final data = await Localstore.instance.collection(RequestCache.collection).get(); + if (data == null || data.isEmpty) return 0; + return data.values.fold(0, (sum, value) => sum + jsonEncode(value).length) * 8; } } @@ -49,15 +49,16 @@ class _CacheViewState extends State { return ListView.builder( itemCount: snapshot.data!.length, itemBuilder: (context, index) { - Map element = snapshot.data![snapshot.data!.keys.elementAt(index)]; - var filename = snapshot.data!.keys.elementAt(index).split('/').last; + final key = snapshot.data!.keys.elementAt(index); + final element = snapshot.data![key] as Map; + final filename = key.split('/').last; return ListTile( leading: const Icon(Icons.text_snippet_outlined), title: Text(filename), - subtitle: Text("${filesize(jsonEncode(element).length * 8)}, ${Jiffy.parseFromMillisecondsSinceEpoch(element['lastupdate']).fromNow()}"), + subtitle: Text('${filesize(jsonEncode(element).length * 8)}, ${Jiffy.parseFromMillisecondsSinceEpoch(element['lastupdate'] as int).fromNow()}'), trailing: const Icon(Icons.arrow_right), - onTap: () => JsonViewer.asDialog(context, jsonDecode(element['json'])), + onTap: () => JsonViewer.asDialog(context, jsonDecode(element['json'] as String) as Map), ); }, ); diff --git a/lib/widget/debug/json_viewer.dart b/lib/widget/debug/json_viewer.dart index 389ddc5..71f85ee 100644 --- a/lib/widget/debug/json_viewer.dart +++ b/lib/widget/debug/json_viewer.dart @@ -1,6 +1,7 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:pretty_json/pretty_json.dart'; class JsonViewer extends StatelessWidget { final String title; @@ -19,7 +20,9 @@ class JsonViewer extends StatelessWidget { ), ); - static String format(Map jsonInput) => prettyJson(jsonInput, indent: 2); + static final _encoder = const JsonEncoder.withIndent(' '); + + static String format(Map jsonInput) => _encoder.convert(jsonInput); static void asDialog(BuildContext context, Map dataMap) { showDialog(context: context, builder: (context) => AlertDialog( diff --git a/lib/widget/file_pick.dart b/lib/widget/file_pick.dart index b9d7dbb..0e678f3 100644 --- a/lib/widget/file_pick.dart +++ b/lib/widget/file_pick.dart @@ -1,29 +1,19 @@ - import 'package:file_picker/file_picker.dart'; import 'package:image_picker/image_picker.dart'; class FilePick { static final _picker = ImagePicker(); - static Future galleryPick() async { - final pickedImage = await _picker.pickImage(source: ImageSource.gallery); - if (pickedImage != null) { - return pickedImage; - } - return null; - } - static Future?> multipleGalleryPick() async { final pickedImages = await _picker.pickMultiImage(); - if(pickedImages.isNotEmpty) { - return pickedImages; - } - return null; + return pickedImages.isNotEmpty ? pickedImages : null; } + static Future cameraPick() => _picker.pickImage(source: ImageSource.camera); + static Future?> documentPick() async { - var result = await FilePicker.platform.pickFiles(allowMultiple: true); - var paths = result?.files.nonNulls.map((e) => e.path).toList(); + final result = await FilePicker.pickFiles(allowMultiple: true); + final paths = result?.files.nonNulls.map((e) => e.path).toList(); return paths?.nonNulls.toList(); } } diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index 88bb8f8..06ce484 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -1,16 +1,17 @@ +import 'dart:async'; import 'dart:io'; import 'dart:math'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:open_filex/open_filex.dart'; import 'package:photo_view/photo_view.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:share_plus/share_plus.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import '../routing/app_routes.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart'; -import '../utils/file_saver.dart'; import 'info_dialog.dart'; import 'placeholder_view.dart'; import 'share_position_origin.dart'; @@ -30,6 +31,55 @@ enum FileViewingActions { save } +/// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal +/// LayoutBuilder calls `localToGlobal` during build, which asserts when an +/// ancestor RenderTransform (from the page-push animation) is still mid-layout. +/// We wait for the route's enter animation to complete before mounting it. +class _DeferredPdfViewer extends StatefulWidget { + const _DeferredPdfViewer({required this.path}); + final String path; + + @override + State<_DeferredPdfViewer> createState() => _DeferredPdfViewerState(); +} + +class _DeferredPdfViewerState extends State<_DeferredPdfViewer> { + bool _ready = false; + Animation? _routeAnimation; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_ready || _routeAnimation != null) return; + final animation = ModalRoute.of(context)?.animation; + if (animation == null || animation.isCompleted) { + _ready = true; + return; + } + _routeAnimation = animation..addStatusListener(_onAnimationStatus); + } + + void _onAnimationStatus(AnimationStatus status) { + if (status == AnimationStatus.completed && mounted) { + setState(() => _ready = true); + } + } + + @override + void dispose() { + _routeAnimation?.removeStatusListener(_onAnimationStatus); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_ready) { + return const Center(child: CircularProgressIndicator()); + } + return SfPdfViewer.file(File(widget.path)); + } +} + class _FileViewerState extends State { PhotoViewController photoViewController = PhotoViewController(); @@ -44,7 +94,7 @@ class _FileViewerState extends State { @override Widget build(BuildContext context) { - AppBar appbar({List actions = const []}) => AppBar( + AppBar appbar({List actions = const []}) => AppBar( title: Text(widget.path.split('/').last), actions: [ ...actions, @@ -55,17 +105,26 @@ class _FileViewerState extends State { AppRoutes.openFileViewer(context, widget.path, openExternal: true); break; case FileViewingActions.share: - SharePlus.instance.share( - ShareParams( - files: [XFile(widget.path)], - sharePositionOrigin: SharePositionOrigin.get(context) - ) - ); + unawaited(SharePlus.instance.share( + ShareParams( + files: [XFile(widget.path)], + sharePositionOrigin: SharePositionOrigin.get(context), + ), + )); break; case FileViewingActions.save: - await FileSaver.writeBytes(await File(widget.path).readAsBytes(), widget.path.split('/').last); - if(!context.mounted) return; - InfoDialog.show(context, 'Die Datei wurde im Downloads Ordner gespeichert.'); + try { + final bytes = await File(widget.path).readAsBytes(); + final saved = await FilePicker.saveFile( + fileName: widget.path.split('/').last, + bytes: bytes, + ); + if (!context.mounted) return; + if (saved != null) InfoDialog.show(context, 'Datei gespeichert.'); + } on Object catch (e) { + if (!context.mounted) return; + InfoDialog.show(context, 'Speichern fehlgeschlagen: $e'); + } break; } }, @@ -86,7 +145,7 @@ class _FileViewerState extends State { dense: true, ), ), - if(Platform.isAndroid) const PopupMenuItem( + const PopupMenuItem( value: FileViewingActions.save, child: ListTile( leading: Icon(Icons.save_alt_outlined), @@ -129,9 +188,7 @@ class _FileViewerState extends State { case 'pdf': return Scaffold( appBar: appbar(), - body: SfPdfViewer.file( - File(widget.path), - ), + body: _DeferredPdfViewer(path: widget.path), ); default: diff --git a/pubspec.yaml b/pubspec.yaml index 776b411..1798def 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,7 @@ version: 0.1.7+46 environment: sdk: ">=3.8.0 <4.0.0" +# nextcloud (custom Git fork) pins intl ^0.18.0; Flutter 3.41 needs ^0.20.2. dependency_overrides: intl: 0.20.2 @@ -17,33 +18,22 @@ dependencies: flutter_localizations: sdk: flutter - animated_digit: ^3.2.3 async: ^2.11.0 badges: ^3.1.2 - bloc: ^9.0.0 - bottom_sheet: ^4.0.4 - bubble: ^1.2.1 cached_network_image: ^3.4.1 collection: ^1.19.0 connectivity_plus: ^7.1.0 crypto: ^3.0.6 - cupertino_icons: ^1.0.8 device_info_plus: ^12.4.0 - dio: ^4.0.6 - easy_debounce: ^2.0.3 + dio: ^5.9.2 emoji_picker_flutter: ^4.3.0 - fast_rsa: ^3.7.1 - file_picker: ^10.3.2 + file_picker: ^11.0.2 filesize: ^2.0.1 firebase_core: ^4.1.0 - firebase_in_app_messaging: ^0.9.0+1 firebase_messaging: ^16.0.1 - flowder: - git: - url: https://github.com/Harsh223/flowder.git flutter_app_badge: ^2.0.2 flutter_bloc: ^9.0.0 - flutter_secure_storage: ^9.2.4 + flutter_secure_storage: ^10.0.0 intl: ^0.20.2 flutter_linkify: ^6.0.0 flutter_local_notifications: ^21.0.0 @@ -68,22 +58,18 @@ dependencies: open_filex: ^4.7.0 package_info_plus: ^9.0.1 path_provider: ^2.1.5 - permission_handler: ^12.0.1 persistent_bottom_nav_bar_v2: ^6.1.0 photo_view: ^0.15.0 - pretty_json: ^2.0.0 - provider: ^6.1.2 qr_flutter: ^4.1.0 rrule: ^0.2.17 rrule_generator: ^0.9.0 screen_brightness: ^2.1.7 share_plus: ^12.0.2 shared_preferences: ^2.3.5 - syncfusion_flutter_calendar: ^33.1.46 - syncfusion_flutter_pdfviewer: ^33.1.46 + syncfusion_flutter_calendar: ^33.2.5 + syncfusion_flutter_pdfviewer: ^33.2.5 time_range_picker: ^2.3.0 url_launcher: ^6.3.1 - uuid: ^4.5.1 enough_icalendar: ^0.17.0 dev_dependencies: From 4e1272aba93c89121bcab42c95e05c9dc79eaa32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 6 May 2026 11:58:50 +0200 Subject: [PATCH 11/23] claude refactorings, flutter best practices, platform dependent changes, general cleanup --- analysis_options.yaml | 102 +++-- android/app/build.gradle | 2 +- lib/api/{apiError.dart => api_error.dart} | 0 lib/api/{apiParams.dart => api_params.dart} | 0 lib/api/{apiRequest.dart => api_request.dart} | 0 .../{apiResponse.dart => api_response.dart} | 0 lib/api/errors/error_mapper.dart | 6 +- lib/api/errors/talk_exception.dart | 2 +- lib/api/errors/webuntis_exception.dart | 2 +- .../{getHolidays.dart => get_holidays.dart} | 2 +- ...daysCache.dart => get_holidays_cache.dart} | 6 +- ...sponse.dart => get_holidays_response.dart} | 4 +- ...se.g.dart => get_holidays_response.g.dart} | 2 +- ...completeApi.dart => autocomplete_api.dart} | 5 +- ...sponse.dart => autocomplete_response.dart} | 2 +- ...se.g.dart => autocomplete_response.g.dart} | 2 +- .../file_sharing_api.dart} | 2 +- .../file_sharing_api_params.dart} | 2 +- .../file_sharing_api_params.g.dart} | 2 +- .../talk/actions/talk_actions.dart | 6 +- .../talk/chat/{getChat.dart => get_chat.dart} | 11 +- ...{getChatCache.dart => get_chat_cache.dart} | 8 +- ...etChatParams.dart => get_chat_params.dart} | 4 +- ...atParams.g.dart => get_chat_params.g.dart} | 2 +- ...atResponse.dart => get_chat_response.dart} | 14 +- ...sponse.g.dart => get_chat_response.g.dart} | 2 +- ...dart => rich_object_string_processor.dart} | 2 +- .../create_room.dart} | 6 +- .../create_room_params.dart} | 4 +- .../create_room_params.g.dart} | 2 +- .../delete_react_message.dart} | 8 +- .../delete_react_message_params.dart} | 4 +- .../delete_react_message_params.g.dart} | 2 +- .../get_participants.dart} | 9 +- .../get_participants_cache.dart} | 6 +- .../get_participants_response.dart} | 4 +- .../get_participants_response.g.dart} | 2 +- .../get_poll_state.dart} | 9 +- .../get_poll_state_response.dart} | 4 +- .../get_poll_state_response.g.dart} | 2 +- .../get_reactions.dart} | 11 +- .../get_reactions_response.dart} | 4 +- .../get_reactions_response.g.dart} | 2 +- .../react_message.dart} | 8 +- .../react_message_params.dart} | 4 +- .../react_message_params.g.dart} | 2 +- .../talk/room/{getRoom.dart => get_room.dart} | 11 +- ...{getRoomCache.dart => get_room_cache.dart} | 8 +- ...etRoomParams.dart => get_room_params.dart} | 4 +- ...omParams.g.dart => get_room_params.g.dart} | 2 +- ...omResponse.dart => get_room_response.dart} | 6 +- ...sponse.g.dart => get_room_response.g.dart} | 2 +- .../send_message.dart} | 8 +- .../send_message_params.dart} | 4 +- .../send_message_params.g.dart} | 2 +- .../set_read_marker.dart} | 6 +- .../set_read_marker_params.dart} | 4 +- .../set_read_marker_params.g.dart} | 2 +- .../talk/{talkApi.dart => talk_api.dart} | 6 +- .../talk/{talkError.dart => talk_error.dart} | 0 .../queries/downloadFile/downloadFile.dart | 22 - .../queries/download_file/download_file.dart | 16 + .../download_file_params.dart} | 4 +- .../download_file_params.g.dart} | 2 +- .../download_file_response.dart} | 2 +- .../download_file_response.g.dart} | 2 +- .../queries/listFiles/listFilesCache.dart | 25 -- .../cacheable_file.dart} | 6 +- .../cacheable_file.g.dart} | 2 +- .../list_files.dart} | 18 +- .../queries/list_files/list_files_cache.dart | 41 ++ .../list_files_params.dart} | 4 +- .../list_files_params.g.dart} | 2 +- .../list_files_response.dart} | 6 +- .../list_files_response.g.dart} | 2 +- .../{webdavApi.dart => webdav_api.dart} | 4 +- .../get_breakers.dart} | 6 +- .../get_breakers_cache.dart} | 6 +- .../get_breakers_response.dart} | 4 +- .../get_breakers_response.g.dart} | 2 +- .../add/add_custom_timetable_event.dart} | 4 +- .../add_custom_timetable_event_params.dart} | 4 +- .../add_custom_timetable_event_params.g.dart} | 2 +- .../custom_timetable_event.dart} | 4 +- .../custom_timetable_event.g.dart} | 2 +- .../get/get_custom_timetable_event.dart} | 6 +- .../get_custom_timetable_event_cache.dart} | 8 +- .../get_custom_timetable_event_params.dart} | 2 +- .../get_custom_timetable_event_params.g.dart} | 2 +- .../get_custom_timetable_event_response.dart} | 6 +- ...et_custom_timetable_event_response.g.dart} | 2 +- .../remove_custom_timetable_event.dart} | 4 +- ...remove_custom_timetable_event_params.dart} | 2 +- ...move_custom_timetable_event_params.g.dart} | 2 +- .../update_custom_timetable_event.dart} | 4 +- ...update_custom_timetable_event_params.dart} | 4 +- ...date_custom_timetable_event_params.g.dart} | 2 +- lib/api/mhsl/{mhslApi.dart => mhsl_api.dart} | 2 +- ...tifyRegister.dart => notify_register.dart} | 4 +- ...arams.dart => notify_register_params.dart} | 2 +- ...s.g.dart => notify_register_params.g.dart} | 2 +- .../{addFeedback.dart => add_feedback.dart} | 4 +- ...ckParams.dart => add_feedback_params.dart} | 2 +- ...rams.g.dart => add_feedback_params.g.dart} | 2 +- .../update/update_user_index_params.dart} | 2 +- .../update/update_user_index_params.g.dart} | 2 +- .../update/update_userindex.dart} | 9 +- .../{requestCache.dart => request_cache.dart} | 13 +- .../queries/authenticate/authenticate.dart | 19 +- ...teParams.dart => authenticate_params.dart} | 4 +- ...rams.g.dart => authenticate_params.g.dart} | 2 +- ...sponse.dart => authenticate_response.dart} | 4 +- ...se.g.dart => authenticate_response.g.dart} | 2 +- .../get_holidays.dart} | 8 +- .../get_holidays_cache.dart} | 6 +- .../get_holidays_response.dart} | 4 +- .../get_holidays_response.g.dart} | 2 +- .../get_rooms.dart} | 10 +- .../get_rooms_cache.dart} | 6 +- .../get_rooms_response.dart} | 4 +- .../get_rooms_response.g.dart} | 2 +- .../get_subjects.dart} | 8 +- .../get_subjects_cache.dart} | 6 +- .../get_subjects_response.dart} | 4 +- .../get_subjects_response.g.dart} | 2 +- .../get_timegrid_units.dart} | 8 +- .../get_timegrid_units_cache.dart} | 6 +- .../get_timegrid_units_response.dart} | 4 +- .../get_timegrid_units_response.g.dart} | 2 +- .../get_timetable.dart} | 10 +- .../get_timetable_cache.dart} | 8 +- .../get_timetable_params.dart} | 4 +- .../get_timetable_params.g.dart} | 2 +- .../get_timetable_response.dart} | 4 +- .../get_timetable_response.g.dart} | 2 +- .../{webuntisApi.dart => webuntis_api.dart} | 32 +- ...webuntisError.dart => webuntis_error.dart} | 0 lib/app.dart | 12 +- lib/main.dart | 6 +- lib/model/account_data.dart | 4 +- lib/model/data_cleaner.dart | 8 +- lib/notification/notification_controller.dart | 28 +- lib/notification/notification_tasks.dart | 4 +- lib/notification/notify_updater.dart | 14 +- lib/routing/app_routes.dart | 8 +- .../basis/dataloader/holiday_data_loader.dart | 2 +- .../basis/dataloader/mhsl_data_loader.dart | 2 +- .../data_loader.dart | 8 +- .../bloc/loadable_state_bloc.dart | 4 +- .../bloc/loadable_state_event.dart | 0 .../bloc/loadable_state_state.dart | 0 .../bloc/loadable_state_state.freezed.dart | 0 .../loadable_state.dart | 0 .../loadable_state.freezed.dart | 0 .../loading_error.dart | 0 .../loading_error.freezed.dart | 0 .../loadable_state_background_loading.dart | 0 .../view/loadable_state_consumer.dart | 7 +- .../view/loadable_state_error_bar.dart | 0 .../view/loadable_state_error_screen.dart | 2 +- .../view/loadable_state_primary_loading.dart | 0 .../bloc_module.dart | 0 .../loadable_hydrated_bloc.dart | 6 +- .../loadable_hydrated_bloc_event.dart | 2 +- .../loadable_save_context.dart | 2 +- .../loadable_save_context.freezed.dart | 0 .../loadable_save_context.g.dart | 0 lib/state/app/modules/app_modules.dart | 21 +- .../modules/breaker/bloc/breaker_bloc.dart | 6 +- .../modules/breaker/bloc/breaker_event.dart | 2 +- .../modules/breaker/bloc/breaker_state.dart | 2 +- .../breaker_data_provider.dart | 4 +- .../repository/breaker_repository.dart | 2 +- .../app/modules/chat/bloc/chat_bloc.dart | 8 +- .../app/modules/chat/bloc/chat_event.dart | 2 +- .../app/modules/chat/bloc/chat_state.dart | 2 +- .../chat_data_provider.dart | 4 +- .../chat/repository/chat_repository.dart | 2 +- .../bloc/chat_list_bloc.dart | 17 +- .../bloc/chat_list_event.dart | 2 +- .../bloc/chat_list_state.dart | 2 +- .../bloc/chat_list_state.freezed.dart | 0 .../bloc/chat_list_state.g.dart | 0 .../chat_list_data_provider.dart | 8 +- .../repository/chat_list_repository.dart | 2 +- .../app/modules/files/bloc/files_bloc.dart | 8 +- .../app/modules/files/bloc/files_event.dart | 2 +- .../app/modules/files/bloc/files_state.dart | 2 +- .../files_data_provider.dart | 6 +- .../files/repository/files_repository.dart | 2 +- .../bloc/grade_averages_bloc.dart | 0 .../bloc/grade_averages_event.dart | 0 .../bloc/grade_averages_state.dart | 0 .../bloc/grade_averages_state.freezed.dart | 0 .../bloc/grade_averages_state.g.dart | 0 .../modules/holidays/bloc/holidays_bloc.dart | 10 +- .../modules/holidays/bloc/holidays_event.dart | 2 +- .../modules/holidays/bloc/holidays_state.dart | 2 +- .../holidays_get_holidays.dart | 2 +- .../repository/holidays_repository.dart | 2 +- .../bloc/marianum_dates_bloc.dart | 10 +- .../bloc/marianum_dates_event.dart | 2 +- .../bloc/marianum_dates_state.dart | 2 +- .../bloc/marianum_dates_state.freezed.dart | 0 .../bloc/marianum_dates_state.g.dart | 0 .../marianum_dates_get_events.dart | 4 +- .../repository/marianum_dates_repository.dart | 2 +- .../bloc/marianum_message_bloc.dart | 4 +- .../bloc/marianum_message_event.dart | 2 +- .../bloc/marianum_message_state.dart | 0 .../bloc/marianum_message_state.freezed.dart | 0 .../bloc/marianum_message_state.g.dart | 0 .../marianum_message_get_messages.dart | 4 +- .../marianum_message_repository.dart | 2 +- .../modules/settings/bloc/settings_cubit.dart | 6 +- .../timetable/bloc/timetable_bloc.dart | 8 +- .../timetable/bloc/timetable_event.dart | 2 +- .../timetable/bloc/timetable_state.dart | 12 +- .../timetable_data_provider.dart | 40 +- .../repository/timetable_repository.dart | 2 +- lib/storage/settings.dart | 2 +- lib/utils/cache_invalidation_bus.dart | 20 + lib/utils/debouncer.dart | 34 ++ lib/utils/download_manager.dart | 146 ++++++ lib/utils/file_clipboard.dart | 44 ++ lib/utils/file_downloader.dart | 47 ++ lib/utils/file_saver.dart | 23 - lib/view/login/login.dart | 4 +- lib/view/pages/files/files.dart | 256 +++++++++-- lib/view/pages/files/files_upload_dialog.dart | 22 +- .../files/widgets/file_details_sheet.dart | 80 ++++ .../pages/files/widgets/file_element.dart | 414 ++++++++++++------ .../grade_averages_list_view.dart | 6 +- .../grade_averages/grade_averages_view.dart | 6 +- lib/view/pages/holidays/holidays_view.dart | 6 +- .../marianum_dates/marianum_dates_view.dart | 12 +- .../marianum_message_list_view.dart | 10 +- .../marianum_message_view.dart | 2 +- .../pages/more/feedback/feedback_dialog.dart | 20 +- lib/view/pages/overhang.dart | 12 +- .../pages/settings/data/default_settings.dart | 6 +- .../settings/sections/account_section.dart | 7 +- .../settings/sections/dev_tools_section.dart | 2 +- lib/view/pages/talk/chat_list.dart | 16 +- lib/view/pages/talk/chat_view.dart | 12 +- .../pages/talk/data/chat_bubble_styles.dart | 2 +- lib/view/pages/talk/data/chat_message.dart | 4 +- lib/view/pages/talk/details/chat_info.dart | 6 +- .../pages/talk/details/message_reactions.dart | 4 +- .../talk/details/participants_list_view.dart | 4 +- lib/view/pages/talk/join_chat.dart | 4 +- lib/view/pages/talk/search_chat.dart | 4 +- .../pages/talk/widgets/answer_reference.dart | 4 +- lib/view/pages/talk/widgets/bubble.dart | 87 ++++ lib/view/pages/talk/widgets/chat_bubble.dart | 172 +++++--- .../widgets/chat_message_options_dialog.dart | 8 +- .../pages/talk/widgets/chat_textfield.dart | 17 +- lib/view/pages/talk/widgets/chat_tile.dart | 10 +- .../pages/talk/widgets/poll_options_list.dart | 2 +- .../custom_event_edit_dialog.dart | 3 +- .../custom_events/custom_events_view.dart | 2 +- .../timetable/data/arbitrary_appointment.dart | 4 +- .../data/lesson_period_schedule.dart | 2 +- .../pages/timetable/data/lesson_status.dart | 2 +- .../data/timetable_appointment_factory.dart | 10 +- .../timetable/details/_bottom_sheet.dart | 20 - .../pages/timetable/details/bottom_sheet.dart | 51 +++ .../timetable/details/custom_event_sheet.dart | 4 +- .../details/delete_custom_event.dart | 2 +- .../details/webuntis_lesson_sheet.dart | 8 +- lib/view/pages/timetable/timetable.dart | 4 +- .../timetable/widgets/appointment_tile.dart | 2 +- .../widgets/custom_workweek_calendar.dart | 4 +- .../widgets/special_regions_builder.dart | 2 +- lib/widget/animated_time.dart | 21 +- lib/widget/breaker/breaker.dart | 2 +- lib/widget/debug/cache_view.dart | 17 +- lib/widget/debug/json_viewer.dart | 7 +- lib/widget/file_pick.dart | 20 +- lib/widget/file_viewer.dart | 89 +++- pubspec.yaml | 26 +- 281 files changed, 1948 insertions(+), 1041 deletions(-) rename lib/api/{apiError.dart => api_error.dart} (100%) rename lib/api/{apiParams.dart => api_params.dart} (100%) rename lib/api/{apiRequest.dart => api_request.dart} (100%) rename lib/api/{apiResponse.dart => api_response.dart} (100%) rename lib/api/holidays/{getHolidays.dart => get_holidays.dart} (93%) rename lib/api/holidays/{getHolidaysCache.dart => get_holidays_cache.dart} (83%) rename lib/api/holidays/{getHolidaysResponse.dart => get_holidays_response.dart} (93%) rename lib/api/holidays/{getHolidaysResponse.g.dart => get_holidays_response.g.dart} (97%) rename lib/api/marianumcloud/autocomplete/{autocompleteApi.dart => autocomplete_api.dart} (77%) rename lib/api/marianumcloud/autocomplete/{autocompleteResponse.dart => autocomplete_response.dart} (96%) rename lib/api/marianumcloud/autocomplete/{autocompleteResponse.g.dart => autocomplete_response.g.dart} (97%) rename lib/api/marianumcloud/{files-sharing/fileSharingApi.dart => files_sharing/file_sharing_api.dart} (93%) rename lib/api/marianumcloud/{files-sharing/fileSharingApiParams.dart => files_sharing/file_sharing_api_params.dart} (93%) rename lib/api/marianumcloud/{files-sharing/fileSharingApiParams.g.dart => files_sharing/file_sharing_api_params.g.dart} (95%) rename lib/api/marianumcloud/talk/chat/{getChat.dart => get_chat.dart} (62%) rename lib/api/marianumcloud/talk/chat/{getChatCache.dart => get_chat_cache.dart} (83%) rename lib/api/marianumcloud/talk/chat/{getChatParams.dart => get_chat_params.dart} (92%) rename lib/api/marianumcloud/talk/chat/{getChatParams.g.dart => get_chat_params.g.dart} (97%) rename lib/api/marianumcloud/talk/chat/{getChatResponse.dart => get_chat_response.dart} (91%) rename lib/api/marianumcloud/talk/chat/{getChatResponse.g.dart => get_chat_response.g.dart} (99%) rename lib/api/marianumcloud/talk/chat/{richObjectStringProcessor.dart => rich_object_string_processor.dart} (89%) rename lib/api/marianumcloud/talk/{createRoom/createRoom.dart => create_room/create_room.dart} (83%) rename lib/api/marianumcloud/talk/{createRoom/createRoomParams.dart => create_room/create_room_params.dart} (89%) rename lib/api/marianumcloud/talk/{createRoom/createRoomParams.g.dart => create_room/create_room_params.g.dart} (96%) rename lib/api/marianumcloud/talk/{deleteReactMessage/deleteReactMessage.dart => delete_react_message/delete_react_message.dart} (80%) rename lib/api/marianumcloud/talk/{deleteReactMessage/deleteReactMessageParams.dart => delete_react_message/delete_react_message_params.dart} (83%) rename lib/api/marianumcloud/talk/{deleteReactMessage/deleteReactMessageParams.g.dart => delete_react_message/delete_react_message_params.g.dart} (92%) rename lib/api/marianumcloud/talk/{getParticipants/getParticipants.dart => get_participants/get_participants.dart} (58%) rename lib/api/marianumcloud/talk/{getParticipants/getParticipantsCache.dart => get_participants/get_participants_cache.dart} (80%) rename lib/api/marianumcloud/talk/{getParticipants/getParticipantsResponse.dart => get_participants/get_participants_response.dart} (96%) rename lib/api/marianumcloud/talk/{getParticipants/getParticipantsResponse.g.dart => get_participants/get_participants_response.g.dart} (98%) rename lib/api/marianumcloud/talk/{getPoll/getPollState.dart => get_poll/get_poll_state.dart} (61%) rename lib/api/marianumcloud/talk/{getPoll/getPollStateResponse.dart => get_poll/get_poll_state_response.dart} (94%) rename lib/api/marianumcloud/talk/{getPoll/getPollStateResponse.g.dart => get_poll/get_poll_state_response.g.dart} (97%) rename lib/api/marianumcloud/talk/{getReactions/getReactions.dart => get_reactions/get_reactions.dart} (61%) rename lib/api/marianumcloud/talk/{getReactions/getReactionsResponse.dart => get_reactions/get_reactions_response.dart} (92%) rename lib/api/marianumcloud/talk/{getReactions/getReactionsResponse.g.dart => get_reactions/get_reactions_response.g.dart} (97%) rename lib/api/marianumcloud/talk/{reactMessage/reactMessage.dart => react_message/react_message.dart} (80%) rename lib/api/marianumcloud/talk/{reactMessage/reactMessageParams.dart => react_message/react_message_params.dart} (83%) rename lib/api/marianumcloud/talk/{reactMessage/reactMessageParams.g.dart => react_message/react_message_params.g.dart} (93%) rename lib/api/marianumcloud/talk/room/{getRoom.dart => get_room.dart} (57%) rename lib/api/marianumcloud/talk/room/{getRoomCache.dart => get_room_cache.dart} (73%) rename lib/api/marianumcloud/talk/room/{getRoomParams.dart => get_room_params.dart} (90%) rename lib/api/marianumcloud/talk/room/{getRoomParams.g.dart => get_room_params.g.dart} (96%) rename lib/api/marianumcloud/talk/room/{getRoomResponse.dart => get_room_response.dart} (97%) rename lib/api/marianumcloud/talk/room/{getRoomResponse.g.dart => get_room_response.g.dart} (99%) rename lib/api/marianumcloud/talk/{sendMessage/sendMessage.dart => send_message/send_message.dart} (77%) rename lib/api/marianumcloud/talk/{sendMessage/sendMessageParams.dart => send_message/send_message_params.dart} (85%) rename lib/api/marianumcloud/talk/{sendMessage/sendMessageParams.g.dart => send_message/send_message_params.g.dart} (94%) rename lib/api/marianumcloud/talk/{setReadMarker/setReadMarker.dart => set_read_marker/set_read_marker.dart} (87%) rename lib/api/marianumcloud/talk/{setReadMarker/setReadMarkerParams.dart => set_read_marker/set_read_marker_params.dart} (83%) rename lib/api/marianumcloud/talk/{setReadMarker/setReadMarkerParams.g.dart => set_read_marker/set_read_marker_params.g.dart} (93%) rename lib/api/marianumcloud/talk/{talkApi.dart => talk_api.dart} (96%) rename lib/api/marianumcloud/talk/{talkError.dart => talk_error.dart} (100%) delete mode 100644 lib/api/marianumcloud/webdav/queries/downloadFile/downloadFile.dart create mode 100644 lib/api/marianumcloud/webdav/queries/download_file/download_file.dart rename lib/api/marianumcloud/webdav/queries/{downloadFile/downloadFileParams.dart => download_file/download_file_params.dart} (85%) rename lib/api/marianumcloud/webdav/queries/{downloadFile/downloadFileParams.g.dart => download_file/download_file_params.g.dart} (95%) rename lib/api/marianumcloud/webdav/queries/{downloadFile/downloadFileResponse.dart => download_file/download_file_response.dart} (89%) rename lib/api/marianumcloud/webdav/queries/{downloadFile/downloadFileResponse.g.dart => download_file/download_file_response.g.dart} (92%) delete mode 100644 lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart rename lib/api/marianumcloud/webdav/queries/{listFiles/cacheableFile.dart => list_files/cacheable_file.dart} (88%) rename lib/api/marianumcloud/webdav/queries/{listFiles/cacheableFile.g.dart => list_files/cacheable_file.g.dart} (97%) rename lib/api/marianumcloud/webdav/queries/{listFiles/listFiles.dart => list_files/list_files.dart} (67%) create mode 100644 lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart rename lib/api/marianumcloud/webdav/queries/{listFiles/listFilesParams.dart => list_files/list_files_params.dart} (83%) rename lib/api/marianumcloud/webdav/queries/{listFiles/listFilesParams.g.dart => list_files/list_files_params.g.dart} (93%) rename lib/api/marianumcloud/webdav/queries/{listFiles/listFilesResponse.dart => list_files/list_files_response.dart} (94%) rename lib/api/marianumcloud/webdav/queries/{listFiles/listFilesResponse.g.dart => list_files/list_files_response.g.dart} (95%) rename lib/api/marianumcloud/webdav/{webdavApi.dart => webdav_api.dart} (93%) rename lib/api/mhsl/breaker/{getBreakers/getBreakers.dart => get_breakers/get_breakers.dart} (73%) rename lib/api/mhsl/breaker/{getBreakers/getBreakersCache.dart => get_breakers/get_breakers_cache.dart} (75%) rename lib/api/mhsl/breaker/{getBreakers/getBreakersResponse.dart => get_breakers/get_breakers_response.dart} (93%) rename lib/api/mhsl/breaker/{getBreakers/getBreakersResponse.g.dart => get_breakers/get_breakers_response.g.dart} (97%) rename lib/api/mhsl/{customTimetableEvent/add/addCustomTimetableEvent.dart => custom_timetable_event/add/add_custom_timetable_event.dart} (85%) rename lib/api/mhsl/{customTimetableEvent/add/addCustomTimetableEventParams.dart => custom_timetable_event/add/add_custom_timetable_event_params.dart} (83%) rename lib/api/mhsl/{customTimetableEvent/add/addCustomTimetableEventParams.g.dart => custom_timetable_event/add/add_custom_timetable_event_params.g.dart} (92%) rename lib/api/mhsl/{customTimetableEvent/customTimetableEvent.dart => custom_timetable_event/custom_timetable_event.dart} (93%) rename lib/api/mhsl/{customTimetableEvent/customTimetableEvent.g.dart => custom_timetable_event/custom_timetable_event.g.dart} (97%) rename lib/api/mhsl/{customTimetableEvent/get/getCustomTimetableEvent.dart => custom_timetable_event/get/get_custom_timetable_event.dart} (80%) rename lib/api/mhsl/{customTimetableEvent/get/getCustomTimetableEventCache.dart => custom_timetable_event/get/get_custom_timetable_event_cache.dart} (72%) rename lib/api/mhsl/{customTimetableEvent/get/getCustomTimetableEventParams.dart => custom_timetable_event/get/get_custom_timetable_event_params.dart} (88%) rename lib/api/mhsl/{customTimetableEvent/get/getCustomTimetableEventParams.g.dart => custom_timetable_event/get/get_custom_timetable_event_params.g.dart} (91%) rename lib/api/mhsl/{customTimetableEvent/get/getCustomTimetableEventResponse.dart => custom_timetable_event/get/get_custom_timetable_event_response.dart} (77%) rename lib/api/mhsl/{customTimetableEvent/get/getCustomTimetableEventResponse.g.dart => custom_timetable_event/get/get_custom_timetable_event_response.g.dart} (94%) rename lib/api/mhsl/{customTimetableEvent/remove/removeCustomTimetableEvent.dart => custom_timetable_event/remove/remove_custom_timetable_event.dart} (84%) rename lib/api/mhsl/{customTimetableEvent/remove/removeCustomTimetableEventParams.dart => custom_timetable_event/remove/remove_custom_timetable_event_params.dart} (88%) rename lib/api/mhsl/{customTimetableEvent/remove/removeCustomTimetableEventParams.g.dart => custom_timetable_event/remove/remove_custom_timetable_event_params.g.dart} (91%) rename lib/api/mhsl/{customTimetableEvent/update/updateCustomTimetableEvent.dart => custom_timetable_event/update/update_custom_timetable_event.dart} (84%) rename lib/api/mhsl/{customTimetableEvent/update/updateCustomTimetableEventParams.dart => custom_timetable_event/update/update_custom_timetable_event_params.dart} (83%) rename lib/api/mhsl/{customTimetableEvent/update/updateCustomTimetableEventParams.g.dart => custom_timetable_event/update/update_custom_timetable_event_params.g.dart} (92%) rename lib/api/mhsl/{mhslApi.dart => mhsl_api.dart} (98%) rename lib/api/mhsl/notify/register/{notifyRegister.dart => notify_register.dart} (88%) rename lib/api/mhsl/notify/register/{notifyRegisterParams.dart => notify_register_params.dart} (92%) rename lib/api/mhsl/notify/register/{notifyRegisterParams.g.dart => notify_register_params.g.dart} (94%) rename lib/api/mhsl/server/feedback/{addFeedback.dart => add_feedback.dart} (85%) rename lib/api/mhsl/server/feedback/{addFeedbackParams.dart => add_feedback_params.dart} (93%) rename lib/api/mhsl/server/feedback/{addFeedbackParams.g.dart => add_feedback_params.g.dart} (95%) rename lib/api/mhsl/server/{userIndex/update/updateUserIndexParams.dart => user_index/update/update_user_index_params.dart} (93%) rename lib/api/mhsl/server/{userIndex/update/updateUserIndexParams.g.dart => user_index/update/update_user_index_params.g.dart} (95%) rename lib/api/mhsl/server/{userIndex/update/updateUserindex.dart => user_index/update/update_userindex.dart} (88%) rename lib/api/{requestCache.dart => request_cache.dart} (89%) rename lib/api/webuntis/queries/authenticate/{authenticateParams.dart => authenticate_params.dart} (85%) rename lib/api/webuntis/queries/authenticate/{authenticateParams.g.dart => authenticate_params.g.dart} (94%) rename lib/api/webuntis/queries/authenticate/{authenticateResponse.dart => authenticate_response.dart} (86%) rename lib/api/webuntis/queries/authenticate/{authenticateResponse.g.dart => authenticate_response.g.dart} (96%) rename lib/api/webuntis/queries/{getHolidays/getHolidays.dart => get_holidays/get_holidays.dart} (82%) rename lib/api/webuntis/queries/{getHolidays/getHolidaysCache.dart => get_holidays/get_holidays_cache.dart} (76%) rename lib/api/webuntis/queries/{getHolidays/getHolidaysResponse.dart => get_holidays/get_holidays_response.dart} (91%) rename lib/api/webuntis/queries/{getHolidays/getHolidaysResponse.g.dart => get_holidays/get_holidays_response.g.dart} (97%) rename lib/api/webuntis/queries/{getRooms/getRooms.dart => get_rooms/get_rooms.dart} (72%) rename lib/api/webuntis/queries/{getRooms/getRoomsCache.dart => get_rooms/get_rooms_cache.dart} (76%) rename lib/api/webuntis/queries/{getRooms/getRoomsResponse.dart => get_rooms/get_rooms_response.dart} (91%) rename lib/api/webuntis/queries/{getRooms/getRoomsResponse.g.dart => get_rooms/get_rooms_response.g.dart} (97%) rename lib/api/webuntis/queries/{getSubjects/getSubjects.dart => get_subjects/get_subjects.dart} (61%) rename lib/api/webuntis/queries/{getSubjects/getSubjectsCache.dart => get_subjects/get_subjects_cache.dart} (76%) rename lib/api/webuntis/queries/{getSubjects/getSubjectsResponse.dart => get_subjects/get_subjects_response.dart} (92%) rename lib/api/webuntis/queries/{getSubjects/getSubjectsResponse.g.dart => get_subjects/get_subjects_response.g.dart} (97%) rename lib/api/webuntis/queries/{getTimegridUnits/getTimegridUnits.dart => get_timegrid_units/get_timegrid_units.dart} (76%) rename lib/api/webuntis/queries/{getTimegridUnits/getTimegridUnitsCache.dart => get_timegrid_units/get_timegrid_units_cache.dart} (74%) rename lib/api/webuntis/queries/{getTimegridUnits/getTimegridUnitsResponse.dart => get_timegrid_units/get_timegrid_units_response.dart} (93%) rename lib/api/webuntis/queries/{getTimegridUnits/getTimegridUnitsResponse.g.dart => get_timegrid_units/get_timegrid_units_response.g.dart} (97%) rename lib/api/webuntis/queries/{getTimetable/getTimetable.dart => get_timetable/get_timetable.dart} (60%) rename lib/api/webuntis/queries/{getTimetable/getTimetableCache.dart => get_timetable/get_timetable_cache.dart} (90%) rename lib/api/webuntis/queries/{getTimetable/getTimetableParams.dart => get_timetable/get_timetable_params.dart} (97%) rename lib/api/webuntis/queries/{getTimetable/getTimetableParams.g.dart => get_timetable/get_timetable_params.g.dart} (99%) rename lib/api/webuntis/queries/{getTimetable/getTimetableResponse.dart => get_timetable/get_timetable_response.dart} (98%) rename lib/api/webuntis/queries/{getTimetable/getTimetableResponse.g.dart => get_timetable/get_timetable_response.g.dart} (99%) rename lib/api/webuntis/{webuntisApi.dart => webuntis_api.dart} (68%) rename lib/api/webuntis/{webuntisError.dart => webuntis_error.dart} (100%) rename lib/state/app/infrastructure/{dataLoader => data_loader}/data_loader.dart (81%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/bloc/loadable_state_bloc.dart (91%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/bloc/loadable_state_event.dart (100%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/bloc/loadable_state_state.dart (100%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/bloc/loadable_state_state.freezed.dart (100%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/loadable_state.dart (100%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/loadable_state.freezed.dart (100%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/loading_error.dart (100%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/loading_error.freezed.dart (100%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/view/loadable_state_background_loading.dart (100%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/view/loadable_state_consumer.dart (95%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/view/loadable_state_error_bar.dart (100%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/view/loadable_state_error_screen.dart (97%) rename lib/state/app/infrastructure/{loadableState => loadable_state}/view/loadable_state_primary_loading.dart (100%) rename lib/state/app/infrastructure/{utilityWidgets => utility_widgets}/bloc_module.dart (100%) rename lib/state/app/infrastructure/{utilityWidgets/loadableHydratedBloc => utility_widgets/loadable_hydrated_bloc}/loadable_hydrated_bloc.dart (95%) rename lib/state/app/infrastructure/{utilityWidgets/loadableHydratedBloc => utility_widgets/loadable_hydrated_bloc}/loadable_hydrated_bloc_event.dart (91%) rename lib/state/app/infrastructure/{utilityWidgets/loadableHydratedBloc => utility_widgets/loadable_hydrated_bloc}/loadable_save_context.dart (93%) rename lib/state/app/infrastructure/{utilityWidgets/loadableHydratedBloc => utility_widgets/loadable_hydrated_bloc}/loadable_save_context.freezed.dart (100%) rename lib/state/app/infrastructure/{utilityWidgets/loadableHydratedBloc => utility_widgets/loadable_hydrated_bloc}/loadable_save_context.g.dart (100%) rename lib/state/app/modules/breaker/{dataProvider => data_provider}/breaker_data_provider.dart (64%) rename lib/state/app/modules/chat/{dataProvider => data_provider}/chat_data_provider.dart (80%) rename lib/state/app/modules/{chatList => chat_list}/bloc/chat_list_bloc.dart (77%) rename lib/state/app/modules/{chatList => chat_list}/bloc/chat_list_event.dart (50%) rename lib/state/app/modules/{chatList => chat_list}/bloc/chat_list_state.dart (83%) rename lib/state/app/modules/{chatList => chat_list}/bloc/chat_list_state.freezed.dart (100%) rename lib/state/app/modules/{chatList => chat_list}/bloc/chat_list_state.g.dart (100%) rename lib/state/app/modules/{chatList/dataProvider => chat_list/data_provider}/chat_list_data_provider.dart (67%) rename lib/state/app/modules/{chatList => chat_list}/repository/chat_list_repository.dart (86%) rename lib/state/app/modules/files/{dataProvider => data_provider}/files_data_provider.dart (82%) rename lib/state/app/modules/{gradeAverages => grade_averages}/bloc/grade_averages_bloc.dart (100%) rename lib/state/app/modules/{gradeAverages => grade_averages}/bloc/grade_averages_event.dart (100%) rename lib/state/app/modules/{gradeAverages => grade_averages}/bloc/grade_averages_state.dart (100%) rename lib/state/app/modules/{gradeAverages => grade_averages}/bloc/grade_averages_state.freezed.dart (100%) rename lib/state/app/modules/{gradeAverages => grade_averages}/bloc/grade_averages_state.g.dart (100%) rename lib/state/app/modules/holidays/{dataProvider => data_provider}/holidays_get_holidays.dart (86%) rename lib/state/app/modules/{marianumDates => marianum_dates}/bloc/marianum_dates_bloc.dart (65%) rename lib/state/app/modules/{marianumDates => marianum_dates}/bloc/marianum_dates_event.dart (70%) rename lib/state/app/modules/{marianumDates => marianum_dates}/bloc/marianum_dates_state.dart (100%) rename lib/state/app/modules/{marianumDates => marianum_dates}/bloc/marianum_dates_state.freezed.dart (100%) rename lib/state/app/modules/{marianumDates => marianum_dates}/bloc/marianum_dates_state.g.dart (100%) rename lib/state/app/modules/{marianumDates/dataProvider => marianum_dates/data_provider}/marianum_dates_get_events.dart (92%) rename lib/state/app/modules/{marianumDates => marianum_dates}/repository/marianum_dates_repository.dart (81%) rename lib/state/app/modules/{marianumMessage => marianum_message}/bloc/marianum_message_bloc.dart (80%) rename lib/state/app/modules/{marianumMessage => marianum_message}/bloc/marianum_message_event.dart (63%) rename lib/state/app/modules/{marianumMessage => marianum_message}/bloc/marianum_message_state.dart (100%) rename lib/state/app/modules/{marianumMessage => marianum_message}/bloc/marianum_message_state.freezed.dart (100%) rename lib/state/app/modules/{marianumMessage => marianum_message}/bloc/marianum_message_state.g.dart (100%) rename lib/state/app/modules/{marianumMessage/dataProvider => marianum_message/data_provider}/marianum_message_get_messages.dart (79%) rename lib/state/app/modules/{marianumMessage => marianum_message}/repository/marianum_message_repository.dart (81%) rename lib/state/app/modules/timetable/{dataProvider => data_provider}/timetable_data_provider.dart (67%) create mode 100644 lib/utils/cache_invalidation_bus.dart create mode 100644 lib/utils/debouncer.dart create mode 100644 lib/utils/download_manager.dart create mode 100644 lib/utils/file_clipboard.dart create mode 100644 lib/utils/file_downloader.dart delete mode 100644 lib/utils/file_saver.dart create mode 100644 lib/view/pages/files/widgets/file_details_sheet.dart create mode 100644 lib/view/pages/talk/widgets/bubble.dart delete mode 100644 lib/view/pages/timetable/details/_bottom_sheet.dart create mode 100644 lib/view/pages/timetable/details/bottom_sheet.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index a40338c..5dd5f4c 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,44 +1,88 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. +# Static analysis configuration for the Flutter project. +# https://dart.dev/guides/language/analysis-options # -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. +# Base ruleset: flutter_lints (recommended Flutter defaults). +# Additional lints below catch real bugs and enforce consistent style. -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml analyzer: + language: + strict-casts: true + strict-raw-types: true errors: invalid_annotation_target: ignore + todo: ignore + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + - "lib/firebase_options.dart" + - "build/**" linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - file_names: false + # === Project conventions === prefer_relative_imports: true - unnecessary_lambdas: true prefer_single_quotes: true - prefer_if_elements_to_conditional_expressions: true - prefer_expression_function_bodies: true - omit_local_variable_types: true eol_at_end_of_file: true - cast_nullable_to_non_nullable: true - avoid_void_async: true + omit_local_variable_types: true avoid_multiple_declarations_per_line: true -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options + # === Bug catchers === + always_declare_return_types: true + avoid_empty_else: true + avoid_slow_async_io: true + avoid_type_to_string: true + avoid_void_async: true + await_only_futures: true + cancel_subscriptions: true + cast_nullable_to_non_nullable: true + close_sinks: true + empty_catches: true + hash_and_equals: true + no_adjacent_strings_in_list: true + no_duplicate_case_values: true + test_types_in_equals: true + throw_in_finally: true + unawaited_futures: true + unnecessary_statements: true + unrelated_type_equality_checks: true + use_build_context_synchronously: true + valid_regexps: true + + # === Flutter widget hygiene === + avoid_unnecessary_containers: true + sized_box_for_whitespace: true + sort_child_properties_last: true + use_colored_box: true + use_decorated_box: true + use_full_hex_values_for_flutter_colors: true + use_key_in_widget_constructors: true + + # === Code clarity === + directives_ordering: true + library_prefixes: true + no_leading_underscores_for_local_identifiers: true + prefer_conditional_assignment: true + prefer_if_elements_to_conditional_expressions: true + prefer_if_null_operators: true + prefer_initializing_formals: true + prefer_interpolation_to_compose_strings: true + prefer_is_empty: true + prefer_is_not_empty: true + prefer_is_not_operator: true + prefer_iterable_whereType: true + prefer_null_aware_operators: true + prefer_spread_collections: true + prefer_void_to_null: true + unnecessary_await_in_return: true + unnecessary_brace_in_string_interps: true + unnecessary_lambdas: true + unnecessary_null_aware_assignments: true + unnecessary_null_checks: true + unnecessary_parenthesis: true + unnecessary_string_interpolations: true + use_super_parameters: true + + # === File naming === + file_names: true diff --git a/android/app/build.gradle b/android/app/build.gradle index b62e5c5..befe6bb 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -25,7 +25,7 @@ if (flutterVersionName == null) { android { namespace "eu.mhsl.marianum.mobile.client" compileSdk flutter.compileSdkVersion - ndkVersion "27.0.12077973" + ndkVersion "28.2.13676358" compileOptions { sourceCompatibility JavaVersion.VERSION_17 diff --git a/lib/api/apiError.dart b/lib/api/api_error.dart similarity index 100% rename from lib/api/apiError.dart rename to lib/api/api_error.dart diff --git a/lib/api/apiParams.dart b/lib/api/api_params.dart similarity index 100% rename from lib/api/apiParams.dart rename to lib/api/api_params.dart diff --git a/lib/api/apiRequest.dart b/lib/api/api_request.dart similarity index 100% rename from lib/api/apiRequest.dart rename to lib/api/api_request.dart diff --git a/lib/api/apiResponse.dart b/lib/api/api_response.dart similarity index 100% rename from lib/api/apiResponse.dart rename to lib/api/api_response.dart diff --git a/lib/api/errors/error_mapper.dart b/lib/api/errors/error_mapper.dart index 2619643..313fd50 100644 --- a/lib/api/errors/error_mapper.dart +++ b/lib/api/errors/error_mapper.dart @@ -3,9 +3,9 @@ import 'dart:io'; import 'package:http/http.dart' as http; -import '../apiError.dart'; -import '../marianumcloud/talk/talkError.dart'; -import '../webuntis/webuntisError.dart'; +import '../api_error.dart'; +import '../marianumcloud/talk/talk_error.dart'; +import '../webuntis/webuntis_error.dart'; import 'app_exception.dart'; import 'network_exception.dart'; import 'parse_exception.dart'; diff --git a/lib/api/errors/talk_exception.dart b/lib/api/errors/talk_exception.dart index f46c0c7..534d1b2 100644 --- a/lib/api/errors/talk_exception.dart +++ b/lib/api/errors/talk_exception.dart @@ -1,4 +1,4 @@ -import '../marianumcloud/talk/talkError.dart'; +import '../marianumcloud/talk/talk_error.dart'; import 'app_exception.dart'; class TalkException extends AppException { diff --git a/lib/api/errors/webuntis_exception.dart b/lib/api/errors/webuntis_exception.dart index fd35d35..211f5ae 100644 --- a/lib/api/errors/webuntis_exception.dart +++ b/lib/api/errors/webuntis_exception.dart @@ -1,4 +1,4 @@ -import '../webuntis/webuntisError.dart'; +import '../webuntis/webuntis_error.dart'; import 'app_exception.dart'; class WebuntisException extends AppException { diff --git a/lib/api/holidays/getHolidays.dart b/lib/api/holidays/get_holidays.dart similarity index 93% rename from lib/api/holidays/getHolidays.dart rename to lib/api/holidays/get_holidays.dart index 8ce3325..5014c48 100644 --- a/lib/api/holidays/getHolidays.dart +++ b/lib/api/holidays/get_holidays.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import 'getHolidaysResponse.dart'; +import 'get_holidays_response.dart'; class GetHolidays { Future query() async { diff --git a/lib/api/holidays/getHolidaysCache.dart b/lib/api/holidays/get_holidays_cache.dart similarity index 83% rename from lib/api/holidays/getHolidaysCache.dart rename to lib/api/holidays/get_holidays_cache.dart index 2707916..5781b59 100644 --- a/lib/api/holidays/getHolidaysCache.dart +++ b/lib/api/holidays/get_holidays_cache.dart @@ -1,6 +1,6 @@ -import '../requestCache.dart'; -import 'getHolidays.dart'; -import 'getHolidaysResponse.dart'; +import '../request_cache.dart'; +import 'get_holidays.dart'; +import 'get_holidays_response.dart'; class GetHolidaysCache extends SimpleCache { GetHolidaysCache({super.onUpdate, super.renew}) diff --git a/lib/api/holidays/getHolidaysResponse.dart b/lib/api/holidays/get_holidays_response.dart similarity index 93% rename from lib/api/holidays/getHolidaysResponse.dart rename to lib/api/holidays/get_holidays_response.dart index 6ba00bb..7039417 100644 --- a/lib/api/holidays/getHolidaysResponse.dart +++ b/lib/api/holidays/get_holidays_response.dart @@ -1,9 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; -import '../apiResponse.dart'; +import '../api_response.dart'; -part 'getHolidaysResponse.g.dart'; +part 'get_holidays_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetHolidaysResponse extends ApiResponse { diff --git a/lib/api/holidays/getHolidaysResponse.g.dart b/lib/api/holidays/get_holidays_response.g.dart similarity index 97% rename from lib/api/holidays/getHolidaysResponse.g.dart rename to lib/api/holidays/get_holidays_response.g.dart index 4642931..593ad0b 100644 --- a/lib/api/holidays/getHolidaysResponse.g.dart +++ b/lib/api/holidays/get_holidays_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getHolidaysResponse.dart'; +part of 'get_holidays_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/autocomplete/autocompleteApi.dart b/lib/api/marianumcloud/autocomplete/autocomplete_api.dart similarity index 77% rename from lib/api/marianumcloud/autocomplete/autocompleteApi.dart rename to lib/api/marianumcloud/autocomplete/autocomplete_api.dart index 1539bd7..e1bb9e3 100644 --- a/lib/api/marianumcloud/autocomplete/autocompleteApi.dart +++ b/lib/api/marianumcloud/autocomplete/autocomplete_api.dart @@ -4,7 +4,7 @@ import 'dart:io'; import 'package:http/http.dart' as http; import '../nextcloud_ocs.dart'; -import 'autocompleteResponse.dart'; +import 'autocomplete_response.dart'; class AutocompleteApi { Future find(String query) async { @@ -22,6 +22,7 @@ class AutocompleteApi { if (response.statusCode != HttpStatus.ok) { throw Exception('Api call failed with ${response.statusCode}: ${response.body}'); } - return AutocompleteResponse.fromJson(jsonDecode(response.body)['ocs']); + final decoded = jsonDecode(response.body) as Map; + return AutocompleteResponse.fromJson(decoded['ocs'] as Map); } } diff --git a/lib/api/marianumcloud/autocomplete/autocompleteResponse.dart b/lib/api/marianumcloud/autocomplete/autocomplete_response.dart similarity index 96% rename from lib/api/marianumcloud/autocomplete/autocompleteResponse.dart rename to lib/api/marianumcloud/autocomplete/autocomplete_response.dart index 8e72772..60b4e7b 100644 --- a/lib/api/marianumcloud/autocomplete/autocompleteResponse.dart +++ b/lib/api/marianumcloud/autocomplete/autocomplete_response.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'autocompleteResponse.g.dart'; +part 'autocomplete_response.g.dart'; @JsonSerializable(explicitToJson: true) class AutocompleteResponse { diff --git a/lib/api/marianumcloud/autocomplete/autocompleteResponse.g.dart b/lib/api/marianumcloud/autocomplete/autocomplete_response.g.dart similarity index 97% rename from lib/api/marianumcloud/autocomplete/autocompleteResponse.g.dart rename to lib/api/marianumcloud/autocomplete/autocomplete_response.g.dart index 41ecf2b..65b9863 100644 --- a/lib/api/marianumcloud/autocomplete/autocompleteResponse.g.dart +++ b/lib/api/marianumcloud/autocomplete/autocomplete_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'autocompleteResponse.dart'; +part of 'autocomplete_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/files-sharing/fileSharingApi.dart b/lib/api/marianumcloud/files_sharing/file_sharing_api.dart similarity index 93% rename from lib/api/marianumcloud/files-sharing/fileSharingApi.dart rename to lib/api/marianumcloud/files_sharing/file_sharing_api.dart index 5914915..1551ada 100644 --- a/lib/api/marianumcloud/files-sharing/fileSharingApi.dart +++ b/lib/api/marianumcloud/files_sharing/file_sharing_api.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:http/http.dart' as http; import '../nextcloud_ocs.dart'; -import 'fileSharingApiParams.dart'; +import 'file_sharing_api_params.dart'; class FileSharingApi { Future share(FileSharingApiParams query) async { diff --git a/lib/api/marianumcloud/files-sharing/fileSharingApiParams.dart b/lib/api/marianumcloud/files_sharing/file_sharing_api_params.dart similarity index 93% rename from lib/api/marianumcloud/files-sharing/fileSharingApiParams.dart rename to lib/api/marianumcloud/files_sharing/file_sharing_api_params.dart index edcc6a5..4078d29 100644 --- a/lib/api/marianumcloud/files-sharing/fileSharingApiParams.dart +++ b/lib/api/marianumcloud/files_sharing/file_sharing_api_params.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'fileSharingApiParams.g.dart'; +part 'file_sharing_api_params.g.dart'; @JsonSerializable() class FileSharingApiParams { diff --git a/lib/api/marianumcloud/files-sharing/fileSharingApiParams.g.dart b/lib/api/marianumcloud/files_sharing/file_sharing_api_params.g.dart similarity index 95% rename from lib/api/marianumcloud/files-sharing/fileSharingApiParams.g.dart rename to lib/api/marianumcloud/files_sharing/file_sharing_api_params.g.dart index 9fc1e8b..472d1bc 100644 --- a/lib/api/marianumcloud/files-sharing/fileSharingApiParams.g.dart +++ b/lib/api/marianumcloud/files_sharing/file_sharing_api_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'fileSharingApiParams.dart'; +part of 'file_sharing_api_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/actions/talk_actions.dart b/lib/api/marianumcloud/talk/actions/talk_actions.dart index 88a83f3..59272cb 100644 --- a/lib/api/marianumcloud/talk/actions/talk_actions.dart +++ b/lib/api/marianumcloud/talk/actions/talk_actions.dart @@ -1,8 +1,8 @@ import 'package:http/http.dart' as http; -import '../../../apiParams.dart'; -import '../../../apiResponse.dart'; -import '../talkApi.dart'; +import '../../../api_params.dart'; +import '../../../api_response.dart'; +import '../talk_api.dart'; /// Small POST/DELETE-only Talk endpoints that have no response payload. /// Each class extends [TalkApi] with `assemble` returning `null`. They share diff --git a/lib/api/marianumcloud/talk/chat/getChat.dart b/lib/api/marianumcloud/talk/chat/get_chat.dart similarity index 62% rename from lib/api/marianumcloud/talk/chat/getChat.dart rename to lib/api/marianumcloud/talk/chat/get_chat.dart index fb64466..9009744 100644 --- a/lib/api/marianumcloud/talk/chat/getChat.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat.dart @@ -3,9 +3,9 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import '../talkApi.dart'; -import 'getChatParams.dart'; -import 'getChatResponse.dart'; +import '../talk_api.dart'; +import 'get_chat_params.dart'; +import 'get_chat_response.dart'; class GetChat extends TalkApi { String chatToken; @@ -14,7 +14,10 @@ class GetChat extends TalkApi { GetChat(this.chatToken, this.params) : super('v1/chat/$chatToken', null, getParameters: params.toJson()); @override - assemble(String raw) => GetChatResponse.fromJson(jsonDecode(raw)['ocs']); + GetChatResponse assemble(String raw) { + final decoded = jsonDecode(raw) as Map; + return GetChatResponse.fromJson(decoded['ocs'] as Map); + } @override Future request(Uri uri, Object? body, Map? headers) => http.get(uri, headers: headers); diff --git a/lib/api/marianumcloud/talk/chat/getChatCache.dart b/lib/api/marianumcloud/talk/chat/get_chat_cache.dart similarity index 83% rename from lib/api/marianumcloud/talk/chat/getChatCache.dart rename to lib/api/marianumcloud/talk/chat/get_chat_cache.dart index 92efd3b..608da9a 100644 --- a/lib/api/marianumcloud/talk/chat/getChatCache.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_cache.dart @@ -1,7 +1,7 @@ -import '../../../requestCache.dart'; -import 'getChat.dart'; -import 'getChatParams.dart'; -import 'getChatResponse.dart'; +import '../../../request_cache.dart'; +import 'get_chat.dart'; +import 'get_chat_params.dart'; +import 'get_chat_response.dart'; class GetChatCache extends SimpleCache { GetChatCache({ diff --git a/lib/api/marianumcloud/talk/chat/getChatParams.dart b/lib/api/marianumcloud/talk/chat/get_chat_params.dart similarity index 92% rename from lib/api/marianumcloud/talk/chat/getChatParams.dart rename to lib/api/marianumcloud/talk/chat/get_chat_params.dart index 08197b2..5287a3b 100644 --- a/lib/api/marianumcloud/talk/chat/getChatParams.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'getChatParams.g.dart'; +part 'get_chat_params.g.dart'; @JsonSerializable(explicitToJson: true, includeIfNull: false) class GetChatParams extends ApiParams { diff --git a/lib/api/marianumcloud/talk/chat/getChatParams.g.dart b/lib/api/marianumcloud/talk/chat/get_chat_params.g.dart similarity index 97% rename from lib/api/marianumcloud/talk/chat/getChatParams.g.dart rename to lib/api/marianumcloud/talk/chat/get_chat_params.g.dart index a2c9707..b318eb6 100644 --- a/lib/api/marianumcloud/talk/chat/getChatParams.g.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getChatParams.dart'; +part of 'get_chat_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/chat/getChatResponse.dart b/lib/api/marianumcloud/talk/chat/get_chat_response.dart similarity index 91% rename from lib/api/marianumcloud/talk/chat/getChatResponse.dart rename to lib/api/marianumcloud/talk/chat/get_chat_response.dart index 2c1db07..6470b19 100644 --- a/lib/api/marianumcloud/talk/chat/getChatResponse.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_response.dart @@ -1,10 +1,10 @@ import 'package:jiffy/jiffy.dart'; import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; -import '../room/getRoomResponse.dart'; +import '../../../api_response.dart'; +import '../room/get_room_response.dart'; -part 'getChatResponse.g.dart'; +part 'get_chat_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetChatResponse extends ApiResponse { @@ -87,10 +87,10 @@ class GetChatResponseObject { } Map? _fromJson(dynamic json) { - if(json is Map) { - var data = {}; - for (var element in json.keys) { - data.putIfAbsent(element, () => RichObjectString.fromJson(json[element])); + if (json is Map) { + final data = {}; + for (final element in json.keys) { + data.putIfAbsent(element, () => RichObjectString.fromJson(json[element] as Map)); } return data; } diff --git a/lib/api/marianumcloud/talk/chat/getChatResponse.g.dart b/lib/api/marianumcloud/talk/chat/get_chat_response.g.dart similarity index 99% rename from lib/api/marianumcloud/talk/chat/getChatResponse.g.dart rename to lib/api/marianumcloud/talk/chat/get_chat_response.g.dart index 1835359..c8f0276 100644 --- a/lib/api/marianumcloud/talk/chat/getChatResponse.g.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getChatResponse.dart'; +part of 'get_chat_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/chat/richObjectStringProcessor.dart b/lib/api/marianumcloud/talk/chat/rich_object_string_processor.dart similarity index 89% rename from lib/api/marianumcloud/talk/chat/richObjectStringProcessor.dart rename to lib/api/marianumcloud/talk/chat/rich_object_string_processor.dart index b61d064..af03502 100644 --- a/lib/api/marianumcloud/talk/chat/richObjectStringProcessor.dart +++ b/lib/api/marianumcloud/talk/chat/rich_object_string_processor.dart @@ -1,5 +1,5 @@ -import 'getChatResponse.dart'; +import 'get_chat_response.dart'; class RichObjectStringProcessor { static String parseToString(String message, Map? data) { diff --git a/lib/api/marianumcloud/talk/createRoom/createRoom.dart b/lib/api/marianumcloud/talk/create_room/create_room.dart similarity index 83% rename from lib/api/marianumcloud/talk/createRoom/createRoom.dart rename to lib/api/marianumcloud/talk/create_room/create_room.dart index 27d274d..e2183b6 100644 --- a/lib/api/marianumcloud/talk/createRoom/createRoom.dart +++ b/lib/api/marianumcloud/talk/create_room/create_room.dart @@ -2,15 +2,15 @@ import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import '../talkApi.dart'; -import 'createRoomParams.dart'; +import '../talk_api.dart'; +import 'create_room_params.dart'; class CreateRoom extends TalkApi { CreateRoomParams params; CreateRoom(this.params) : super('v4/room', params); @override - assemble(String raw) => null; + Null assemble(String raw) => null; @override Future? request(Uri uri, Object? body, Map? headers) { diff --git a/lib/api/marianumcloud/talk/createRoom/createRoomParams.dart b/lib/api/marianumcloud/talk/create_room/create_room_params.dart similarity index 89% rename from lib/api/marianumcloud/talk/createRoom/createRoomParams.dart rename to lib/api/marianumcloud/talk/create_room/create_room_params.dart index cb1d1b5..56ffe1d 100644 --- a/lib/api/marianumcloud/talk/createRoom/createRoomParams.dart +++ b/lib/api/marianumcloud/talk/create_room/create_room_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'createRoomParams.g.dart'; +part 'create_room_params.g.dart'; @JsonSerializable() class CreateRoomParams extends ApiParams { diff --git a/lib/api/marianumcloud/talk/createRoom/createRoomParams.g.dart b/lib/api/marianumcloud/talk/create_room/create_room_params.g.dart similarity index 96% rename from lib/api/marianumcloud/talk/createRoom/createRoomParams.g.dart rename to lib/api/marianumcloud/talk/create_room/create_room_params.g.dart index 8592ebb..b873df4 100644 --- a/lib/api/marianumcloud/talk/createRoom/createRoomParams.g.dart +++ b/lib/api/marianumcloud/talk/create_room/create_room_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'createRoomParams.dart'; +part of 'create_room_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessage.dart b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message.dart similarity index 80% rename from lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessage.dart rename to lib/api/marianumcloud/talk/delete_react_message/delete_react_message.dart index d586d5b..9dc886c 100644 --- a/lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessage.dart +++ b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message.dart @@ -1,9 +1,9 @@ import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import '../../../apiParams.dart'; -import '../talkApi.dart'; -import 'deleteReactMessageParams.dart'; +import '../../../api_params.dart'; +import '../talk_api.dart'; +import 'delete_react_message_params.dart'; class DeleteReactMessage extends TalkApi { String chatToken; @@ -11,7 +11,7 @@ class DeleteReactMessage extends TalkApi { DeleteReactMessage({required this.chatToken, required this.messageId, required DeleteReactMessageParams params}) : super('v1/reaction/$chatToken/$messageId', params); @override - assemble(String raw) => null; + Null assemble(String raw) => null; @override Future? request(Uri uri, ApiParams? body, Map? headers) { diff --git a/lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.dart b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart similarity index 83% rename from lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.dart rename to lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart index c40c317..d17bebc 100644 --- a/lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.dart +++ b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'deleteReactMessageParams.g.dart'; +part 'delete_react_message_params.g.dart'; @JsonSerializable() class DeleteReactMessageParams extends ApiParams { diff --git a/lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.g.dart b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.g.dart similarity index 92% rename from lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.g.dart rename to lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.g.dart index 7dc6c79..9f50520 100644 --- a/lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.g.dart +++ b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'deleteReactMessageParams.dart'; +part of 'delete_react_message_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/getParticipants/getParticipants.dart b/lib/api/marianumcloud/talk/get_participants/get_participants.dart similarity index 58% rename from lib/api/marianumcloud/talk/getParticipants/getParticipants.dart rename to lib/api/marianumcloud/talk/get_participants/get_participants.dart index ec88234..03b302a 100644 --- a/lib/api/marianumcloud/talk/getParticipants/getParticipants.dart +++ b/lib/api/marianumcloud/talk/get_participants/get_participants.dart @@ -2,15 +2,18 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import '../talkApi.dart'; -import 'getParticipantsResponse.dart'; +import '../talk_api.dart'; +import 'get_participants_response.dart'; class GetParticipants extends TalkApi { String token; GetParticipants(this.token) : super('v4/room/$token/participants', null); @override - GetParticipantsResponse assemble(String raw) => GetParticipantsResponse.fromJson(jsonDecode(raw)['ocs']); + GetParticipantsResponse assemble(String raw) { + final decoded = jsonDecode(raw) as Map; + return GetParticipantsResponse.fromJson(decoded['ocs'] as Map); + } @override Future request(Uri uri, Object? body, Map? headers) => http.get(uri, headers: headers); diff --git a/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart b/lib/api/marianumcloud/talk/get_participants/get_participants_cache.dart similarity index 80% rename from lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart rename to lib/api/marianumcloud/talk/get_participants/get_participants_cache.dart index c869b26..f40b017 100644 --- a/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart +++ b/lib/api/marianumcloud/talk/get_participants/get_participants_cache.dart @@ -1,6 +1,6 @@ -import '../../../requestCache.dart'; -import 'getParticipants.dart'; -import 'getParticipantsResponse.dart'; +import '../../../request_cache.dart'; +import 'get_participants.dart'; +import 'get_participants_response.dart'; class GetParticipantsCache extends SimpleCache { GetParticipantsCache({ diff --git a/lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart b/lib/api/marianumcloud/talk/get_participants/get_participants_response.dart similarity index 96% rename from lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart rename to lib/api/marianumcloud/talk/get_participants/get_participants_response.dart index 3d0e9ff..5f97086 100644 --- a/lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart +++ b/lib/api/marianumcloud/talk/get_participants/get_participants_response.dart @@ -1,9 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getParticipantsResponse.g.dart'; +part 'get_participants_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetParticipantsResponse extends ApiResponse { diff --git a/lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.g.dart b/lib/api/marianumcloud/talk/get_participants/get_participants_response.g.dart similarity index 98% rename from lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.g.dart rename to lib/api/marianumcloud/talk/get_participants/get_participants_response.g.dart index 424b161..f3fd7cb 100644 --- a/lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.g.dart +++ b/lib/api/marianumcloud/talk/get_participants/get_participants_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getParticipantsResponse.dart'; +part of 'get_participants_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/getPoll/getPollState.dart b/lib/api/marianumcloud/talk/get_poll/get_poll_state.dart similarity index 61% rename from lib/api/marianumcloud/talk/getPoll/getPollState.dart rename to lib/api/marianumcloud/talk/get_poll/get_poll_state.dart index 503c1d0..c4c37b7 100644 --- a/lib/api/marianumcloud/talk/getPoll/getPollState.dart +++ b/lib/api/marianumcloud/talk/get_poll/get_poll_state.dart @@ -2,8 +2,8 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import '../talkApi.dart'; -import 'getPollStateResponse.dart'; +import '../talk_api.dart'; +import 'get_poll_state_response.dart'; class GetPollState extends TalkApi { String token; @@ -11,7 +11,10 @@ class GetPollState extends TalkApi { GetPollState({required this.token, required this.pollId}) : super('v1/poll/$token/$pollId', null); @override - GetPollStateResponse assemble(String raw) => GetPollStateResponse.fromJson(jsonDecode(raw)['ocs']); + GetPollStateResponse assemble(String raw) { + final decoded = jsonDecode(raw) as Map; + return GetPollStateResponse.fromJson(decoded['ocs'] as Map); + } @override Future request(Uri uri, Object? body, Map? headers) => http.get(uri, headers: headers); diff --git a/lib/api/marianumcloud/talk/getPoll/getPollStateResponse.dart b/lib/api/marianumcloud/talk/get_poll/get_poll_state_response.dart similarity index 94% rename from lib/api/marianumcloud/talk/getPoll/getPollStateResponse.dart rename to lib/api/marianumcloud/talk/get_poll/get_poll_state_response.dart index 75d20c0..5c43a38 100644 --- a/lib/api/marianumcloud/talk/getPoll/getPollStateResponse.dart +++ b/lib/api/marianumcloud/talk/get_poll/get_poll_state_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getPollStateResponse.g.dart'; +part 'get_poll_state_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetPollStateResponse extends ApiResponse { diff --git a/lib/api/marianumcloud/talk/getPoll/getPollStateResponse.g.dart b/lib/api/marianumcloud/talk/get_poll/get_poll_state_response.g.dart similarity index 97% rename from lib/api/marianumcloud/talk/getPoll/getPollStateResponse.g.dart rename to lib/api/marianumcloud/talk/get_poll/get_poll_state_response.g.dart index 3015979..c81d2f5 100644 --- a/lib/api/marianumcloud/talk/getPoll/getPollStateResponse.g.dart +++ b/lib/api/marianumcloud/talk/get_poll/get_poll_state_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getPollStateResponse.dart'; +part of 'get_poll_state_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/getReactions/getReactions.dart b/lib/api/marianumcloud/talk/get_reactions/get_reactions.dart similarity index 61% rename from lib/api/marianumcloud/talk/getReactions/getReactions.dart rename to lib/api/marianumcloud/talk/get_reactions/get_reactions.dart index 5b9a8e3..549c788 100644 --- a/lib/api/marianumcloud/talk/getReactions/getReactions.dart +++ b/lib/api/marianumcloud/talk/get_reactions/get_reactions.dart @@ -3,9 +3,9 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import '../../../apiParams.dart'; -import '../talkApi.dart'; -import 'getReactionsResponse.dart'; +import '../../../api_params.dart'; +import '../talk_api.dart'; +import 'get_reactions_response.dart'; class GetReactions extends TalkApi { String chatToken; @@ -13,7 +13,10 @@ class GetReactions extends TalkApi { GetReactions({required this.chatToken, required this.messageId}) : super('v1/reaction/$chatToken/$messageId', null); @override - assemble(String raw) => GetReactionsResponse.fromJson(jsonDecode(raw)['ocs']); + GetReactionsResponse assemble(String raw) { + final decoded = jsonDecode(raw) as Map; + return GetReactionsResponse.fromJson(decoded['ocs'] as Map); + } @override Future? request(Uri uri, ApiParams? body, Map? headers) => http.get(uri, headers: headers); diff --git a/lib/api/marianumcloud/talk/getReactions/getReactionsResponse.dart b/lib/api/marianumcloud/talk/get_reactions/get_reactions_response.dart similarity index 92% rename from lib/api/marianumcloud/talk/getReactions/getReactionsResponse.dart rename to lib/api/marianumcloud/talk/get_reactions/get_reactions_response.dart index 5a6c9f0..052b03a 100644 --- a/lib/api/marianumcloud/talk/getReactions/getReactionsResponse.dart +++ b/lib/api/marianumcloud/talk/get_reactions/get_reactions_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getReactionsResponse.g.dart'; +part 'get_reactions_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetReactionsResponse extends ApiResponse { diff --git a/lib/api/marianumcloud/talk/getReactions/getReactionsResponse.g.dart b/lib/api/marianumcloud/talk/get_reactions/get_reactions_response.g.dart similarity index 97% rename from lib/api/marianumcloud/talk/getReactions/getReactionsResponse.g.dart rename to lib/api/marianumcloud/talk/get_reactions/get_reactions_response.g.dart index 2fa0d24..a050c59 100644 --- a/lib/api/marianumcloud/talk/getReactions/getReactionsResponse.g.dart +++ b/lib/api/marianumcloud/talk/get_reactions/get_reactions_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getReactionsResponse.dart'; +part of 'get_reactions_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/reactMessage/reactMessage.dart b/lib/api/marianumcloud/talk/react_message/react_message.dart similarity index 80% rename from lib/api/marianumcloud/talk/reactMessage/reactMessage.dart rename to lib/api/marianumcloud/talk/react_message/react_message.dart index ac76bd2..c1e93b1 100644 --- a/lib/api/marianumcloud/talk/reactMessage/reactMessage.dart +++ b/lib/api/marianumcloud/talk/react_message/react_message.dart @@ -1,9 +1,9 @@ import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import '../../../apiParams.dart'; -import '../talkApi.dart'; -import 'reactMessageParams.dart'; +import '../../../api_params.dart'; +import '../talk_api.dart'; +import 'react_message_params.dart'; class ReactMessage extends TalkApi { String chatToken; @@ -11,7 +11,7 @@ class ReactMessage extends TalkApi { ReactMessage({required this.chatToken, required this.messageId, required ReactMessageParams params}) : super('v1/reaction/$chatToken/$messageId', params); @override - assemble(String raw) => null; + Null assemble(String raw) => null; @override Future? request(Uri uri, ApiParams? body, Map? headers) { diff --git a/lib/api/marianumcloud/talk/reactMessage/reactMessageParams.dart b/lib/api/marianumcloud/talk/react_message/react_message_params.dart similarity index 83% rename from lib/api/marianumcloud/talk/reactMessage/reactMessageParams.dart rename to lib/api/marianumcloud/talk/react_message/react_message_params.dart index 0fb6cc1..22b8845 100644 --- a/lib/api/marianumcloud/talk/reactMessage/reactMessageParams.dart +++ b/lib/api/marianumcloud/talk/react_message/react_message_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'reactMessageParams.g.dart'; +part 'react_message_params.g.dart'; @JsonSerializable() class ReactMessageParams extends ApiParams { diff --git a/lib/api/marianumcloud/talk/reactMessage/reactMessageParams.g.dart b/lib/api/marianumcloud/talk/react_message/react_message_params.g.dart similarity index 93% rename from lib/api/marianumcloud/talk/reactMessage/reactMessageParams.g.dart rename to lib/api/marianumcloud/talk/react_message/react_message_params.g.dart index 328c53a..054b0c6 100644 --- a/lib/api/marianumcloud/talk/reactMessage/reactMessageParams.g.dart +++ b/lib/api/marianumcloud/talk/react_message/react_message_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'reactMessageParams.dart'; +part of 'react_message_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/room/getRoom.dart b/lib/api/marianumcloud/talk/room/get_room.dart similarity index 57% rename from lib/api/marianumcloud/talk/room/getRoom.dart rename to lib/api/marianumcloud/talk/room/get_room.dart index dd7cc52..bb7d68e 100644 --- a/lib/api/marianumcloud/talk/room/getRoom.dart +++ b/lib/api/marianumcloud/talk/room/get_room.dart @@ -2,9 +2,9 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import '../talkApi.dart'; -import 'getRoomParams.dart'; -import 'getRoomResponse.dart'; +import '../talk_api.dart'; +import 'get_room_params.dart'; +import 'get_room_response.dart'; class GetRoom extends TalkApi { @@ -14,7 +14,10 @@ class GetRoom extends TalkApi { @override - GetRoomResponse assemble(String raw) => GetRoomResponse.fromJson(jsonDecode(raw)['ocs']); + GetRoomResponse assemble(String raw) { + final decoded = jsonDecode(raw) as Map; + return GetRoomResponse.fromJson(decoded['ocs'] as Map); + } @override Future request(Uri uri, Object? body, Map? headers) => http.get(uri, headers: headers); diff --git a/lib/api/marianumcloud/talk/room/getRoomCache.dart b/lib/api/marianumcloud/talk/room/get_room_cache.dart similarity index 73% rename from lib/api/marianumcloud/talk/room/getRoomCache.dart rename to lib/api/marianumcloud/talk/room/get_room_cache.dart index 03fd785..107a58b 100644 --- a/lib/api/marianumcloud/talk/room/getRoomCache.dart +++ b/lib/api/marianumcloud/talk/room/get_room_cache.dart @@ -1,7 +1,7 @@ -import '../../../requestCache.dart'; -import 'getRoom.dart'; -import 'getRoomParams.dart'; -import 'getRoomResponse.dart'; +import '../../../request_cache.dart'; +import 'get_room.dart'; +import 'get_room_params.dart'; +import 'get_room_response.dart'; class GetRoomCache extends SimpleCache { GetRoomCache({super.onUpdate, super.onError, super.renew}) diff --git a/lib/api/marianumcloud/talk/room/getRoomParams.dart b/lib/api/marianumcloud/talk/room/get_room_params.dart similarity index 90% rename from lib/api/marianumcloud/talk/room/getRoomParams.dart rename to lib/api/marianumcloud/talk/room/get_room_params.dart index 70d371d..09e397e 100644 --- a/lib/api/marianumcloud/talk/room/getRoomParams.dart +++ b/lib/api/marianumcloud/talk/room/get_room_params.dart @@ -1,9 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'getRoomParams.g.dart'; +part 'get_room_params.g.dart'; @JsonSerializable(explicitToJson: true) class GetRoomParams extends ApiParams { diff --git a/lib/api/marianumcloud/talk/room/getRoomParams.g.dart b/lib/api/marianumcloud/talk/room/get_room_params.g.dart similarity index 96% rename from lib/api/marianumcloud/talk/room/getRoomParams.g.dart rename to lib/api/marianumcloud/talk/room/get_room_params.g.dart index 0e20511..ceaa6b1 100644 --- a/lib/api/marianumcloud/talk/room/getRoomParams.g.dart +++ b/lib/api/marianumcloud/talk/room/get_room_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getRoomParams.dart'; +part of 'get_room_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/room/getRoomResponse.dart b/lib/api/marianumcloud/talk/room/get_room_response.dart similarity index 97% rename from lib/api/marianumcloud/talk/room/getRoomResponse.dart rename to lib/api/marianumcloud/talk/room/get_room_response.dart index 36e7b6d..c2ce467 100644 --- a/lib/api/marianumcloud/talk/room/getRoomResponse.dart +++ b/lib/api/marianumcloud/talk/room/get_room_response.dart @@ -1,9 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; -import '../chat/getChatResponse.dart'; +import '../../../api_response.dart'; +import '../chat/get_chat_response.dart'; -part 'getRoomResponse.g.dart'; +part 'get_room_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetRoomResponse extends ApiResponse { diff --git a/lib/api/marianumcloud/talk/room/getRoomResponse.g.dart b/lib/api/marianumcloud/talk/room/get_room_response.g.dart similarity index 99% rename from lib/api/marianumcloud/talk/room/getRoomResponse.g.dart rename to lib/api/marianumcloud/talk/room/get_room_response.g.dart index 92491fb..49362cc 100644 --- a/lib/api/marianumcloud/talk/room/getRoomResponse.g.dart +++ b/lib/api/marianumcloud/talk/room/get_room_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getRoomResponse.dart'; +part of 'get_room_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/sendMessage/sendMessage.dart b/lib/api/marianumcloud/talk/send_message/send_message.dart similarity index 77% rename from lib/api/marianumcloud/talk/sendMessage/sendMessage.dart rename to lib/api/marianumcloud/talk/send_message/send_message.dart index 61af457..af3a012 100644 --- a/lib/api/marianumcloud/talk/sendMessage/sendMessage.dart +++ b/lib/api/marianumcloud/talk/send_message/send_message.dart @@ -1,16 +1,16 @@ import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import '../../../apiParams.dart'; -import '../talkApi.dart'; -import 'sendMessageParams.dart'; +import '../../../api_params.dart'; +import '../talk_api.dart'; +import 'send_message_params.dart'; class SendMessage extends TalkApi { String chatToken; SendMessage(this.chatToken, SendMessageParams params) : super('v1/chat/$chatToken', params); @override - assemble(String raw) => null; + Null assemble(String raw) => null; @override Future? request(Uri uri, ApiParams? body, Map? headers) { diff --git a/lib/api/marianumcloud/talk/sendMessage/sendMessageParams.dart b/lib/api/marianumcloud/talk/send_message/send_message_params.dart similarity index 85% rename from lib/api/marianumcloud/talk/sendMessage/sendMessageParams.dart rename to lib/api/marianumcloud/talk/send_message/send_message_params.dart index d467246..8ded2e2 100644 --- a/lib/api/marianumcloud/talk/sendMessage/sendMessageParams.dart +++ b/lib/api/marianumcloud/talk/send_message/send_message_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'sendMessageParams.g.dart'; +part 'send_message_params.g.dart'; @JsonSerializable(explicitToJson: true, includeIfNull: false) class SendMessageParams extends ApiParams { diff --git a/lib/api/marianumcloud/talk/sendMessage/sendMessageParams.g.dart b/lib/api/marianumcloud/talk/send_message/send_message_params.g.dart similarity index 94% rename from lib/api/marianumcloud/talk/sendMessage/sendMessageParams.g.dart rename to lib/api/marianumcloud/talk/send_message/send_message_params.g.dart index 602decc..34e1d0c 100644 --- a/lib/api/marianumcloud/talk/sendMessage/sendMessageParams.g.dart +++ b/lib/api/marianumcloud/talk/send_message/send_message_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'sendMessageParams.dart'; +part of 'send_message_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/setReadMarker/setReadMarker.dart b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart similarity index 87% rename from lib/api/marianumcloud/talk/setReadMarker/setReadMarker.dart rename to lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart index c3ae029..24389ef 100644 --- a/lib/api/marianumcloud/talk/setReadMarker/setReadMarker.dart +++ b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart @@ -2,8 +2,8 @@ import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import '../talkApi.dart'; -import 'setReadMarkerParams.dart'; +import '../talk_api.dart'; +import 'set_read_marker_params.dart'; class SetReadMarker extends TalkApi { String chatToken; @@ -15,7 +15,7 @@ class SetReadMarker extends TalkApi { } @override - assemble(String raw) => null; + Null assemble(String raw) => null; @override Future request(Uri uri, Object? body, Map? headers) { diff --git a/lib/api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dart b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart similarity index 83% rename from lib/api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dart rename to lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart index 5f037c2..50edee7 100644 --- a/lib/api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dart +++ b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'setReadMarkerParams.g.dart'; +part 'set_read_marker_params.g.dart'; @JsonSerializable() class SetReadMarkerParams extends ApiParams { diff --git a/lib/api/marianumcloud/talk/setReadMarker/setReadMarkerParams.g.dart b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.g.dart similarity index 93% rename from lib/api/marianumcloud/talk/setReadMarker/setReadMarkerParams.g.dart rename to lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.g.dart index 5c3c941..e6067b1 100644 --- a/lib/api/marianumcloud/talk/setReadMarker/setReadMarkerParams.g.dart +++ b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'setReadMarkerParams.dart'; +part of 'set_read_marker_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/talkApi.dart b/lib/api/marianumcloud/talk/talk_api.dart similarity index 96% rename from lib/api/marianumcloud/talk/talkApi.dart rename to lib/api/marianumcloud/talk/talk_api.dart index 371d9f2..9e63d35 100644 --- a/lib/api/marianumcloud/talk/talkApi.dart +++ b/lib/api/marianumcloud/talk/talk_api.dart @@ -4,9 +4,9 @@ import 'dart:io'; import 'package:http/http.dart' as http; -import '../../apiParams.dart'; -import '../../apiRequest.dart'; -import '../../apiResponse.dart'; +import '../../api_params.dart'; +import '../../api_request.dart'; +import '../../api_response.dart'; import '../../errors/auth_exception.dart'; import '../../errors/network_exception.dart'; import '../../errors/not_found_exception.dart'; diff --git a/lib/api/marianumcloud/talk/talkError.dart b/lib/api/marianumcloud/talk/talk_error.dart similarity index 100% rename from lib/api/marianumcloud/talk/talkError.dart rename to lib/api/marianumcloud/talk/talk_error.dart diff --git a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFile.dart b/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFile.dart deleted file mode 100644 index 30269c6..0000000 --- a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFile.dart +++ /dev/null @@ -1,22 +0,0 @@ - - -import '../../../../apiResponse.dart'; -import '../../webdavApi.dart'; -import 'downloadFileParams.dart'; - -class DownloadFile extends WebdavApi { - DownloadFileParams params; - - DownloadFile(this.params) : super(params); - - @override - Future run() async { - - // final file = await File(localPath).create(); - // Uint8List downloadedFile = await (await WebdavApi.webdav).download(params.webdavPath); - // file.writeAsBytesSync(downloadedFile, flush: true, mode: FileMode.write); - // - // OpenFile.open(localPath); - throw UnimplementedError(); - } -} diff --git a/lib/api/marianumcloud/webdav/queries/download_file/download_file.dart b/lib/api/marianumcloud/webdav/queries/download_file/download_file.dart new file mode 100644 index 0000000..5a4f164 --- /dev/null +++ b/lib/api/marianumcloud/webdav/queries/download_file/download_file.dart @@ -0,0 +1,16 @@ + + +import '../../../../api_response.dart'; +import '../../webdav_api.dart'; +import 'download_file_params.dart'; + +class DownloadFile extends WebdavApi { + DownloadFileParams params; + + DownloadFile(this.params) : super(params); + + @override + Future run() async { + throw UnimplementedError(); + } +} diff --git a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileParams.dart b/lib/api/marianumcloud/webdav/queries/download_file/download_file_params.dart similarity index 85% rename from lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileParams.dart rename to lib/api/marianumcloud/webdav/queries/download_file/download_file_params.dart index 11b6231..ba8b075 100644 --- a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileParams.dart +++ b/lib/api/marianumcloud/webdav/queries/download_file/download_file_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../../apiParams.dart'; +import '../../../../api_params.dart'; -part 'downloadFileParams.g.dart'; +part 'download_file_params.g.dart'; @JsonSerializable() class DownloadFileParams extends ApiParams { diff --git a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileParams.g.dart b/lib/api/marianumcloud/webdav/queries/download_file/download_file_params.g.dart similarity index 95% rename from lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileParams.g.dart rename to lib/api/marianumcloud/webdav/queries/download_file/download_file_params.g.dart index 3b56332..aa72134 100644 --- a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileParams.g.dart +++ b/lib/api/marianumcloud/webdav/queries/download_file/download_file_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'downloadFileParams.dart'; +part of 'download_file_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileResponse.dart b/lib/api/marianumcloud/webdav/queries/download_file/download_file_response.dart similarity index 89% rename from lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileResponse.dart rename to lib/api/marianumcloud/webdav/queries/download_file/download_file_response.dart index 3368cdd..76ff712 100644 --- a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileResponse.dart +++ b/lib/api/marianumcloud/webdav/queries/download_file/download_file_response.dart @@ -1,7 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; -part 'downloadFileResponse.g.dart'; +part 'download_file_response.g.dart'; @JsonSerializable() class DownloadFileResponse { diff --git a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileResponse.g.dart b/lib/api/marianumcloud/webdav/queries/download_file/download_file_response.g.dart similarity index 92% rename from lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileResponse.g.dart rename to lib/api/marianumcloud/webdav/queries/download_file/download_file_response.g.dart index 745d832..bab583f 100644 --- a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileResponse.g.dart +++ b/lib/api/marianumcloud/webdav/queries/download_file/download_file_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'downloadFileResponse.dart'; +part of 'download_file_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart b/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart deleted file mode 100644 index 3a61460..0000000 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'dart:convert'; -import 'package:crypto/crypto.dart'; - -import '../../../../requestCache.dart'; -import 'listFiles.dart'; -import 'listFilesParams.dart'; -import 'listFilesResponse.dart'; - -class ListFilesCache extends SimpleCache { - ListFilesCache({ - required void Function(ListFilesResponse) onUpdate, - super.onCacheData, - super.onNetworkData, - super.onError, - required String path, - }) : super( - cacheTime: RequestCache.cacheNothing, - loader: () => ListFiles(ListFilesParams(path)).run(), - fromJson: ListFilesResponse.fromJson, - onUpdate: onUpdate, - ) { - final cacheName = md5.convert(utf8.encode('MarianumMobile-$path')).toString(); - start('wd-folder-$cacheName'); - } -} diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart similarity index 88% rename from lib/api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart rename to lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart index b8a9918..c716dfb 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart @@ -1,7 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:nextcloud/nextcloud.dart'; -part 'cacheableFile.g.dart'; +part 'cacheable_file.g.dart'; @JsonSerializable(explicitToJson: true) class CacheableFile { @@ -15,10 +15,6 @@ class CacheableFile { DateTime? modifiedAt; String? sort; - @JsonKey(includeFromJson: false, includeToJson: false) - bool currentlyDownloading = false; - - CacheableFile({required this.path, required this.isDirectory, required this.name, this.mimeType, this.size, this.eTag, this.createdAt, this.modifiedAt}); CacheableFile.fromDavFile(WebDavFile file) { diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/cacheableFile.g.dart b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.g.dart similarity index 97% rename from lib/api/marianumcloud/webdav/queries/listFiles/cacheableFile.g.dart rename to lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.g.dart index c79547b..4e3407d 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/cacheableFile.g.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'cacheableFile.dart'; +part of 'cacheable_file.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFiles.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files.dart similarity index 67% rename from lib/api/marianumcloud/webdav/queries/listFiles/listFiles.dart rename to lib/api/marianumcloud/webdav/queries/list_files/list_files.dart index 8582989..6bebff9 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFiles.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files.dart @@ -1,10 +1,10 @@ import 'package:nextcloud/nextcloud.dart'; -import '../../webdavApi.dart'; -import 'cacheableFile.dart'; -import 'listFilesParams.dart'; -import 'listFilesResponse.dart'; +import '../../webdav_api.dart'; +import 'cacheable_file.dart'; +import 'list_files_params.dart'; +import 'list_files_response.dart'; class ListFiles extends WebdavApi { ListFilesParams params; @@ -27,16 +27,8 @@ class ListFiles extends WebdavApi { final webdav = await WebdavApi.webdav; final timeout = _isRoot ? _rootTimeout : _subfolderTimeout; final davFiles = (await webdav.propfind(PathUri.parse(params.path)).timeout(timeout)).toWebDavFiles(); - var files = davFiles.map(CacheableFile.fromDavFile).toSet(); + final files = davFiles.map(CacheableFile.fromDavFile).toSet(); - // webdav handles subdirectories wrong, this is a fix - // currently this fix is not needed anymore - // if(EndpointData().getEndpointMode() == EndpointMode.stage) { - // files = files.map((e) { // somehow - // e.path = e.path.split("mobile/cloud/remote.php/webdav")[1]; - // return e; - // }).toSet(); - // } // somehow the current working folder is also listed, it is filtered here. files.removeWhere((element) => element.path == '/${params.path}/' || element.path == '/'); diff --git a/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart new file mode 100644 index 0000000..4f17e6e --- /dev/null +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:localstore/localstore.dart'; + +import '../../../../../utils/cache_invalidation_bus.dart'; +import '../../../../request_cache.dart'; +import 'list_files.dart'; +import 'list_files_params.dart'; +import 'list_files_response.dart'; + +class ListFilesCache extends SimpleCache { + ListFilesCache({ + required void Function(ListFilesResponse) onUpdate, + super.onCacheData, + super.onNetworkData, + super.onError, + required String path, + }) : super( + cacheTime: RequestCache.cacheNothing, + loader: () => ListFiles(ListFilesParams(path)).run(), + fromJson: ListFilesResponse.fromJson, + onUpdate: onUpdate, + ) { + start(_documentId(path)); + } + + static String _documentId(String path) { + final cacheName = md5.convert(utf8.encode('MarianumMobile-$path')).toString(); + return 'wd-folder-$cacheName'; + } + + /// Drops the cached listing for [path] from local storage so the next + /// `listFiles` call cannot serve stale data, and notifies any live + /// `_FilesView` for that path via [CacheInvalidationBus] so it refetches + /// even while it is sitting in the background of the navigation stack. + static Future invalidate(String path) async { + await Localstore.instance.collection(RequestCache.collection).doc(_documentId(path)).delete(); + CacheInvalidationBus.notifyListFiles(path); + } +} diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesParams.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_params.dart similarity index 83% rename from lib/api/marianumcloud/webdav/queries/listFiles/listFilesParams.dart rename to lib/api/marianumcloud/webdav/queries/list_files/list_files_params.dart index f0adf21..c18a539 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesParams.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../../apiParams.dart'; +import '../../../../api_params.dart'; -part 'listFilesParams.g.dart'; +part 'list_files_params.g.dart'; @JsonSerializable(explicitToJson: true) class ListFilesParams extends ApiParams { diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesParams.g.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_params.g.dart similarity index 93% rename from lib/api/marianumcloud/webdav/queries/listFiles/listFilesParams.g.dart rename to lib/api/marianumcloud/webdav/queries/list_files/list_files_params.g.dart index 6a72efd..29f551f 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesParams.g.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'listFilesParams.dart'; +part of 'list_files_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart similarity index 94% rename from lib/api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart rename to lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart index 59f8d0e..1983583 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart @@ -2,10 +2,10 @@ import 'package:jiffy/jiffy.dart'; import 'package:json_annotation/json_annotation.dart'; import '../../../../../view/pages/files/files.dart'; -import '../../../../apiResponse.dart'; -import 'cacheableFile.dart'; +import '../../../../api_response.dart'; +import 'cacheable_file.dart'; -part 'listFilesResponse.g.dart'; +part 'list_files_response.g.dart'; @JsonSerializable(explicitToJson: true) class ListFilesResponse extends ApiResponse { diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesResponse.g.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.g.dart similarity index 95% rename from lib/api/marianumcloud/webdav/queries/listFiles/listFilesResponse.g.dart rename to lib/api/marianumcloud/webdav/queries/list_files/list_files_response.g.dart index 77522c2..09123cc 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesResponse.g.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'listFilesResponse.dart'; +part of 'list_files_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/webdav/webdavApi.dart b/lib/api/marianumcloud/webdav/webdav_api.dart similarity index 93% rename from lib/api/marianumcloud/webdav/webdavApi.dart rename to lib/api/marianumcloud/webdav/webdav_api.dart index 5049153..3327b62 100644 --- a/lib/api/marianumcloud/webdav/webdavApi.dart +++ b/lib/api/marianumcloud/webdav/webdav_api.dart @@ -2,8 +2,8 @@ import 'package:nextcloud/nextcloud.dart'; import '../../../model/account_data.dart'; import '../../../model/endpoint_data.dart'; -import '../../apiRequest.dart'; -import '../../apiResponse.dart'; +import '../../api_request.dart'; +import '../../api_response.dart'; abstract class WebdavApi extends ApiRequest { T genericParams; diff --git a/lib/api/mhsl/breaker/getBreakers/getBreakers.dart b/lib/api/mhsl/breaker/get_breakers/get_breakers.dart similarity index 73% rename from lib/api/mhsl/breaker/getBreakers/getBreakers.dart rename to lib/api/mhsl/breaker/get_breakers/get_breakers.dart index 63d2fe0..b8f5c93 100644 --- a/lib/api/mhsl/breaker/getBreakers/getBreakers.dart +++ b/lib/api/mhsl/breaker/get_breakers/get_breakers.dart @@ -2,14 +2,14 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import '../../mhslApi.dart'; -import 'getBreakersResponse.dart'; +import '../../mhsl_api.dart'; +import 'get_breakers_response.dart'; class GetBreakers extends MhslApi { GetBreakers() : super('breaker/'); @override - GetBreakersResponse assemble(String raw) => GetBreakersResponse.fromJson(jsonDecode(raw)); + GetBreakersResponse assemble(String raw) => GetBreakersResponse.fromJson(jsonDecode(raw) as Map); @override Future? request(Uri uri) => http.get(uri); diff --git a/lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart b/lib/api/mhsl/breaker/get_breakers/get_breakers_cache.dart similarity index 75% rename from lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart rename to lib/api/mhsl/breaker/get_breakers/get_breakers_cache.dart index d7bc0f8..8f3c180 100644 --- a/lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart +++ b/lib/api/mhsl/breaker/get_breakers/get_breakers_cache.dart @@ -1,6 +1,6 @@ -import '../../../requestCache.dart'; -import 'getBreakers.dart'; -import 'getBreakersResponse.dart'; +import '../../../request_cache.dart'; +import 'get_breakers.dart'; +import 'get_breakers_response.dart'; class GetBreakersCache extends SimpleCache { GetBreakersCache({super.onUpdate, super.renew}) diff --git a/lib/api/mhsl/breaker/getBreakers/getBreakersResponse.dart b/lib/api/mhsl/breaker/get_breakers/get_breakers_response.dart similarity index 93% rename from lib/api/mhsl/breaker/getBreakers/getBreakersResponse.dart rename to lib/api/mhsl/breaker/get_breakers/get_breakers_response.dart index 6e0cb73..aa0f3b1 100644 --- a/lib/api/mhsl/breaker/getBreakers/getBreakersResponse.dart +++ b/lib/api/mhsl/breaker/get_breakers/get_breakers_response.dart @@ -1,9 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getBreakersResponse.g.dart'; +part 'get_breakers_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetBreakersResponse extends ApiResponse { diff --git a/lib/api/mhsl/breaker/getBreakers/getBreakersResponse.g.dart b/lib/api/mhsl/breaker/get_breakers/get_breakers_response.g.dart similarity index 97% rename from lib/api/mhsl/breaker/getBreakers/getBreakersResponse.g.dart rename to lib/api/mhsl/breaker/get_breakers/get_breakers_response.g.dart index 5a2418c..30d16e6 100644 --- a/lib/api/mhsl/breaker/getBreakers/getBreakersResponse.g.dart +++ b/lib/api/mhsl/breaker/get_breakers/get_breakers_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getBreakersResponse.dart'; +part of 'get_breakers_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart similarity index 85% rename from lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart rename to lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart index 15f237f..c7fe3fc 100644 --- a/lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart +++ b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart @@ -3,8 +3,8 @@ import 'dart:convert'; import 'package:http/http.dart'; import 'package:http/http.dart' as http; -import '../../mhslApi.dart'; -import 'addCustomTimetableEventParams.dart'; +import '../../mhsl_api.dart'; +import 'add_custom_timetable_event_params.dart'; class AddCustomTimetableEvent extends MhslApi { AddCustomTimetableEventParams params; diff --git a/lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.dart b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart similarity index 83% rename from lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.dart rename to lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart index 125d3ea..a1d3b74 100644 --- a/lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.dart +++ b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../customTimetableEvent.dart'; +import '../custom_timetable_event.dart'; -part 'addCustomTimetableEventParams.g.dart'; +part 'add_custom_timetable_event_params.g.dart'; @JsonSerializable(explicitToJson: true) class AddCustomTimetableEventParams { diff --git a/lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.g.dart b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.g.dart similarity index 92% rename from lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.g.dart rename to lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.g.dart index e8d2b80..eb39f91 100644 --- a/lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.g.dart +++ b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'addCustomTimetableEventParams.dart'; +part of 'add_custom_timetable_event_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/customTimetableEvent/customTimetableEvent.dart b/lib/api/mhsl/custom_timetable_event/custom_timetable_event.dart similarity index 93% rename from lib/api/mhsl/customTimetableEvent/customTimetableEvent.dart rename to lib/api/mhsl/custom_timetable_event/custom_timetable_event.dart index d34489b..be9e4a6 100644 --- a/lib/api/mhsl/customTimetableEvent/customTimetableEvent.dart +++ b/lib/api/mhsl/custom_timetable_event/custom_timetable_event.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../mhslApi.dart'; +import '../mhsl_api.dart'; -part 'customTimetableEvent.g.dart'; +part 'custom_timetable_event.g.dart'; @JsonSerializable() class CustomTimetableEvent { diff --git a/lib/api/mhsl/customTimetableEvent/customTimetableEvent.g.dart b/lib/api/mhsl/custom_timetable_event/custom_timetable_event.g.dart similarity index 97% rename from lib/api/mhsl/customTimetableEvent/customTimetableEvent.g.dart rename to lib/api/mhsl/custom_timetable_event/custom_timetable_event.g.dart index e15d6d8..b83138b 100644 --- a/lib/api/mhsl/customTimetableEvent/customTimetableEvent.g.dart +++ b/lib/api/mhsl/custom_timetable_event/custom_timetable_event.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'customTimetableEvent.dart'; +part of 'custom_timetable_event.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEvent.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart similarity index 80% rename from lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEvent.dart rename to lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart index bca7fd0..dbdf476 100644 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEvent.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart @@ -3,9 +3,9 @@ import 'dart:convert'; import 'package:http/http.dart'; import 'package:http/http.dart' as http; -import '../../mhslApi.dart'; -import 'getCustomTimetableEventParams.dart'; -import 'getCustomTimetableEventResponse.dart'; +import '../../mhsl_api.dart'; +import 'get_custom_timetable_event_params.dart'; +import 'get_custom_timetable_event_response.dart'; class GetCustomTimetableEvent extends MhslApi { GetCustomTimetableEventParams params; diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart similarity index 72% rename from lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart rename to lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart index 5adc186..ba49152 100644 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart @@ -1,7 +1,7 @@ -import '../../../requestCache.dart'; -import 'getCustomTimetableEvent.dart'; -import 'getCustomTimetableEventParams.dart'; -import 'getCustomTimetableEventResponse.dart'; +import '../../../request_cache.dart'; +import 'get_custom_timetable_event.dart'; +import 'get_custom_timetable_event_params.dart'; +import 'get_custom_timetable_event_response.dart'; class GetCustomTimetableEventCache extends SimpleCache { GetCustomTimetableEventCache( diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart similarity index 88% rename from lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.dart rename to lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart index c4d6c79..58a9103 100644 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'getCustomTimetableEventParams.g.dart'; +part 'get_custom_timetable_event_params.g.dart'; @JsonSerializable() class GetCustomTimetableEventParams { diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.g.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.g.dart similarity index 91% rename from lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.g.dart rename to lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.g.dart index 6fc4044..01fc5a5 100644 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.g.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getCustomTimetableEventParams.dart'; +part of 'get_custom_timetable_event_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart similarity index 77% rename from lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart rename to lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart index c086b73..99684a2 100644 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart @@ -1,9 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; -import '../customTimetableEvent.dart'; +import '../../../api_response.dart'; +import '../custom_timetable_event.dart'; -part 'getCustomTimetableEventResponse.g.dart'; +part 'get_custom_timetable_event_response.g.dart'; @JsonSerializable() class GetCustomTimetableEventResponse extends ApiResponse { diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.g.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.g.dart similarity index 94% rename from lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.g.dart rename to lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.g.dart index 3e16db0..5bed445 100644 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.g.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getCustomTimetableEventResponse.dart'; +part of 'get_custom_timetable_event_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEvent.dart b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart similarity index 84% rename from lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEvent.dart rename to lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart index add1c55..436395e 100644 --- a/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEvent.dart +++ b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart @@ -3,8 +3,8 @@ import 'dart:convert'; import 'package:http/http.dart'; import 'package:http/http.dart' as http; -import '../../mhslApi.dart'; -import 'removeCustomTimetableEventParams.dart'; +import '../../mhsl_api.dart'; +import 'remove_custom_timetable_event_params.dart'; class RemoveCustomTimetableEvent extends MhslApi { RemoveCustomTimetableEventParams params; diff --git a/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.dart b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart similarity index 88% rename from lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.dart rename to lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart index 3b1d989..a84ba07 100644 --- a/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.dart +++ b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'removeCustomTimetableEventParams.g.dart'; +part 'remove_custom_timetable_event_params.g.dart'; @JsonSerializable() class RemoveCustomTimetableEventParams { diff --git a/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.g.dart b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.g.dart similarity index 91% rename from lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.g.dart rename to lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.g.dart index aa30f6f..4e85a31 100644 --- a/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.g.dart +++ b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'removeCustomTimetableEventParams.dart'; +part of 'remove_custom_timetable_event_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEvent.dart b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart similarity index 84% rename from lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEvent.dart rename to lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart index 9b1a754..4ae91d4 100644 --- a/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEvent.dart +++ b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart @@ -3,8 +3,8 @@ import 'dart:convert'; import 'package:http/http.dart'; import 'package:http/http.dart' as http; -import '../../mhslApi.dart'; -import 'updateCustomTimetableEventParams.dart'; +import '../../mhsl_api.dart'; +import 'update_custom_timetable_event_params.dart'; class UpdateCustomTimetableEvent extends MhslApi { UpdateCustomTimetableEventParams params; diff --git a/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.dart b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart similarity index 83% rename from lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.dart rename to lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart index 4a09c83..75f4dae 100644 --- a/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.dart +++ b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart @@ -1,9 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; -import '../customTimetableEvent.dart'; +import '../custom_timetable_event.dart'; -part 'updateCustomTimetableEventParams.g.dart'; +part 'update_custom_timetable_event_params.g.dart'; @JsonSerializable(explicitToJson: true) class UpdateCustomTimetableEventParams { diff --git a/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.g.dart b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.g.dart similarity index 92% rename from lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.g.dart rename to lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.g.dart index 11ec8e9..37c5d71 100644 --- a/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.g.dart +++ b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'updateCustomTimetableEventParams.dart'; +part of 'update_custom_timetable_event_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/mhslApi.dart b/lib/api/mhsl/mhsl_api.dart similarity index 98% rename from lib/api/mhsl/mhslApi.dart rename to lib/api/mhsl/mhsl_api.dart index 55f31c1..da380f3 100644 --- a/lib/api/mhsl/mhslApi.dart +++ b/lib/api/mhsl/mhsl_api.dart @@ -5,7 +5,7 @@ import 'dart:io'; import 'package:http/http.dart' as http; import 'package:jiffy/jiffy.dart'; -import '../apiRequest.dart'; +import '../api_request.dart'; import '../errors/network_exception.dart'; import '../errors/parse_exception.dart'; import '../errors/server_exception.dart'; diff --git a/lib/api/mhsl/notify/register/notifyRegister.dart b/lib/api/mhsl/notify/register/notify_register.dart similarity index 88% rename from lib/api/mhsl/notify/register/notifyRegister.dart rename to lib/api/mhsl/notify/register/notify_register.dart index a7053dc..b28c3dc 100644 --- a/lib/api/mhsl/notify/register/notifyRegister.dart +++ b/lib/api/mhsl/notify/register/notify_register.dart @@ -4,8 +4,8 @@ import 'dart:developer'; import 'package:http/http.dart' as http; -import '../../mhslApi.dart'; -import 'notifyRegisterParams.dart'; +import '../../mhsl_api.dart'; +import 'notify_register_params.dart'; class NotifyRegister extends MhslApi { NotifyRegisterParams params; diff --git a/lib/api/mhsl/notify/register/notifyRegisterParams.dart b/lib/api/mhsl/notify/register/notify_register_params.dart similarity index 92% rename from lib/api/mhsl/notify/register/notifyRegisterParams.dart rename to lib/api/mhsl/notify/register/notify_register_params.dart index 3f18319..1c92c46 100644 --- a/lib/api/mhsl/notify/register/notifyRegisterParams.dart +++ b/lib/api/mhsl/notify/register/notify_register_params.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'notifyRegisterParams.g.dart'; +part 'notify_register_params.g.dart'; @JsonSerializable() class NotifyRegisterParams { diff --git a/lib/api/mhsl/notify/register/notifyRegisterParams.g.dart b/lib/api/mhsl/notify/register/notify_register_params.g.dart similarity index 94% rename from lib/api/mhsl/notify/register/notifyRegisterParams.g.dart rename to lib/api/mhsl/notify/register/notify_register_params.g.dart index d862558..ad53ab5 100644 --- a/lib/api/mhsl/notify/register/notifyRegisterParams.g.dart +++ b/lib/api/mhsl/notify/register/notify_register_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'notifyRegisterParams.dart'; +part of 'notify_register_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/server/feedback/addFeedback.dart b/lib/api/mhsl/server/feedback/add_feedback.dart similarity index 85% rename from lib/api/mhsl/server/feedback/addFeedback.dart rename to lib/api/mhsl/server/feedback/add_feedback.dart index 54c3ce0..7f69978 100644 --- a/lib/api/mhsl/server/feedback/addFeedback.dart +++ b/lib/api/mhsl/server/feedback/add_feedback.dart @@ -3,8 +3,8 @@ import 'dart:convert'; import 'package:http/http.dart'; import 'package:http/http.dart' as http; -import '../../mhslApi.dart'; -import 'addFeedbackParams.dart'; +import '../../mhsl_api.dart'; +import 'add_feedback_params.dart'; class AddFeedback extends MhslApi { diff --git a/lib/api/mhsl/server/feedback/addFeedbackParams.dart b/lib/api/mhsl/server/feedback/add_feedback_params.dart similarity index 93% rename from lib/api/mhsl/server/feedback/addFeedbackParams.dart rename to lib/api/mhsl/server/feedback/add_feedback_params.dart index 945b00c..ecf9adb 100644 --- a/lib/api/mhsl/server/feedback/addFeedbackParams.dart +++ b/lib/api/mhsl/server/feedback/add_feedback_params.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'addFeedbackParams.g.dart'; +part 'add_feedback_params.g.dart'; @JsonSerializable() class AddFeedbackParams { diff --git a/lib/api/mhsl/server/feedback/addFeedbackParams.g.dart b/lib/api/mhsl/server/feedback/add_feedback_params.g.dart similarity index 95% rename from lib/api/mhsl/server/feedback/addFeedbackParams.g.dart rename to lib/api/mhsl/server/feedback/add_feedback_params.g.dart index ec85d22..747d43b 100644 --- a/lib/api/mhsl/server/feedback/addFeedbackParams.g.dart +++ b/lib/api/mhsl/server/feedback/add_feedback_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'addFeedbackParams.dart'; +part of 'add_feedback_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/server/userIndex/update/updateUserIndexParams.dart b/lib/api/mhsl/server/user_index/update/update_user_index_params.dart similarity index 93% rename from lib/api/mhsl/server/userIndex/update/updateUserIndexParams.dart rename to lib/api/mhsl/server/user_index/update/update_user_index_params.dart index 680edda..7fd07f4 100644 --- a/lib/api/mhsl/server/userIndex/update/updateUserIndexParams.dart +++ b/lib/api/mhsl/server/user_index/update/update_user_index_params.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'updateUserIndexParams.g.dart'; +part 'update_user_index_params.g.dart'; @JsonSerializable() class UpdateUserIndexParams { diff --git a/lib/api/mhsl/server/userIndex/update/updateUserIndexParams.g.dart b/lib/api/mhsl/server/user_index/update/update_user_index_params.g.dart similarity index 95% rename from lib/api/mhsl/server/userIndex/update/updateUserIndexParams.g.dart rename to lib/api/mhsl/server/user_index/update/update_user_index_params.g.dart index 285302a..4d8775a 100644 --- a/lib/api/mhsl/server/userIndex/update/updateUserIndexParams.g.dart +++ b/lib/api/mhsl/server/user_index/update/update_user_index_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'updateUserIndexParams.dart'; +part of 'update_user_index_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/server/userIndex/update/updateUserindex.dart b/lib/api/mhsl/server/user_index/update/update_userindex.dart similarity index 88% rename from lib/api/mhsl/server/userIndex/update/updateUserindex.dart rename to lib/api/mhsl/server/user_index/update/update_userindex.dart index c020fe3..9b728b6 100644 --- a/lib/api/mhsl/server/userIndex/update/updateUserindex.dart +++ b/lib/api/mhsl/server/user_index/update/update_userindex.dart @@ -1,4 +1,5 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:developer'; @@ -7,8 +8,8 @@ import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; import '../../../../../model/account_data.dart'; -import '../../../mhslApi.dart'; -import 'updateUserIndexParams.dart'; +import '../../../mhsl_api.dart'; +import 'update_user_index_params.dart'; class UpdateUserIndex extends MhslApi { UpdateUserIndexParams params; @@ -25,7 +26,7 @@ class UpdateUserIndex extends MhslApi { } static Future index() async { - UpdateUserIndex( + unawaited(UpdateUserIndex( UpdateUserIndexParams( username: AccountData().getUsername(), user: AccountData().getUserSecret(), @@ -33,6 +34,6 @@ class UpdateUserIndex extends MhslApi { appVersion: int.parse((await PackageInfo.fromPlatform()).buildNumber), deviceInfo: jsonEncode((await DeviceInfoPlugin().deviceInfo).data).toString(), ), - ).run(); + ).run()); } } diff --git a/lib/api/requestCache.dart b/lib/api/request_cache.dart similarity index 89% rename from lib/api/requestCache.dart rename to lib/api/request_cache.dart index df88c25..8d6fdb6 100644 --- a/lib/api/requestCache.dart +++ b/lib/api/request_cache.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:localstore/localstore.dart'; -import 'apiResponse.dart'; +import 'api_response.dart'; abstract class RequestCache { static const int cacheNothing = 0; @@ -50,12 +50,13 @@ abstract class RequestCache { try { final tableData = await Localstore.instance.collection(collection).doc(document).get(); if (tableData != null) { - final cached = onLocalData(tableData['json']); + final cached = onLocalData(tableData['json'] as String); onUpdate?.call(cached); onCacheData?.call(cached); } - if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < (tableData?['lastupdate'] ?? 0)) { + final lastUpdate = (tableData?['lastupdate'] as num?) ?? 0; + if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < lastUpdate) { if (renew == null || !renew!) return; } @@ -63,10 +64,10 @@ abstract class RequestCache { final newValue = await onLoad(); onUpdate?.call(newValue); onNetworkData?.call(newValue); - Localstore.instance.collection(collection).doc(document).set({ + unawaited(Localstore.instance.collection(collection).doc(document).set({ 'json': jsonEncode(newValue), 'lastupdate': DateTime.now().millisecondsSinceEpoch, - }); + })); } on Exception catch (e) { onError(e); } @@ -112,5 +113,5 @@ class SimpleCache extends RequestCache { Future onLoad() => _loader(); @override - T onLocalData(String json) => _fromJson(jsonDecode(json)); + T onLocalData(String json) => _fromJson(jsonDecode(json) as Map); } diff --git a/lib/api/webuntis/queries/authenticate/authenticate.dart b/lib/api/webuntis/queries/authenticate/authenticate.dart index 5f49dc2..551d815 100644 --- a/lib/api/webuntis/queries/authenticate/authenticate.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate.dart @@ -2,9 +2,9 @@ import 'dart:async'; import 'dart:convert'; import '../../../../model/account_data.dart'; -import '../../webuntisApi.dart'; -import 'authenticateParams.dart'; -import 'authenticateResponse.dart'; +import '../../webuntis_api.dart'; +import 'authenticate_params.dart'; +import 'authenticate_response.dart'; class Authenticate extends WebuntisApi { AuthenticateParams param; @@ -15,18 +15,19 @@ class Authenticate extends WebuntisApi { Future run() async { awaitingResponse = true; try { - var rawAnswer = await query(this); - AuthenticateResponse response = finalize(AuthenticateResponse.fromJson(jsonDecode(rawAnswer)['result'])); + final rawAnswer = await query(this); + final decoded = jsonDecode(rawAnswer) as Map; + final response = finalize(AuthenticateResponse.fromJson(decoded['result'] as Map)); _lastResponse = response; - if(!awaitedResponse.isCompleted) awaitedResponse.complete(); + if (!awaitedResponse.isCompleted) awaitedResponse.complete(); return response; } catch (e) { // Surface the error to anyone waiting on the current completer, then // install a fresh one so a future attempt can succeed. Without this, // any later call to getSession() would hang forever on a completer // that is already settled with no listeners (or never settles at all). - if(!awaitedResponse.isCompleted) awaitedResponse.completeError(e); - awaitedResponse = Completer(); + if (!awaitedResponse.isCompleted) awaitedResponse.completeError(e); + awaitedResponse = Completer(); rethrow; } finally { awaitingResponse = false; @@ -34,7 +35,7 @@ class Authenticate extends WebuntisApi { } static bool awaitingResponse = false; - static Completer awaitedResponse = Completer(); + static Completer awaitedResponse = Completer(); static AuthenticateResponse? _lastResponse; static Future createSession() async { diff --git a/lib/api/webuntis/queries/authenticate/authenticateParams.dart b/lib/api/webuntis/queries/authenticate/authenticate_params.dart similarity index 85% rename from lib/api/webuntis/queries/authenticate/authenticateParams.dart rename to lib/api/webuntis/queries/authenticate/authenticate_params.dart index bfa65e6..bf3b23e 100644 --- a/lib/api/webuntis/queries/authenticate/authenticateParams.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'authenticateParams.g.dart'; +part 'authenticate_params.g.dart'; @JsonSerializable() class AuthenticateParams extends ApiParams { diff --git a/lib/api/webuntis/queries/authenticate/authenticateParams.g.dart b/lib/api/webuntis/queries/authenticate/authenticate_params.g.dart similarity index 94% rename from lib/api/webuntis/queries/authenticate/authenticateParams.g.dart rename to lib/api/webuntis/queries/authenticate/authenticate_params.g.dart index ba8b65e..9765ad8 100644 --- a/lib/api/webuntis/queries/authenticate/authenticateParams.g.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'authenticateParams.dart'; +part of 'authenticate_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/authenticate/authenticateResponse.dart b/lib/api/webuntis/queries/authenticate/authenticate_response.dart similarity index 86% rename from lib/api/webuntis/queries/authenticate/authenticateResponse.dart rename to lib/api/webuntis/queries/authenticate/authenticate_response.dart index 509b1dc..0ca87db 100644 --- a/lib/api/webuntis/queries/authenticate/authenticateResponse.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'authenticateResponse.g.dart'; +part 'authenticate_response.g.dart'; @JsonSerializable() class AuthenticateResponse extends ApiResponse { diff --git a/lib/api/webuntis/queries/authenticate/authenticateResponse.g.dart b/lib/api/webuntis/queries/authenticate/authenticate_response.g.dart similarity index 96% rename from lib/api/webuntis/queries/authenticate/authenticateResponse.g.dart rename to lib/api/webuntis/queries/authenticate/authenticate_response.g.dart index c3ca62a..e7d7dd0 100644 --- a/lib/api/webuntis/queries/authenticate/authenticateResponse.g.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'authenticateResponse.dart'; +part of 'authenticate_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/getHolidays/getHolidays.dart b/lib/api/webuntis/queries/get_holidays/get_holidays.dart similarity index 82% rename from lib/api/webuntis/queries/getHolidays/getHolidays.dart rename to lib/api/webuntis/queries/get_holidays/get_holidays.dart index 145cb6e..68031ec 100644 --- a/lib/api/webuntis/queries/getHolidays/getHolidays.dart +++ b/lib/api/webuntis/queries/get_holidays/get_holidays.dart @@ -1,15 +1,15 @@ import 'dart:convert'; -import '../../webuntisApi.dart'; -import 'getHolidaysResponse.dart'; +import '../../webuntis_api.dart'; +import 'get_holidays_response.dart'; class GetHolidays extends WebuntisApi { GetHolidays() : super('getHolidays', null); @override Future run() async { - var rawAnswer = await query(this); - return finalize(GetHolidaysResponse.fromJson(jsonDecode(rawAnswer))); + final rawAnswer = await query(this); + return finalize(GetHolidaysResponse.fromJson(jsonDecode(rawAnswer) as Map)); } static GetHolidaysResponseObject? find(GetHolidaysResponse holidaysResponse, {DateTime? time}) { diff --git a/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart b/lib/api/webuntis/queries/get_holidays/get_holidays_cache.dart similarity index 76% rename from lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart rename to lib/api/webuntis/queries/get_holidays/get_holidays_cache.dart index d6a2ff4..a974eb1 100644 --- a/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart +++ b/lib/api/webuntis/queries/get_holidays/get_holidays_cache.dart @@ -1,6 +1,6 @@ -import '../../../requestCache.dart'; -import 'getHolidays.dart'; -import 'getHolidaysResponse.dart'; +import '../../../request_cache.dart'; +import 'get_holidays.dart'; +import 'get_holidays_response.dart'; class GetHolidaysCache extends SimpleCache { GetHolidaysCache({super.onUpdate, super.onError, super.renew}) diff --git a/lib/api/webuntis/queries/getHolidays/getHolidaysResponse.dart b/lib/api/webuntis/queries/get_holidays/get_holidays_response.dart similarity index 91% rename from lib/api/webuntis/queries/getHolidays/getHolidaysResponse.dart rename to lib/api/webuntis/queries/get_holidays/get_holidays_response.dart index f087c4a..8fa2624 100644 --- a/lib/api/webuntis/queries/getHolidays/getHolidaysResponse.dart +++ b/lib/api/webuntis/queries/get_holidays/get_holidays_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getHolidaysResponse.g.dart'; +part 'get_holidays_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetHolidaysResponse extends ApiResponse { diff --git a/lib/api/webuntis/queries/getHolidays/getHolidaysResponse.g.dart b/lib/api/webuntis/queries/get_holidays/get_holidays_response.g.dart similarity index 97% rename from lib/api/webuntis/queries/getHolidays/getHolidaysResponse.g.dart rename to lib/api/webuntis/queries/get_holidays/get_holidays_response.g.dart index 323b3fb..e373642 100644 --- a/lib/api/webuntis/queries/getHolidays/getHolidaysResponse.g.dart +++ b/lib/api/webuntis/queries/get_holidays/get_holidays_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getHolidaysResponse.dart'; +part of 'get_holidays_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/getRooms/getRooms.dart b/lib/api/webuntis/queries/get_rooms/get_rooms.dart similarity index 72% rename from lib/api/webuntis/queries/getRooms/getRooms.dart rename to lib/api/webuntis/queries/get_rooms/get_rooms.dart index 45c53c5..4b7bf86 100644 --- a/lib/api/webuntis/queries/getRooms/getRooms.dart +++ b/lib/api/webuntis/queries/get_rooms/get_rooms.dart @@ -1,18 +1,18 @@ import 'dart:convert'; import 'dart:developer'; -import '../../webuntisApi.dart'; -import 'getRoomsResponse.dart'; +import '../../webuntis_api.dart'; +import 'get_rooms_response.dart'; class GetRooms extends WebuntisApi { GetRooms() : super('getRooms', null); @override Future run() async { - var rawAnswer = await query(this); + final rawAnswer = await query(this); try { - return finalize(GetRoomsResponse.fromJson(jsonDecode(rawAnswer))); - } catch(e, trace) { + return finalize(GetRoomsResponse.fromJson(jsonDecode(rawAnswer) as Map)); + } catch (e, trace) { log(trace.toString()); log('Failed to parse getRoom data with server response: $rawAnswer'); } diff --git a/lib/api/webuntis/queries/getRooms/getRoomsCache.dart b/lib/api/webuntis/queries/get_rooms/get_rooms_cache.dart similarity index 76% rename from lib/api/webuntis/queries/getRooms/getRoomsCache.dart rename to lib/api/webuntis/queries/get_rooms/get_rooms_cache.dart index 4f8e064..a07a449 100644 --- a/lib/api/webuntis/queries/getRooms/getRoomsCache.dart +++ b/lib/api/webuntis/queries/get_rooms/get_rooms_cache.dart @@ -1,6 +1,6 @@ -import '../../../requestCache.dart'; -import 'getRooms.dart'; -import 'getRoomsResponse.dart'; +import '../../../request_cache.dart'; +import 'get_rooms.dart'; +import 'get_rooms_response.dart'; class GetRoomsCache extends SimpleCache { GetRoomsCache({super.onUpdate, super.onError, super.renew}) diff --git a/lib/api/webuntis/queries/getRooms/getRoomsResponse.dart b/lib/api/webuntis/queries/get_rooms/get_rooms_response.dart similarity index 91% rename from lib/api/webuntis/queries/getRooms/getRoomsResponse.dart rename to lib/api/webuntis/queries/get_rooms/get_rooms_response.dart index fe4dc84..614406d 100644 --- a/lib/api/webuntis/queries/getRooms/getRoomsResponse.dart +++ b/lib/api/webuntis/queries/get_rooms/get_rooms_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getRoomsResponse.g.dart'; +part 'get_rooms_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetRoomsResponse extends ApiResponse { diff --git a/lib/api/webuntis/queries/getRooms/getRoomsResponse.g.dart b/lib/api/webuntis/queries/get_rooms/get_rooms_response.g.dart similarity index 97% rename from lib/api/webuntis/queries/getRooms/getRoomsResponse.g.dart rename to lib/api/webuntis/queries/get_rooms/get_rooms_response.g.dart index a88ade3..2bf9998 100644 --- a/lib/api/webuntis/queries/getRooms/getRoomsResponse.g.dart +++ b/lib/api/webuntis/queries/get_rooms/get_rooms_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getRoomsResponse.dart'; +part of 'get_rooms_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/getSubjects/getSubjects.dart b/lib/api/webuntis/queries/get_subjects/get_subjects.dart similarity index 61% rename from lib/api/webuntis/queries/getSubjects/getSubjects.dart rename to lib/api/webuntis/queries/get_subjects/get_subjects.dart index 8505381..75a4f1b 100644 --- a/lib/api/webuntis/queries/getSubjects/getSubjects.dart +++ b/lib/api/webuntis/queries/get_subjects/get_subjects.dart @@ -1,14 +1,14 @@ import 'dart:convert'; -import '../../webuntisApi.dart'; -import 'getSubjectsResponse.dart'; +import '../../webuntis_api.dart'; +import 'get_subjects_response.dart'; class GetSubjects extends WebuntisApi { GetSubjects() : super('getSubjects', null); @override Future run() async { - var rawAnswer = await query(this); - return finalize(GetSubjectsResponse.fromJson(jsonDecode(rawAnswer))); + final rawAnswer = await query(this); + return finalize(GetSubjectsResponse.fromJson(jsonDecode(rawAnswer) as Map)); } } diff --git a/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart b/lib/api/webuntis/queries/get_subjects/get_subjects_cache.dart similarity index 76% rename from lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart rename to lib/api/webuntis/queries/get_subjects/get_subjects_cache.dart index 5eeb8d3..c513054 100644 --- a/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart +++ b/lib/api/webuntis/queries/get_subjects/get_subjects_cache.dart @@ -1,6 +1,6 @@ -import '../../../requestCache.dart'; -import 'getSubjects.dart'; -import 'getSubjectsResponse.dart'; +import '../../../request_cache.dart'; +import 'get_subjects.dart'; +import 'get_subjects_response.dart'; class GetSubjectsCache extends SimpleCache { GetSubjectsCache({super.onUpdate, super.onError, super.renew}) diff --git a/lib/api/webuntis/queries/getSubjects/getSubjectsResponse.dart b/lib/api/webuntis/queries/get_subjects/get_subjects_response.dart similarity index 92% rename from lib/api/webuntis/queries/getSubjects/getSubjectsResponse.dart rename to lib/api/webuntis/queries/get_subjects/get_subjects_response.dart index cfd2cf1..255b5ad 100644 --- a/lib/api/webuntis/queries/getSubjects/getSubjectsResponse.dart +++ b/lib/api/webuntis/queries/get_subjects/get_subjects_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getSubjectsResponse.g.dart'; +part 'get_subjects_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetSubjectsResponse extends ApiResponse { diff --git a/lib/api/webuntis/queries/getSubjects/getSubjectsResponse.g.dart b/lib/api/webuntis/queries/get_subjects/get_subjects_response.g.dart similarity index 97% rename from lib/api/webuntis/queries/getSubjects/getSubjectsResponse.g.dart rename to lib/api/webuntis/queries/get_subjects/get_subjects_response.g.dart index 35bbb8b..9ce84d5 100644 --- a/lib/api/webuntis/queries/getSubjects/getSubjectsResponse.g.dart +++ b/lib/api/webuntis/queries/get_subjects/get_subjects_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getSubjectsResponse.dart'; +part of 'get_subjects_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnits.dart b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart similarity index 76% rename from lib/api/webuntis/queries/getTimegridUnits/getTimegridUnits.dart rename to lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart index 0e9c38f..9f910e1 100644 --- a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnits.dart +++ b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart @@ -1,17 +1,17 @@ import 'dart:convert'; import 'dart:developer'; -import '../../webuntisApi.dart'; -import 'getTimegridUnitsResponse.dart'; +import '../../webuntis_api.dart'; +import 'get_timegrid_units_response.dart'; class GetTimegridUnits extends WebuntisApi { GetTimegridUnits() : super('getTimegridUnits', null); @override Future run() async { - var rawAnswer = await query(this); + final rawAnswer = await query(this); try { - return finalize(GetTimegridUnitsResponse.fromJson(jsonDecode(rawAnswer))); + return finalize(GetTimegridUnitsResponse.fromJson(jsonDecode(rawAnswer) as Map)); } catch (e, trace) { log(trace.toString()); log('Failed to parse getTimegridUnits data with server response: $rawAnswer'); diff --git a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart similarity index 74% rename from lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart rename to lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart index 200aa9c..811ed86 100644 --- a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart +++ b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart @@ -1,6 +1,6 @@ -import '../../../requestCache.dart'; -import 'getTimegridUnits.dart'; -import 'getTimegridUnitsResponse.dart'; +import '../../../request_cache.dart'; +import 'get_timegrid_units.dart'; +import 'get_timegrid_units_response.dart'; class GetTimegridUnitsCache extends SimpleCache { GetTimegridUnitsCache({super.onUpdate, super.renew}) diff --git a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart similarity index 93% rename from lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart rename to lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart index a730567..5b458aa 100644 --- a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart +++ b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getTimegridUnitsResponse.g.dart'; +part 'get_timegrid_units_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetTimegridUnitsResponse extends ApiResponse { diff --git a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.g.dart b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.g.dart similarity index 97% rename from lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.g.dart rename to lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.g.dart index b6fc909..250b0fd 100644 --- a/lib/api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.g.dart +++ b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getTimegridUnitsResponse.dart'; +part of 'get_timegrid_units_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/getTimetable/getTimetable.dart b/lib/api/webuntis/queries/get_timetable/get_timetable.dart similarity index 60% rename from lib/api/webuntis/queries/getTimetable/getTimetable.dart rename to lib/api/webuntis/queries/get_timetable/get_timetable.dart index e9da26d..d451d3c 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetable.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable.dart @@ -1,8 +1,8 @@ import 'dart:convert'; -import '../../webuntisApi.dart'; -import 'getTimetableParams.dart'; -import 'getTimetableResponse.dart'; +import '../../webuntis_api.dart'; +import 'get_timetable_params.dart'; +import 'get_timetable_response.dart'; class GetTimetable extends WebuntisApi { GetTimetableParams params; @@ -11,8 +11,8 @@ class GetTimetable extends WebuntisApi { @override Future run() async { - var rawAnswer = await query(this); - return finalize(GetTimetableResponse.fromJson(jsonDecode(rawAnswer))); + final rawAnswer = await query(this); + return finalize(GetTimetableResponse.fromJson(jsonDecode(rawAnswer) as Map)); } } diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_cache.dart similarity index 90% rename from lib/api/webuntis/queries/getTimetable/getTimetableCache.dart rename to lib/api/webuntis/queries/get_timetable/get_timetable_cache.dart index 8a8cd7e..56a73c9 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable_cache.dart @@ -1,8 +1,8 @@ -import '../../../requestCache.dart'; +import '../../../request_cache.dart'; import '../authenticate/authenticate.dart'; -import 'getTimetable.dart'; -import 'getTimetableParams.dart'; -import 'getTimetableResponse.dart'; +import 'get_timetable.dart'; +import 'get_timetable_params.dart'; +import 'get_timetable_response.dart'; class GetTimetableCache extends SimpleCache { GetTimetableCache({ diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableParams.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_params.dart similarity index 97% rename from lib/api/webuntis/queries/getTimetable/getTimetableParams.dart rename to lib/api/webuntis/queries/get_timetable/get_timetable_params.dart index 48ba379..9286863 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetableParams.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'getTimetableParams.g.dart'; +part 'get_timetable_params.g.dart'; @JsonSerializable(explicitToJson: true) class GetTimetableParams extends ApiParams { diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableParams.g.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_params.g.dart similarity index 99% rename from lib/api/webuntis/queries/getTimetable/getTimetableParams.g.dart rename to lib/api/webuntis/queries/get_timetable/get_timetable_params.g.dart index 92e2a1d..2d7ff35 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetableParams.g.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getTimetableParams.dart'; +part of 'get_timetable_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableResponse.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_response.dart similarity index 98% rename from lib/api/webuntis/queries/getTimetable/getTimetableResponse.dart rename to lib/api/webuntis/queries/get_timetable/get_timetable_response.dart index fc6663c..05e1ea1 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetableResponse.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getTimetableResponse.g.dart'; +part 'get_timetable_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetTimetableResponse extends ApiResponse { diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableResponse.g.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_response.g.dart similarity index 99% rename from lib/api/webuntis/queries/getTimetable/getTimetableResponse.g.dart rename to lib/api/webuntis/queries/get_timetable/get_timetable_response.g.dart index effce44..9a3b20b 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetableResponse.g.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getTimetableResponse.dart'; +part of 'get_timetable_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/webuntisApi.dart b/lib/api/webuntis/webuntis_api.dart similarity index 68% rename from lib/api/webuntis/webuntisApi.dart rename to lib/api/webuntis/webuntis_api.dart index 846c1e6..690bf94 100644 --- a/lib/api/webuntis/webuntisApi.dart +++ b/lib/api/webuntis/webuntis_api.dart @@ -5,13 +5,13 @@ import 'dart:io'; import 'package:http/http.dart' as http; import '../../model/endpoint_data.dart'; -import '../apiParams.dart'; -import '../apiRequest.dart'; -import '../apiResponse.dart'; +import '../api_params.dart'; +import '../api_request.dart'; +import '../api_response.dart'; import '../errors/network_exception.dart'; import '../errors/parse_exception.dart'; import 'queries/authenticate/authenticate.dart'; -import 'webuntisError.dart'; +import 'webuntis_error.dart'; abstract class WebuntisApi extends ApiRequest { Uri endpoint = Uri.parse('https://${EndpointData().webuntis().full()}/WebUntis/jsonrpc.do?school=marianum-fulda'); @@ -25,34 +25,36 @@ abstract class WebuntisApi extends ApiRequest { Future query(WebuntisApi untis, {bool retry = false}) async { - var query = '{"id":"ID","method":"$method","params":${untis._body()},"jsonrpc":"2.0"}'; + final body = '{"id":"ID","method":"$method","params":${untis._body()},"jsonrpc":"2.0"}'; var sessionId = '0'; - if(authenticatedResponse) { + if (authenticatedResponse) { sessionId = (await Authenticate.getSession()).sessionId; } - var data = await post(query, {'Cookie': 'JSESSIONID=$sessionId'}); + final data = await post(body, {'Cookie': 'JSESSIONID=$sessionId'}); response = data; - final dynamic jsonData; + final Map jsonData; try { - jsonData = jsonDecode(data.body); + jsonData = jsonDecode(data.body) as Map; } on FormatException catch (e) { throw ParseException(technicalDetails: 'WebUntis JSON decode: ${e.message}'); } - if(jsonData['error'] != null) { - if(jsonData['error']['code'] == -8520) { - if(retry) throw WebuntisError('Authentication was tried (probably session timeout), but was not successful!', -8520); + final error = jsonData['error'] as Map?; + if (error != null) { + final code = error['code'] as int; + if (code == -8520) { + if (retry) throw WebuntisError('Authentication was tried (probably session timeout), but was not successful!', -8520); await Authenticate.createSession(); - return await this.query(untis, retry: true); + return query(untis, retry: true); } else { - throw WebuntisError(jsonData['error']['message'], jsonData['error']['code']); + throw WebuntisError(error['message'] as String, code); } } return data.body; } - dynamic finalize(dynamic response) { + T finalize(T response) { response.rawResponse = this.response!; return response; } diff --git a/lib/api/webuntis/webuntisError.dart b/lib/api/webuntis/webuntis_error.dart similarity index 100% rename from lib/api/webuntis/webuntisError.dart rename to lib/api/webuntis/webuntis_error.dart diff --git a/lib/app.dart b/lib/app.dart index e483fb6..5ac8709 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,25 +1,25 @@ import 'dart:async'; import 'dart:developer'; -import 'package:easy_debounce/easy_throttle.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; -import 'api/mhsl/server/userIndex/update/updateUserindex.dart'; +import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart'; +import 'api/mhsl/server/user_index/update/update_userindex.dart'; import 'main.dart'; -import 'widget/breaker/breaker.dart'; import 'model/data_cleaner.dart'; import 'notification/notification_controller.dart'; import 'notification/notification_tasks.dart'; import 'notification/notify_updater.dart'; import 'state/app/modules/app_modules.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; -import 'state/app/modules/chatList/bloc/chat_list_bloc.dart'; +import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; +import 'utils/debouncer.dart'; import 'view/pages/overhang.dart'; +import 'widget/breaker/breaker.dart'; class App extends StatefulWidget { const App({super.key}); @@ -36,7 +36,7 @@ class _AppState extends State with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { log('AppLifecycle: $state'); if (state == AppLifecycleState.resumed) { - EasyThrottle.throttle('appLifecycleState', const Duration(seconds: 10), () { + Debouncer.throttle('appLifecycleState', const Duration(seconds: 10), () { if (!mounted) return; log('Refreshing due to LifecycleChange'); NotificationTasks.updateProviders(context); diff --git a/lib/main.dart b/lib/main.dart index 411a05c..a8f88c7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,16 +16,15 @@ import 'package:loader_overlay/loader_overlay.dart'; import 'package:path_provider/path_provider.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; +import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart'; import 'app.dart'; import 'firebase_options.dart'; import 'model/account_data.dart'; -import 'widget/breaker/breaker.dart'; import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_state.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; import 'state/app/modules/chat/bloc/chat_bloc.dart'; -import 'state/app/modules/chatList/bloc/chat_list_bloc.dart'; +import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; import 'storage/settings.dart'; @@ -33,6 +32,7 @@ import 'theming/dark_app_theme.dart'; import 'theming/light_app_theme.dart'; import 'view/login/login.dart'; import 'widget/app_progress_indicator.dart'; +import 'widget/breaker/breaker.dart'; Future main() async { log('MarianumMobile started'); diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart index 66db8c8..712301a 100644 --- a/lib/model/account_data.dart +++ b/lib/model/account_data.dart @@ -15,9 +15,7 @@ class AccountData { static const _usernameField = 'username'; static const _passwordField = 'password'; - static const FlutterSecureStorage _secureStorage = FlutterSecureStorage( - aOptions: AndroidOptions(encryptedSharedPreferences: true), - ); + static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(); static final AccountData _instance = AccountData._construct(); Completer _populated = Completer(); diff --git a/lib/model/data_cleaner.dart b/lib/model/data_cleaner.dart index f24fb04..4094023 100644 --- a/lib/model/data_cleaner.dart +++ b/lib/model/data_cleaner.dart @@ -1,13 +1,13 @@ import 'package:localstore/localstore.dart'; -import '../api/requestCache.dart'; +import '../api/request_cache.dart'; class DataCleaner { static Future cleanOldCache() async { - var cacheData = await Localstore.instance.collection(RequestCache.collection).get(); + final cacheData = await Localstore.instance.collection(RequestCache.collection).get(); cacheData?.forEach((key, value) async { - var lastUpdate = DateTime.fromMillisecondsSinceEpoch(value['lastupdate']); - if(DateTime.now().subtract(const Duration(days: 200)).isAfter(lastUpdate)) { + final lastUpdate = DateTime.fromMillisecondsSinceEpoch(value['lastupdate'] as int); + if (DateTime.now().subtract(const Duration(days: 200)).isAfter(lastUpdate)) { await Localstore.instance.collection(RequestCache.collection).doc(key.split('/').last).delete(); } }); diff --git a/lib/notification/notification_controller.dart b/lib/notification/notification_controller.dart index 0a1631f..a9de28b 100644 --- a/lib/notification/notification_controller.dart +++ b/lib/notification/notification_controller.dart @@ -6,36 +6,10 @@ import '../widget/debug/json_viewer.dart'; import 'notification_tasks.dart'; class NotificationController { + // Notification display is handled by the Firebase SDK using server-generated payloads. @pragma('vm:entry-point') static Future onBackgroundMessageHandler(RemoteMessage message) async { NotificationTasks.updateBadgeCount(message); - return; // Displaying the notification is curently done via the Firebase SDK itself. The Message is server-generated. - - // - // await Firebase.initializeApp(); - // AccountData().waitForPopulation().then((value) { - // log("User account status: $value"); - // if(value) { - // GetRoom( - // GetRoomParams( - // includeStatus: false, - // ), - // ).run().then((value) { - // var messageCount = value.data.map((e) => e.unreadMessages).reduce((a, b) => a + b); - // var chatCount = value.data.where((e) => e.unreadMessages > 0).length; - // var people = value.data.where((e) => e.unreadMessages > 0).map((e) => e.displayName.split(" ")[0]); - // - // final NotificationService service = NotificationService(); - // service.initializeNotifications().then((value) { - // service.showNotification( - // title: "Du hast $messageCount ungelesene Nachrichten!", - // body: "In $chatCount Chats, von ${people.join(", ")}", - // badgeCount: messageCount, - // ); - // }); - // }); - // } - // }); } static Future onForegroundMessageHandler(RemoteMessage message, BuildContext context) async { diff --git a/lib/notification/notification_tasks.dart b/lib/notification/notification_tasks.dart index e5e43ff..42c310e 100644 --- a/lib/notification/notification_tasks.dart +++ b/lib/notification/notification_tasks.dart @@ -5,11 +5,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../routing/app_routes.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart'; -import '../state/app/modules/chatList/bloc/chat_list_bloc.dart'; +import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; class NotificationTasks { static void updateBadgeCount(RemoteMessage notification) { - FlutterAppBadge.count(int.parse(notification.data['unreadCount'] ?? '0')); + FlutterAppBadge.count(int.parse((notification.data['unreadCount'] as String?) ?? '0')); } static void updateProviders(BuildContext context) { diff --git a/lib/notification/notify_updater.dart b/lib/notification/notify_updater.dart index 584b9f7..dc3ae15 100644 --- a/lib/notification/notify_updater.dart +++ b/lib/notification/notify_updater.dart @@ -1,8 +1,10 @@ +import 'dart:async'; + import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; -import '../api/mhsl/notify/register/notifyRegister.dart'; -import '../api/mhsl/notify/register/notifyRegisterParams.dart'; +import '../api/mhsl/notify/register/notify_register.dart'; +import '../api/mhsl/notify/register/notify_register_params.dart'; import '../model/account_data.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart'; import '../widget/confirm_dialog.dart'; @@ -17,9 +19,9 @@ class NotifyUpdater { 'Für mehr Informationen drücke lange auf die Einstellungsoption!', confirmButton: 'Aktivieren', onConfirm: () { - FirebaseMessaging.instance.requestPermission(provisional: false); + unawaited(FirebaseMessaging.instance.requestPermission(provisional: false)); settings.val(write: true).notificationSettings.enabled = true; - NotifyUpdater.registerToServer(); + unawaited(NotifyUpdater.registerToServer()); }, ); @@ -29,12 +31,12 @@ class NotifyUpdater { throw Exception('Failed to register push notification because there is no FBC token!'); } - NotifyRegister( + unawaited(NotifyRegister( NotifyRegisterParams( username: AccountData().getUsername(), password: AccountData().getPassword(), fcmToken: fcmToken, ), - ).run(); + ).run()); } } diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 94b85d9..082d5ac 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -3,23 +3,23 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import '../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../api/marianumcloud/talk/room/get_room_response.dart'; import '../main.dart'; import '../model/account_data.dart'; import '../state/app/modules/app_modules.dart'; -import '../state/app/modules/chatList/bloc/chat_list_bloc.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart'; -import '../state/app/modules/marianumMessage/bloc/marianum_message_state.dart'; +import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import '../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; import '../view/pages/files/files.dart'; import '../view/pages/marianum_message/marianum_message_view.dart'; import '../view/pages/more/feedback/feedback_dialog.dart'; import '../view/pages/more/roomplan/roomplan.dart'; import '../view/pages/more/share/qr_share_view.dart'; +import '../view/pages/settings/settings.dart'; import '../view/pages/talk/chat_view.dart'; import '../view/pages/talk/details/message_reactions.dart'; import '../view/pages/talk/talk_navigator.dart'; import '../view/pages/timetable/custom_events/custom_events_view.dart'; -import '../view/pages/settings/settings.dart'; import '../widget/debug/cache_view.dart'; import '../widget/file_viewer.dart'; import '../widget/user_avatar.dart'; diff --git a/lib/state/app/basis/dataloader/holiday_data_loader.dart b/lib/state/app/basis/dataloader/holiday_data_loader.dart index 19c345f..e384f00 100644 --- a/lib/state/app/basis/dataloader/holiday_data_loader.dart +++ b/lib/state/app/basis/dataloader/holiday_data_loader.dart @@ -1,6 +1,6 @@ import 'package:dio/dio.dart'; -import '../../infrastructure/dataLoader/data_loader.dart'; +import '../../infrastructure/data_loader/data_loader.dart'; abstract class HolidayDataLoader extends DataLoader { HolidayDataLoader() : super(Dio(BaseOptions( diff --git a/lib/state/app/basis/dataloader/mhsl_data_loader.dart b/lib/state/app/basis/dataloader/mhsl_data_loader.dart index 6b4baab..fa29cf4 100644 --- a/lib/state/app/basis/dataloader/mhsl_data_loader.dart +++ b/lib/state/app/basis/dataloader/mhsl_data_loader.dart @@ -1,6 +1,6 @@ import 'package:dio/dio.dart'; -import '../../infrastructure/dataLoader/data_loader.dart'; +import '../../infrastructure/data_loader/data_loader.dart'; abstract class MhslDataLoader extends DataLoader { MhslDataLoader() : super(Dio(BaseOptions( diff --git a/lib/state/app/infrastructure/dataLoader/data_loader.dart b/lib/state/app/infrastructure/data_loader/data_loader.dart similarity index 81% rename from lib/state/app/infrastructure/dataLoader/data_loader.dart rename to lib/state/app/infrastructure/data_loader/data_loader.dart index 64c1aa7..ceec932 100644 --- a/lib/state/app/infrastructure/dataLoader/data_loader.dart +++ b/lib/state/app/infrastructure/data_loader/data_loader.dart @@ -6,9 +6,9 @@ import 'package:dio/dio.dart'; abstract class DataLoader { final Dio dio; DataLoader(this.dio) { - dio.options.connectTimeout = const Duration(seconds: 10).inMilliseconds; - dio.options.sendTimeout = const Duration(seconds: 30).inMilliseconds; - dio.options.receiveTimeout = const Duration(seconds: 30).inMilliseconds; + dio.options.connectTimeout = const Duration(seconds: 10); + dio.options.sendTimeout = const Duration(seconds: 30); + dio.options.receiveTimeout = const Duration(seconds: 30); } Future run() async { @@ -26,7 +26,7 @@ abstract class DataLoader { )); } catch(trace, e) { log(trace.toString()); - throw(e); + throw e; } } diff --git a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_bloc.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart similarity index 91% rename from lib/state/app/infrastructure/loadableState/bloc/loadable_state_bloc.dart rename to lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart index ef0e167..625e1dd 100644 --- a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_bloc.dart +++ b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart @@ -1,8 +1,8 @@ import 'dart:async'; -import 'package:bloc/bloc.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:jiffy/jiffy.dart'; import 'loadable_state_event.dart'; @@ -21,7 +21,7 @@ class LoadableStateBloc extends Bloc { } }); - emitConnectivity(List result) => add(ConnectivityChanged(LoadableStateState(connections: result))); + void emitConnectivity(List result) => add(ConnectivityChanged(LoadableStateState(connections: result))); Connectivity().checkConnectivity().then(emitConnectivity); _updateStream = Connectivity().onConnectivityChanged.listen(emitConnectivity); diff --git a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_event.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/bloc/loadable_state_event.dart rename to lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart diff --git a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_state.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_state.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/bloc/loadable_state_state.dart rename to lib/state/app/infrastructure/loadable_state/bloc/loadable_state_state.dart diff --git a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_state.freezed.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_state.freezed.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/bloc/loadable_state_state.freezed.dart rename to lib/state/app/infrastructure/loadable_state/bloc/loadable_state_state.freezed.dart diff --git a/lib/state/app/infrastructure/loadableState/loadable_state.dart b/lib/state/app/infrastructure/loadable_state/loadable_state.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/loadable_state.dart rename to lib/state/app/infrastructure/loadable_state/loadable_state.dart diff --git a/lib/state/app/infrastructure/loadableState/loadable_state.freezed.dart b/lib/state/app/infrastructure/loadable_state/loadable_state.freezed.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/loadable_state.freezed.dart rename to lib/state/app/infrastructure/loadable_state/loadable_state.freezed.dart diff --git a/lib/state/app/infrastructure/loadableState/loading_error.dart b/lib/state/app/infrastructure/loadable_state/loading_error.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/loading_error.dart rename to lib/state/app/infrastructure/loadable_state/loading_error.dart diff --git a/lib/state/app/infrastructure/loadableState/loading_error.freezed.dart b/lib/state/app/infrastructure/loadable_state/loading_error.freezed.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/loading_error.freezed.dart rename to lib/state/app/infrastructure/loadable_state/loading_error.freezed.dart diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_background_loading.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_background_loading.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/view/loadable_state_background_loading.dart rename to lib/state/app/infrastructure/loadable_state/view/loadable_state_background_loading.dart diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart similarity index 95% rename from lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart rename to lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart index cce2287..8867571 100644 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart @@ -1,10 +1,9 @@ -import 'package:bloc/bloc.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../../widget/conditional_wrapper.dart'; -import '../../utilityWidgets/bloc_module.dart'; -import '../../utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../utility_widgets/bloc_module.dart'; +import '../../utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../bloc/loadable_state_bloc.dart'; import '../bloc/loadable_state_state.dart'; import '../loadable_state.dart'; diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_error_bar.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/view/loadable_state_error_bar.dart rename to lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_error_screen.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart similarity index 97% rename from lib/state/app/infrastructure/loadableState/view/loadable_state_error_screen.dart rename to lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart index 4d118bc..e56eb29 100644 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_error_screen.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../../widget/info_dialog.dart'; import '../bloc/loadable_state_bloc.dart'; diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_primary_loading.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/view/loadable_state_primary_loading.dart rename to lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart diff --git a/lib/state/app/infrastructure/utilityWidgets/bloc_module.dart b/lib/state/app/infrastructure/utility_widgets/bloc_module.dart similarity index 100% rename from lib/state/app/infrastructure/utilityWidgets/bloc_module.dart rename to lib/state/app/infrastructure/utility_widgets/bloc_module.dart diff --git a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart similarity index 95% rename from lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart rename to lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart index b6807b5..f241431 100644 --- a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart @@ -3,10 +3,10 @@ import 'dart:developer'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../api/errors/error_mapper.dart'; -import '../../loadableState/loading_error.dart'; +import '../../loadable_state/loadable_state.dart'; +import '../../loadable_state/loading_error.dart'; import '../../repository/repository.dart'; import 'loadable_hydrated_bloc_event.dart'; -import '../../loadableState/loadable_state.dart'; import 'loadable_save_context.dart'; abstract class LoadableHydratedBloc< @@ -90,7 +90,7 @@ abstract class LoadableHydratedBloc< } @override - fromJson(Map json) { + LoadableState fromJson(Map json) { var rawData = LoadableSaveContext.unwrap(json); return LoadableState( isLoading: true, diff --git a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart similarity index 91% rename from lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart rename to lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart index 55b8e6a..1485c60 100644 --- a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart @@ -1,4 +1,4 @@ -import '../../loadableState/loading_error.dart'; +import '../../loadable_state/loading_error.dart'; class LoadableHydratedBlocEvent {} class Emit extends LoadableHydratedBlocEvent { diff --git a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.dart similarity index 93% rename from lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.dart rename to lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.dart index 095f2b6..8d7dc2d 100644 --- a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.dart @@ -19,5 +19,5 @@ abstract class LoadableSaveContext with _$LoadableSaveContext { {dataKey: data, metaKey: LoadableSaveContext(timestamp: lastFetch).toJson()}; static ({Map data, LoadableSaveContext meta}) unwrap(Map data) => - (data: data[dataKey] as Map, meta: LoadableSaveContext.fromJson(data[metaKey])); + (data: data[dataKey] as Map, meta: LoadableSaveContext.fromJson(data[metaKey] as Map)); } diff --git a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.freezed.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.freezed.dart similarity index 100% rename from lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.freezed.dart rename to lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.freezed.dart diff --git a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.g.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.g.dart similarity index 100% rename from lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.g.dart rename to lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.g.dart diff --git a/lib/state/app/modules/app_modules.dart b/lib/state/app/modules/app_modules.dart index b93c698..1d960f0 100644 --- a/lib/state/app/modules/app_modules.dart +++ b/lib/state/app/modules/app_modules.dart @@ -1,10 +1,9 @@ -import 'package:flutter/material.dart'; -import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - import 'package:badges/badges.dart' as badges; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import '../../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; +import '../../../api/mhsl/breaker/get_breakers/get_breakers_response.dart'; import '../../../routing/app_routes.dart'; import '../../../view/pages/files/files.dart'; import '../../../view/pages/grade_averages/grade_averages_view.dart'; @@ -16,9 +15,9 @@ import '../../../view/pages/talk/chat_list.dart'; import '../../../view/pages/timetable/timetable.dart'; import '../../../widget/breaker/breaker.dart'; import '../../../widget/centered_leading.dart'; -import '../infrastructure/loadableState/loadable_state.dart'; -import 'chatList/bloc/chat_list_bloc.dart'; -import 'chatList/bloc/chat_list_state.dart'; +import '../infrastructure/loadable_state/loadable_state.dart'; +import 'chat_list/bloc/chat_list_bloc.dart'; +import 'chat_list/bloc/chat_list_state.dart'; import 'settings/bloc/settings_cubit.dart'; class AppModule { @@ -30,8 +29,8 @@ class AppModule { AppModule(this.module, {required this.name, required this.icon, this.breakerArea = BreakerArea.global, required this.create}); - static Map modules(BuildContext context, { showFiltered = false }) { - var settings = context.read(); + static Map modules(BuildContext context, {bool showFiltered = false}) { + final settings = context.read(); var available = { Modules.timetable: AppModule( Modules.timetable, @@ -109,7 +108,7 @@ class AppModule { ), }; - if(!showFiltered) available.removeWhere((key, value) => settings.val().modulesSettings.hiddenModules.contains(key)); + if (!showFiltered) available.removeWhere((key, value) => settings.val().modulesSettings.hiddenModules.contains(key)); return { for (var element in settings.val().modulesSettings.moduleOrder.where((element) => available.containsKey(element))) element : available[element]! }; } diff --git a/lib/state/app/modules/breaker/bloc/breaker_bloc.dart b/lib/state/app/modules/breaker/bloc/breaker_bloc.dart index 0381513..86bb6fc 100644 --- a/lib/state/app/modules/breaker/bloc/breaker_bloc.dart +++ b/lib/state/app/modules/breaker/bloc/breaker_bloc.dart @@ -1,9 +1,9 @@ import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import '../../../../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../../../api/mhsl/breaker/get_breakers/get_breakers_response.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 '../repository/breaker_repository.dart'; import 'breaker_event.dart'; import 'breaker_state.dart'; diff --git a/lib/state/app/modules/breaker/bloc/breaker_event.dart b/lib/state/app/modules/breaker/bloc/breaker_event.dart index 5c9ed7e..e5b6030 100644 --- a/lib/state/app/modules/breaker/bloc/breaker_event.dart +++ b/lib/state/app/modules/breaker/bloc/breaker_event.dart @@ -1,4 +1,4 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'breaker_state.dart'; sealed class BreakerEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/breaker/bloc/breaker_state.dart b/lib/state/app/modules/breaker/bloc/breaker_state.dart index 140cc45..60c1685 100644 --- a/lib/state/app/modules/breaker/bloc/breaker_state.dart +++ b/lib/state/app/modules/breaker/bloc/breaker_state.dart @@ -1,6 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import '../../../../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; +import '../../../../../api/mhsl/breaker/get_breakers/get_breakers_response.dart'; part 'breaker_state.freezed.dart'; part 'breaker_state.g.dart'; diff --git a/lib/state/app/modules/breaker/dataProvider/breaker_data_provider.dart b/lib/state/app/modules/breaker/data_provider/breaker_data_provider.dart similarity index 64% rename from lib/state/app/modules/breaker/dataProvider/breaker_data_provider.dart rename to lib/state/app/modules/breaker/data_provider/breaker_data_provider.dart index 5623e71..d07fa83 100644 --- a/lib/state/app/modules/breaker/dataProvider/breaker_data_provider.dart +++ b/lib/state/app/modules/breaker/data_provider/breaker_data_provider.dart @@ -1,7 +1,7 @@ import 'dart:async'; -import '../../../../../api/mhsl/breaker/getBreakers/getBreakersCache.dart'; -import '../../../../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; +import '../../../../../api/mhsl/breaker/get_breakers/get_breakers_cache.dart'; +import '../../../../../api/mhsl/breaker/get_breakers/get_breakers_response.dart'; class BreakerDataProvider { Future getBreakers() { diff --git a/lib/state/app/modules/breaker/repository/breaker_repository.dart b/lib/state/app/modules/breaker/repository/breaker_repository.dart index 42bb070..7bc37ac 100644 --- a/lib/state/app/modules/breaker/repository/breaker_repository.dart +++ b/lib/state/app/modules/breaker/repository/breaker_repository.dart @@ -1,6 +1,6 @@ import '../../../infrastructure/repository/repository.dart'; import '../bloc/breaker_state.dart'; -import '../dataProvider/breaker_data_provider.dart'; +import '../data_provider/breaker_data_provider.dart'; class BreakerRepository extends Repository { final BreakerDataProvider _provider; diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index 5dfb29a..ee1823f 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -1,8 +1,8 @@ import '../../../../../api/errors/error_mapper.dart'; -import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; -import '../../../infrastructure/loadableState/loading_error.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../infrastructure/loadable_state/loading_error.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 '../repository/chat_repository.dart'; import 'chat_event.dart'; import 'chat_state.dart'; diff --git a/lib/state/app/modules/chat/bloc/chat_event.dart b/lib/state/app/modules/chat/bloc/chat_event.dart index 460817d..015577f 100644 --- a/lib/state/app/modules/chat/bloc/chat_event.dart +++ b/lib/state/app/modules/chat/bloc/chat_event.dart @@ -1,4 +1,4 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'chat_state.dart'; sealed class ChatEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/chat/bloc/chat_state.dart b/lib/state/app/modules/chat/bloc/chat_state.dart index 221b84d..5145b5b 100644 --- a/lib/state/app/modules/chat/bloc/chat_state.dart +++ b/lib/state/app/modules/chat/bloc/chat_state.dart @@ -1,6 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; +import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; part 'chat_state.freezed.dart'; part 'chat_state.g.dart'; diff --git a/lib/state/app/modules/chat/dataProvider/chat_data_provider.dart b/lib/state/app/modules/chat/data_provider/chat_data_provider.dart similarity index 80% rename from lib/state/app/modules/chat/dataProvider/chat_data_provider.dart rename to lib/state/app/modules/chat/data_provider/chat_data_provider.dart index 25bdccc..8271b61 100644 --- a/lib/state/app/modules/chat/dataProvider/chat_data_provider.dart +++ b/lib/state/app/modules/chat/data_provider/chat_data_provider.dart @@ -1,5 +1,5 @@ -import '../../../../../api/marianumcloud/talk/chat/getChatCache.dart'; -import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; +import '../../../../../api/marianumcloud/talk/chat/get_chat_cache.dart'; +import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; class ChatDataProvider { Future getChat({ diff --git a/lib/state/app/modules/chat/repository/chat_repository.dart b/lib/state/app/modules/chat/repository/chat_repository.dart index 54e4356..38c3833 100644 --- a/lib/state/app/modules/chat/repository/chat_repository.dart +++ b/lib/state/app/modules/chat/repository/chat_repository.dart @@ -1,6 +1,6 @@ import '../../../infrastructure/repository/repository.dart'; import '../bloc/chat_state.dart'; -import '../dataProvider/chat_data_provider.dart'; +import '../data_provider/chat_data_provider.dart'; class ChatRepository extends Repository { final ChatDataProvider _provider; diff --git a/lib/state/app/modules/chatList/bloc/chat_list_bloc.dart b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart similarity index 77% rename from lib/state/app/modules/chatList/bloc/chat_list_bloc.dart rename to lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart index dd69801..b1687c5 100644 --- a/lib/state/app/modules/chatList/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart @@ -1,9 +1,12 @@ +import 'dart:developer'; + import 'package:flutter_app_badge/flutter_app_badge.dart'; import '../../../../../api/errors/error_mapper.dart'; -import '../../../infrastructure/loadableState/loading_error.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../infrastructure/loadable_state/loading_error.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 '../repository/chat_list_repository.dart'; import 'chat_list_event.dart'; import 'chat_list_state.dart'; @@ -72,10 +75,12 @@ class ChatListBloc extends LoadableHydratedBloc e.unreadMessages).fold(0, (a, b) => a + b as int); + final unread = rooms.data.fold(0, (a, room) => a + room.unreadMessages); FlutterAppBadge.count(unread); - } catch (_) {} + } on Object catch (e) { + log('Failed to update app badge: $e'); + } } } diff --git a/lib/state/app/modules/chatList/bloc/chat_list_event.dart b/lib/state/app/modules/chat_list/bloc/chat_list_event.dart similarity index 50% rename from lib/state/app/modules/chatList/bloc/chat_list_event.dart rename to lib/state/app/modules/chat_list/bloc/chat_list_event.dart index 614898d..302bb02 100644 --- a/lib/state/app/modules/chatList/bloc/chat_list_event.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_event.dart @@ -1,4 +1,4 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'chat_list_state.dart'; sealed class ChatListEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/chatList/bloc/chat_list_state.dart b/lib/state/app/modules/chat_list/bloc/chat_list_state.dart similarity index 83% rename from lib/state/app/modules/chatList/bloc/chat_list_state.dart rename to lib/state/app/modules/chat_list/bloc/chat_list_state.dart index 12ad303..25210cf 100644 --- a/lib/state/app/modules/chatList/bloc/chat_list_state.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_state.dart @@ -1,6 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import '../../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; part 'chat_list_state.freezed.dart'; part 'chat_list_state.g.dart'; diff --git a/lib/state/app/modules/chatList/bloc/chat_list_state.freezed.dart b/lib/state/app/modules/chat_list/bloc/chat_list_state.freezed.dart similarity index 100% rename from lib/state/app/modules/chatList/bloc/chat_list_state.freezed.dart rename to lib/state/app/modules/chat_list/bloc/chat_list_state.freezed.dart diff --git a/lib/state/app/modules/chatList/bloc/chat_list_state.g.dart b/lib/state/app/modules/chat_list/bloc/chat_list_state.g.dart similarity index 100% rename from lib/state/app/modules/chatList/bloc/chat_list_state.g.dart rename to lib/state/app/modules/chat_list/bloc/chat_list_state.g.dart diff --git a/lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart b/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart similarity index 67% rename from lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart rename to lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart index 3bf5c62..6549df4 100644 --- a/lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart +++ b/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart @@ -1,7 +1,7 @@ -import '../../../../../api/marianumcloud/talk/room/getRoomCache.dart'; -import '../../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; -import '../../../../../api/marianumcloud/talk/createRoom/createRoom.dart'; -import '../../../../../api/marianumcloud/talk/createRoom/createRoomParams.dart'; +import '../../../../../api/marianumcloud/talk/create_room/create_room.dart'; +import '../../../../../api/marianumcloud/talk/create_room/create_room_params.dart'; +import '../../../../../api/marianumcloud/talk/room/get_room_cache.dart'; +import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; class ChatListDataProvider { Future getRooms({ diff --git a/lib/state/app/modules/chatList/repository/chat_list_repository.dart b/lib/state/app/modules/chat_list/repository/chat_list_repository.dart similarity index 86% rename from lib/state/app/modules/chatList/repository/chat_list_repository.dart rename to lib/state/app/modules/chat_list/repository/chat_list_repository.dart index 5a10ce6..9589cb3 100644 --- a/lib/state/app/modules/chatList/repository/chat_list_repository.dart +++ b/lib/state/app/modules/chat_list/repository/chat_list_repository.dart @@ -1,6 +1,6 @@ import '../../../infrastructure/repository/repository.dart'; import '../bloc/chat_list_state.dart'; -import '../dataProvider/chat_list_data_provider.dart'; +import '../data_provider/chat_list_data_provider.dart'; class ChatListRepository extends Repository { final ChatListDataProvider _provider; diff --git a/lib/state/app/modules/files/bloc/files_bloc.dart b/lib/state/app/modules/files/bloc/files_bloc.dart index 3483819..fbe72e3 100644 --- a/lib/state/app/modules/files/bloc/files_bloc.dart +++ b/lib/state/app/modules/files/bloc/files_bloc.dart @@ -1,8 +1,8 @@ import '../../../../../api/errors/error_mapper.dart'; -import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart'; -import '../../../infrastructure/loadableState/loading_error.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; +import '../../../infrastructure/loadable_state/loading_error.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 '../repository/files_repository.dart'; import 'files_event.dart'; import 'files_state.dart'; diff --git a/lib/state/app/modules/files/bloc/files_event.dart b/lib/state/app/modules/files/bloc/files_event.dart index 5b6a3a1..2757b8b 100644 --- a/lib/state/app/modules/files/bloc/files_event.dart +++ b/lib/state/app/modules/files/bloc/files_event.dart @@ -1,4 +1,4 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'files_state.dart'; sealed class FilesEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/files/bloc/files_state.dart b/lib/state/app/modules/files/bloc/files_state.dart index d13b079..448241f 100644 --- a/lib/state/app/modules/files/bloc/files_state.dart +++ b/lib/state/app/modules/files/bloc/files_state.dart @@ -1,6 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart'; +import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; part 'files_state.freezed.dart'; part 'files_state.g.dart'; diff --git a/lib/state/app/modules/files/dataProvider/files_data_provider.dart b/lib/state/app/modules/files/data_provider/files_data_provider.dart similarity index 82% rename from lib/state/app/modules/files/dataProvider/files_data_provider.dart rename to lib/state/app/modules/files/data_provider/files_data_provider.dart index 8b1fef4..708cb29 100644 --- a/lib/state/app/modules/files/dataProvider/files_data_provider.dart +++ b/lib/state/app/modules/files/data_provider/files_data_provider.dart @@ -1,8 +1,8 @@ import 'package:nextcloud/nextcloud.dart'; -import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart'; -import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart'; -import '../../../../../api/marianumcloud/webdav/webdavApi.dart'; +import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; +import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; +import '../../../../../api/marianumcloud/webdav/webdav_api.dart'; class FilesDataProvider { /// Lists files at [path]. Cached payload is delivered via [onCacheData] as diff --git a/lib/state/app/modules/files/repository/files_repository.dart b/lib/state/app/modules/files/repository/files_repository.dart index 341734e..c7e129c 100644 --- a/lib/state/app/modules/files/repository/files_repository.dart +++ b/lib/state/app/modules/files/repository/files_repository.dart @@ -1,6 +1,6 @@ import '../../../infrastructure/repository/repository.dart'; import '../bloc/files_state.dart'; -import '../dataProvider/files_data_provider.dart'; +import '../data_provider/files_data_provider.dart'; class FilesRepository extends Repository { final FilesDataProvider _provider; diff --git a/lib/state/app/modules/gradeAverages/bloc/grade_averages_bloc.dart b/lib/state/app/modules/grade_averages/bloc/grade_averages_bloc.dart similarity index 100% rename from lib/state/app/modules/gradeAverages/bloc/grade_averages_bloc.dart rename to lib/state/app/modules/grade_averages/bloc/grade_averages_bloc.dart diff --git a/lib/state/app/modules/gradeAverages/bloc/grade_averages_event.dart b/lib/state/app/modules/grade_averages/bloc/grade_averages_event.dart similarity index 100% rename from lib/state/app/modules/gradeAverages/bloc/grade_averages_event.dart rename to lib/state/app/modules/grade_averages/bloc/grade_averages_event.dart diff --git a/lib/state/app/modules/gradeAverages/bloc/grade_averages_state.dart b/lib/state/app/modules/grade_averages/bloc/grade_averages_state.dart similarity index 100% rename from lib/state/app/modules/gradeAverages/bloc/grade_averages_state.dart rename to lib/state/app/modules/grade_averages/bloc/grade_averages_state.dart diff --git a/lib/state/app/modules/gradeAverages/bloc/grade_averages_state.freezed.dart b/lib/state/app/modules/grade_averages/bloc/grade_averages_state.freezed.dart similarity index 100% rename from lib/state/app/modules/gradeAverages/bloc/grade_averages_state.freezed.dart rename to lib/state/app/modules/grade_averages/bloc/grade_averages_state.freezed.dart diff --git a/lib/state/app/modules/gradeAverages/bloc/grade_averages_state.g.dart b/lib/state/app/modules/grade_averages/bloc/grade_averages_state.g.dart similarity index 100% rename from lib/state/app/modules/gradeAverages/bloc/grade_averages_state.g.dart rename to lib/state/app/modules/grade_averages/bloc/grade_averages_state.g.dart diff --git a/lib/state/app/modules/holidays/bloc/holidays_bloc.dart b/lib/state/app/modules/holidays/bloc/holidays_bloc.dart index 3f82d04..2e3b96b 100644 --- a/lib/state/app/modules/holidays/bloc/holidays_bloc.dart +++ b/lib/state/app/modules/holidays/bloc/holidays_bloc.dart @@ -1,5 +1,5 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.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 '../repository/holidays_repository.dart'; import 'holidays_event.dart'; import 'holidays_state.dart'; @@ -22,16 +22,16 @@ class HolidaysBloc extends LoadableHydratedBloc const HolidaysState(showPastHolidays: false, holidays: [], showDisclaimer: true); + HolidaysState fromNothing() => const HolidaysState(showPastHolidays: false, holidays: [], showDisclaimer: true); @override - fromStorage(Map json) => HolidaysState.fromJson(json); + HolidaysState fromStorage(Map json) => HolidaysState.fromJson(json); @override Future gatherData() async { var holidays = await repo.getHolidays(); add(DataGathered((state) => state.copyWith(holidays: holidays))); } @override - repository() => HolidaysRepository(); + HolidaysRepository repository() => HolidaysRepository(); @override Map? toStorage(state) => state.toJson(); } diff --git a/lib/state/app/modules/holidays/bloc/holidays_event.dart b/lib/state/app/modules/holidays/bloc/holidays_event.dart index 8565250..4be4a68 100644 --- a/lib/state/app/modules/holidays/bloc/holidays_event.dart +++ b/lib/state/app/modules/holidays/bloc/holidays_event.dart @@ -1,4 +1,4 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'holidays_state.dart'; sealed class HolidaysEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/holidays/bloc/holidays_state.dart b/lib/state/app/modules/holidays/bloc/holidays_state.dart index 1a7eef0..eec02b2 100644 --- a/lib/state/app/modules/holidays/bloc/holidays_state.dart +++ b/lib/state/app/modules/holidays/bloc/holidays_state.dart @@ -1,5 +1,5 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; part 'holidays_state.freezed.dart'; part 'holidays_state.g.dart'; diff --git a/lib/state/app/modules/holidays/dataProvider/holidays_get_holidays.dart b/lib/state/app/modules/holidays/data_provider/holidays_get_holidays.dart similarity index 86% rename from lib/state/app/modules/holidays/dataProvider/holidays_get_holidays.dart rename to lib/state/app/modules/holidays/data_provider/holidays_get_holidays.dart index cd52128..ad663da 100644 --- a/lib/state/app/modules/holidays/dataProvider/holidays_get_holidays.dart +++ b/lib/state/app/modules/holidays/data_provider/holidays_get_holidays.dart @@ -1,7 +1,7 @@ import 'package:dio/dio.dart'; import '../../../basis/dataloader/holiday_data_loader.dart'; -import '../../../infrastructure/dataLoader/data_loader.dart'; +import '../../../infrastructure/data_loader/data_loader.dart'; import '../bloc/holidays_state.dart'; class HolidaysGetHolidays extends HolidayDataLoader> { diff --git a/lib/state/app/modules/holidays/repository/holidays_repository.dart b/lib/state/app/modules/holidays/repository/holidays_repository.dart index 72ec949..7e964c6 100644 --- a/lib/state/app/modules/holidays/repository/holidays_repository.dart +++ b/lib/state/app/modules/holidays/repository/holidays_repository.dart @@ -1,6 +1,6 @@ import '../../../infrastructure/repository/repository.dart'; import '../bloc/holidays_state.dart'; -import '../dataProvider/holidays_get_holidays.dart'; +import '../data_provider/holidays_get_holidays.dart'; class HolidaysRepository extends Repository { Future> getHolidays() => HolidaysGetHolidays().run(); diff --git a/lib/state/app/modules/marianumDates/bloc/marianum_dates_bloc.dart b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart similarity index 65% rename from lib/state/app/modules/marianumDates/bloc/marianum_dates_bloc.dart rename to lib/state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart index 3f7961b..241370d 100644 --- a/lib/state/app/modules/marianumDates/bloc/marianum_dates_bloc.dart +++ b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart @@ -1,5 +1,5 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.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 '../repository/marianum_dates_repository.dart'; import 'marianum_dates_event.dart'; import 'marianum_dates_state.dart'; @@ -18,16 +18,16 @@ class MarianumDatesBloc extends LoadableHydratedBloc const MarianumDatesState(showPastEvents: false, events: []); + MarianumDatesState fromNothing() => const MarianumDatesState(showPastEvents: false, events: []); @override - fromStorage(Map json) => MarianumDatesState.fromJson(json); + MarianumDatesState fromStorage(Map json) => MarianumDatesState.fromJson(json); @override Future gatherData() async { final events = await repo.getEvents(); add(DataGathered((state) => state.copyWith(events: events))); } @override - repository() => MarianumDatesRepository(); + MarianumDatesRepository repository() => MarianumDatesRepository(); @override Map? toStorage(state) => state.toJson(); } diff --git a/lib/state/app/modules/marianumDates/bloc/marianum_dates_event.dart b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_event.dart similarity index 70% rename from lib/state/app/modules/marianumDates/bloc/marianum_dates_event.dart rename to lib/state/app/modules/marianum_dates/bloc/marianum_dates_event.dart index 34b5b8d..1bfcb88 100644 --- a/lib/state/app/modules/marianumDates/bloc/marianum_dates_event.dart +++ b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_event.dart @@ -1,4 +1,4 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'marianum_dates_state.dart'; sealed class MarianumDatesEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/marianumDates/bloc/marianum_dates_state.dart b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.dart similarity index 100% rename from lib/state/app/modules/marianumDates/bloc/marianum_dates_state.dart rename to lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.dart index d3a7d14..26eb18f 100644 --- a/lib/state/app/modules/marianumDates/bloc/marianum_dates_state.dart +++ b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.dart @@ -1,5 +1,5 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; part 'marianum_dates_state.freezed.dart'; part 'marianum_dates_state.g.dart'; diff --git a/lib/state/app/modules/marianumDates/bloc/marianum_dates_state.freezed.dart b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.freezed.dart similarity index 100% rename from lib/state/app/modules/marianumDates/bloc/marianum_dates_state.freezed.dart rename to lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.freezed.dart diff --git a/lib/state/app/modules/marianumDates/bloc/marianum_dates_state.g.dart b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.g.dart similarity index 100% rename from lib/state/app/modules/marianumDates/bloc/marianum_dates_state.g.dart rename to lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.g.dart diff --git a/lib/state/app/modules/marianumDates/dataProvider/marianum_dates_get_events.dart b/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart similarity index 92% rename from lib/state/app/modules/marianumDates/dataProvider/marianum_dates_get_events.dart rename to lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart index 2ab5ecd..fc0c177 100644 --- a/lib/state/app/modules/marianumDates/dataProvider/marianum_dates_get_events.dart +++ b/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart @@ -7,8 +7,8 @@ class MarianumDatesGetEvents { static const String url = 'https://public-cal.marianumlan.de/cal_public/ad4c5da8-7466-9c72-89cb-8b8d9a5cf26c'; final Dio _dio = Dio(BaseOptions( - connectTimeout: const Duration(seconds: 10).inMilliseconds, - receiveTimeout: const Duration(seconds: 30).inMilliseconds, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), )); Future> run() async { diff --git a/lib/state/app/modules/marianumDates/repository/marianum_dates_repository.dart b/lib/state/app/modules/marianum_dates/repository/marianum_dates_repository.dart similarity index 81% rename from lib/state/app/modules/marianumDates/repository/marianum_dates_repository.dart rename to lib/state/app/modules/marianum_dates/repository/marianum_dates_repository.dart index 416b4d5..ead14c9 100644 --- a/lib/state/app/modules/marianumDates/repository/marianum_dates_repository.dart +++ b/lib/state/app/modules/marianum_dates/repository/marianum_dates_repository.dart @@ -1,6 +1,6 @@ import '../../../infrastructure/repository/repository.dart'; import '../bloc/marianum_dates_state.dart'; -import '../dataProvider/marianum_dates_get_events.dart'; +import '../data_provider/marianum_dates_get_events.dart'; class MarianumDatesRepository extends Repository { Future> getEvents() => MarianumDatesGetEvents().run(); diff --git a/lib/state/app/modules/marianumMessage/bloc/marianum_message_bloc.dart b/lib/state/app/modules/marianum_message/bloc/marianum_message_bloc.dart similarity index 80% rename from lib/state/app/modules/marianumMessage/bloc/marianum_message_bloc.dart rename to lib/state/app/modules/marianum_message/bloc/marianum_message_bloc.dart index 97c3b95..daca62d 100644 --- a/lib/state/app/modules/marianumMessage/bloc/marianum_message_bloc.dart +++ b/lib/state/app/modules/marianum_message/bloc/marianum_message_bloc.dart @@ -1,5 +1,5 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.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 '../repository/marianum_message_repository.dart'; import 'marianum_message_event.dart'; import 'marianum_message_state.dart'; diff --git a/lib/state/app/modules/marianumMessage/bloc/marianum_message_event.dart b/lib/state/app/modules/marianum_message/bloc/marianum_message_event.dart similarity index 63% rename from lib/state/app/modules/marianumMessage/bloc/marianum_message_event.dart rename to lib/state/app/modules/marianum_message/bloc/marianum_message_event.dart index 43cbf2a..f71d5c7 100644 --- a/lib/state/app/modules/marianumMessage/bloc/marianum_message_event.dart +++ b/lib/state/app/modules/marianum_message/bloc/marianum_message_event.dart @@ -1,4 +1,4 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'marianum_message_state.dart'; sealed class MarianumMessageEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/marianumMessage/bloc/marianum_message_state.dart b/lib/state/app/modules/marianum_message/bloc/marianum_message_state.dart similarity index 100% rename from lib/state/app/modules/marianumMessage/bloc/marianum_message_state.dart rename to lib/state/app/modules/marianum_message/bloc/marianum_message_state.dart diff --git a/lib/state/app/modules/marianumMessage/bloc/marianum_message_state.freezed.dart b/lib/state/app/modules/marianum_message/bloc/marianum_message_state.freezed.dart similarity index 100% rename from lib/state/app/modules/marianumMessage/bloc/marianum_message_state.freezed.dart rename to lib/state/app/modules/marianum_message/bloc/marianum_message_state.freezed.dart diff --git a/lib/state/app/modules/marianumMessage/bloc/marianum_message_state.g.dart b/lib/state/app/modules/marianum_message/bloc/marianum_message_state.g.dart similarity index 100% rename from lib/state/app/modules/marianumMessage/bloc/marianum_message_state.g.dart rename to lib/state/app/modules/marianum_message/bloc/marianum_message_state.g.dart diff --git a/lib/state/app/modules/marianumMessage/dataProvider/marianum_message_get_messages.dart b/lib/state/app/modules/marianum_message/data_provider/marianum_message_get_messages.dart similarity index 79% rename from lib/state/app/modules/marianumMessage/dataProvider/marianum_message_get_messages.dart rename to lib/state/app/modules/marianum_message/data_provider/marianum_message_get_messages.dart index f8c4b24..a74dda4 100644 --- a/lib/state/app/modules/marianumMessage/dataProvider/marianum_message_get_messages.dart +++ b/lib/state/app/modules/marianum_message/data_provider/marianum_message_get_messages.dart @@ -1,12 +1,12 @@ import 'package:dio/dio.dart'; -import '../../../infrastructure/dataLoader/data_loader.dart'; import '../../../basis/dataloader/mhsl_data_loader.dart'; +import '../../../infrastructure/data_loader/data_loader.dart'; import '../bloc/marianum_message_state.dart'; class MarianumMessageGetMessages extends MhslDataLoader { @override Future> fetch() async => dio.get('/message/messages.json'); @override - MarianumMessageList assemble(DataLoaderResult data) => MarianumMessageList.fromJson(data.json); + MarianumMessageList assemble(DataLoaderResult data) => MarianumMessageList.fromJson(data.asMap()); } diff --git a/lib/state/app/modules/marianumMessage/repository/marianum_message_repository.dart b/lib/state/app/modules/marianum_message/repository/marianum_message_repository.dart similarity index 81% rename from lib/state/app/modules/marianumMessage/repository/marianum_message_repository.dart rename to lib/state/app/modules/marianum_message/repository/marianum_message_repository.dart index 3148b92..9a6d9bc 100644 --- a/lib/state/app/modules/marianumMessage/repository/marianum_message_repository.dart +++ b/lib/state/app/modules/marianum_message/repository/marianum_message_repository.dart @@ -1,6 +1,6 @@ import '../../../infrastructure/repository/repository.dart'; import '../bloc/marianum_message_state.dart'; -import '../dataProvider/marianum_message_get_messages.dart'; +import '../data_provider/marianum_message_get_messages.dart'; class MarianumMessageRepository extends Repository { Future getMessages() => MarianumMessageGetMessages().run(); diff --git a/lib/state/app/modules/settings/bloc/settings_cubit.dart b/lib/state/app/modules/settings/bloc/settings_cubit.dart index efc372f..ad38792 100644 --- a/lib/state/app/modules/settings/bloc/settings_cubit.dart +++ b/lib/state/app/modules/settings/bloc/settings_cubit.dart @@ -1,10 +1,10 @@ import 'dart:async'; import 'dart:developer'; -import 'package:easy_debounce/easy_debounce.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../storage/settings.dart'; +import '../../../../../utils/debouncer.dart'; import '../../../../../view/pages/settings/data/default_settings.dart'; import '../../app_modules.dart'; @@ -27,7 +27,7 @@ class SettingsCubit extends HydratedCubit { _emitFreshInstance(); }); } - EasyDebounce.debounce(_debounceTag, const Duration(milliseconds: 500), _emitFreshInstance); + Debouncer.debounce(_debounceTag, const Duration(milliseconds: 500), _emitFreshInstance); } return state; } @@ -77,7 +77,7 @@ class SettingsCubit extends HydratedCubit { oldMap.forEach((key, value) { if (merged.containsKey(key)) { if (value is Map && merged[key] is Map) { - merged[key] = _mergeSettings(value, merged[key]); + merged[key] = _mergeSettings(value, merged[key] as Map); } else { merged[key] = value; } diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart index 1b142e9..1c7dc5b 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -1,9 +1,9 @@ import 'package:intl/intl.dart'; -import '../../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; -import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../../../../../api/webuntis/queries/get_timetable/get_timetable_response.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 '../repository/timetable_repository.dart'; import 'timetable_event.dart'; import 'timetable_state.dart'; diff --git a/lib/state/app/modules/timetable/bloc/timetable_event.dart b/lib/state/app/modules/timetable/bloc/timetable_event.dart index de90c8e..871f2bc 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_event.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_event.dart @@ -1,4 +1,4 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'timetable_state.dart'; sealed class TimetableEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.dart b/lib/state/app/modules/timetable/bloc/timetable_state.dart index 1d1b2ea..af26bee 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.dart @@ -1,11 +1,11 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import '../../../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart'; -import '../../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; -import '../../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; -import '../../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; -import '../../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart'; -import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; +import '../../../../../api/webuntis/queries/get_holidays/get_holidays_response.dart'; +import '../../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart'; +import '../../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart'; +import '../../../../../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart'; +import '../../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; part 'timetable_state.freezed.dart'; part 'timetable_state.g.dart'; diff --git a/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart similarity index 67% rename from lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart rename to lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart index 8e4766b..8859d1d 100644 --- a/lib/state/app/modules/timetable/dataProvider/timetable_data_provider.dart +++ b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart @@ -1,25 +1,25 @@ import 'package:intl/intl.dart'; -import '../../../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart'; -import '../../../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.dart'; -import '../../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; -import '../../../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart'; -import '../../../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.dart'; -import '../../../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart'; -import '../../../../../api/mhsl/customTimetableEvent/remove/removeCustomTimetableEvent.dart'; -import '../../../../../api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.dart'; -import '../../../../../api/mhsl/customTimetableEvent/update/updateCustomTimetableEvent.dart'; -import '../../../../../api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.dart'; -import '../../../../../api/webuntis/queries/getHolidays/getHolidaysCache.dart'; -import '../../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; -import '../../../../../api/webuntis/queries/getRooms/getRoomsCache.dart'; -import '../../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; -import '../../../../../api/webuntis/queries/getSubjects/getSubjectsCache.dart'; -import '../../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; -import '../../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart'; -import '../../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart'; -import '../../../../../api/webuntis/queries/getTimetable/getTimetableCache.dart'; -import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../../../api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart'; +import '../../../../../api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart'; +import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart'; +import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart'; +import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; +import '../../../../../api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart'; +import '../../../../../api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart'; +import '../../../../../api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart'; +import '../../../../../api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart'; +import '../../../../../api/webuntis/queries/get_holidays/get_holidays_cache.dart'; +import '../../../../../api/webuntis/queries/get_holidays/get_holidays_response.dart'; +import '../../../../../api/webuntis/queries/get_rooms/get_rooms_cache.dart'; +import '../../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart'; +import '../../../../../api/webuntis/queries/get_subjects/get_subjects_cache.dart'; +import '../../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart'; +import '../../../../../api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart'; +import '../../../../../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart'; +import '../../../../../api/webuntis/queries/get_timetable/get_timetable_cache.dart'; +import '../../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; import '../../../../../model/account_data.dart'; class TimetableDataProvider { diff --git a/lib/state/app/modules/timetable/repository/timetable_repository.dart b/lib/state/app/modules/timetable/repository/timetable_repository.dart index 43ac3a7..36cb6e3 100644 --- a/lib/state/app/modules/timetable/repository/timetable_repository.dart +++ b/lib/state/app/modules/timetable/repository/timetable_repository.dart @@ -1,6 +1,6 @@ import '../../../infrastructure/repository/repository.dart'; import '../bloc/timetable_state.dart'; -import '../dataProvider/timetable_data_provider.dart'; +import '../data_provider/timetable_data_provider.dart'; class TimetableRepository extends Repository { final TimetableDataProvider _provider; diff --git a/lib/storage/settings.dart b/lib/storage/settings.dart index af3abbb..4e42830 100644 --- a/lib/storage/settings.dart +++ b/lib/storage/settings.dart @@ -4,8 +4,8 @@ import 'package:json_annotation/json_annotation.dart'; import 'dev_tools_settings.dart'; import 'file_settings.dart'; import 'file_view_settings.dart'; -import 'modules_settings.dart'; import 'holidays_settings.dart'; +import 'modules_settings.dart'; import 'notification_settings.dart'; import 'talk_settings.dart'; import 'timetable_settings.dart'; diff --git a/lib/utils/cache_invalidation_bus.dart b/lib/utils/cache_invalidation_bus.dart new file mode 100644 index 0000000..ee98c0d --- /dev/null +++ b/lib/utils/cache_invalidation_bus.dart @@ -0,0 +1,20 @@ +import 'dart:async'; + +/// App-wide pub/sub channel for cache invalidations. Producers (e.g. webdav +/// move/delete handlers) call [notifyListFiles] after they have dropped the +/// cached listing for a folder so that any [_FilesView] currently sitting on +/// that folder — possibly in the background, beneath a child route — can +/// refresh itself instead of showing the stale snapshot it loaded earlier. +class CacheInvalidationBus { + CacheInvalidationBus._(); + + static final StreamController _listFiles = StreamController.broadcast(); + + /// Emits the invalidated `pathString` (in `FilesBloc` format: relative, + /// no leading or trailing slash; root is '/'). + static Stream get listFilesStream => _listFiles.stream; + + static void notifyListFiles(String pathString) { + _listFiles.add(pathString); + } +} diff --git a/lib/utils/debouncer.dart b/lib/utils/debouncer.dart new file mode 100644 index 0000000..ce3fb5e --- /dev/null +++ b/lib/utils/debouncer.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +/// Static map-based debouncer/throttler. Replaces the `easy_debounce` package +/// with a minimal in-house implementation: each unique [tag] tracks its own +/// pending Timer and gating flag. +class Debouncer { + Debouncer._(); + + static final Map _debounceTimers = {}; + static final Map _throttleTimers = {}; + + /// Coalesces calls under [tag]: the [action] runs once [delay] has elapsed + /// without further calls for the same tag. + static void debounce(String tag, Duration delay, void Function() action) { + _debounceTimers[tag]?.cancel(); + _debounceTimers[tag] = Timer(delay, () { + _debounceTimers.remove(tag); + action(); + }); + } + + /// Runs [action] immediately and ignores subsequent calls under the same + /// [tag] until [duration] has elapsed. + static void throttle(String tag, Duration duration, void Function() action) { + if (_throttleTimers.containsKey(tag)) return; + _throttleTimers[tag] = Timer(duration, () => _throttleTimers.remove(tag)); + action(); + } + + static void cancel(String tag) { + _debounceTimers.remove(tag)?.cancel(); + _throttleTimers.remove(tag)?.cancel(); + } +} diff --git a/lib/utils/download_manager.dart b/lib/utils/download_manager.dart new file mode 100644 index 0000000..ba9c0dd --- /dev/null +++ b/lib/utils/download_manager.dart @@ -0,0 +1,146 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../api/marianumcloud/webdav/webdav_api.dart'; +import '../model/account_data.dart'; +import 'file_downloader.dart'; + +/// Snapshot of a single download's lifecycle. UI widgets rebuild whenever the +/// owning [DownloadJob.status] notifier emits a new instance. +sealed class DownloadStatus { + const DownloadStatus(); +} + +class DownloadInProgress extends DownloadStatus { + const DownloadInProgress(this.percent); + final double percent; +} + +class DownloadDone extends DownloadStatus { + const DownloadDone(this.localPath); + final String localPath; +} + +class DownloadCancelled extends DownloadStatus { + const DownloadCancelled(); +} + +class DownloadFailed extends DownloadStatus { + const DownloadFailed(this.message); + final String message; +} + +/// Tracks a single in-flight or finished download. Survives widget dispose so +/// that re-entering the screen reattaches to the same job. +class DownloadJob { + DownloadJob({ + required this.remotePath, + required this.name, + required this.localPath, + required FileDownloader downloader, + }) : _downloader = downloader; + + final String remotePath; + final String name; + final String localPath; + final FileDownloader _downloader; + + final ValueNotifier status = ValueNotifier(const DownloadInProgress(0)); + bool _disposed = false; + + bool get isFinished => + status.value is DownloadDone || + status.value is DownloadFailed || + status.value is DownloadCancelled; + + void cancel() { + if (isFinished) return; + _downloader.cancel(); + status.value = const DownloadCancelled(); + } + + void _dispose() { + if (_disposed) return; + _disposed = true; + status.dispose(); + } +} + +/// Central in-memory registry for downloads. Keyed by remote path so a file +/// triggered from multiple screens (Files + Chat) reuses one job. +/// +/// Not persistent across app restarts — restarting abandons in-flight +/// downloads and partial files are cleaned on next start attempt. +class DownloadManager { + DownloadManager._(); + static final DownloadManager instance = DownloadManager._(); + + final Map _jobs = {}; + + /// Active or recently finished job for [remotePath], or null if none. + DownloadJob? jobFor(String remotePath) => _jobs[remotePath]; + + /// Returns the existing job if a download is in progress for [remotePath], + /// otherwise starts a new one. Caller listens on [DownloadJob.status]. + Future start({required String remotePath, required String name}) async { + final existing = _jobs[remotePath]; + if (existing != null && !existing.isFinished) return existing; + if (existing != null) { + _jobs.remove(remotePath); + scheduleMicrotask(existing._dispose); + } + + final tempDir = await getTemporaryDirectory(); + final encodedPath = Uri.encodeComponent(remotePath).replaceAll('%2F', '/'); + final localPath = '${tempDir.path}${Platform.pathSeparator}$name'; + + final downloader = FileDownloader(); + final job = DownloadJob( + remotePath: remotePath, + name: name, + localPath: localPath, + downloader: downloader, + ); + _jobs[remotePath] = job; + + downloader.run( + client: Dio(BaseOptions(headers: AccountData().authHeaders())), + url: '${WebdavApi.buildWebdavUrl()}$encodedPath', + savePath: localPath, + onProgress: (percent) { + if (job.isFinished) return; + job.status.value = DownloadInProgress(percent); + }, + onDone: () { + if (job.isFinished) return; + job.status.value = DownloadDone(localPath); + }, + onError: (error) { + if (job.isFinished) return; + try { + File(localPath).deleteSync(); + } on FileSystemException { + // partial file may not exist — ignore + } + job.status.value = DownloadFailed(error.toString()); + }, + ); + + return job; + } + + /// Removes a finished job from the registry. Safe to call from a status + /// listener: actual disposal of the underlying notifier is deferred to the + /// next microtask so the in-flight `notifyListeners` cycle can finish before + /// the notifier is destroyed. Active (unfinished) jobs are left untouched. + void clear(String remotePath) { + final job = _jobs[remotePath]; + if (job == null || !job.isFinished) return; + _jobs.remove(remotePath); + scheduleMicrotask(job._dispose); + } +} diff --git a/lib/utils/file_clipboard.dart b/lib/utils/file_clipboard.dart new file mode 100644 index 0000000..a8abda3 --- /dev/null +++ b/lib/utils/file_clipboard.dart @@ -0,0 +1,44 @@ +import 'package:flutter/foundation.dart'; + +import '../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; + +enum FileClipboardOperation { cut, copy } + +/// In-memory clipboard for file operations within the app. Mirrors the +/// cut/copy/paste pattern of native file managers (iOS Files, Android Files, +/// Finder). Contents are not persisted across app restarts. +/// +/// Listen via [ChangeNotifier] (e.g. `ListenableBuilder`) to render a paste +/// banner when [isEmpty] is false. +class FileClipboard extends ChangeNotifier { + FileClipboard._(); + static final FileClipboard instance = FileClipboard._(); + + FileClipboardOperation? _operation; + List _files = const []; + + FileClipboardOperation? get operation => _operation; + 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 copy(List files) { + if (files.isEmpty) return; + _operation = FileClipboardOperation.copy; + _files = List.of(files); + notifyListeners(); + } + + void clear() { + if (_operation == null && _files.isEmpty) return; + _operation = null; + _files = const []; + notifyListeners(); + } +} diff --git a/lib/utils/file_downloader.dart b/lib/utils/file_downloader.dart new file mode 100644 index 0000000..d6b8152 --- /dev/null +++ b/lib/utils/file_downloader.dart @@ -0,0 +1,47 @@ +import 'package:dio/dio.dart'; + +/// Lightweight cancel handle around a single `Dio.download` call. The download +/// itself is started by [run]; the handle returns synchronously so callers can +/// install it into shared state before the first progress event can fire. +class FileDownloader { + FileDownloader(); + + final CancelToken _cancelToken = CancelToken(); + bool _cancelled = false; + + bool get isCancelled => _cancelled; + + void cancel() { + if (_cancelled) return; + _cancelled = true; + _cancelToken.cancel('user cancelled'); + } + + /// Kicks off the download. Returns immediately; the download progresses in + /// the background and events are delivered via callbacks. Callbacks are not + /// invoked once [cancel] has been called. + void run({ + required Dio client, + required String url, + required String savePath, + required void Function(double percent) onProgress, + required void Function() onDone, + required void Function(Object error) onError, + }) { + client.download( + url, + savePath, + cancelToken: _cancelToken, + onReceiveProgress: (received, total) { + if (_cancelled || total <= 0) return; + onProgress((received / total) * 100); + }, + ).then((_) { + if (_cancelled) return; + onDone(); + }).catchError((Object error) { + if (_cancelled) return; + onError(error); + }).ignore(); + } +} diff --git a/lib/utils/file_saver.dart b/lib/utils/file_saver.dart deleted file mode 100644 index 1a1a88c..0000000 --- a/lib/utils/file_saver.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'dart:io'; - -import 'package:permission_handler/permission_handler.dart'; - -// only tested on android! -class FileSaver { - static Future getExternalDocumentPath() async { - var permission = await Permission.storage.status; - if(!permission.isGranted) { - await Permission.storage.request(); - } - var directory = Directory('/storage/emulated/0/Download'); - final externalPath = directory.path; - await Directory(externalPath).create(recursive: true); - return externalPath; - } - - static Future writeBytes(List bytes, String name) async { - final path = await getExternalDocumentPath(); - var file = File('$path/$name'); - return file.writeAsBytes(bytes); - } -} diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index 73df505..a2d22cc 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -5,8 +5,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_login/flutter_login.dart'; -import '../../api/marianumcloud/talk/room/getRoom.dart'; -import '../../api/marianumcloud/talk/room/getRoomParams.dart'; +import '../../api/marianumcloud/talk/room/get_room.dart'; +import '../../api/marianumcloud/talk/room/get_room_params.dart'; import '../../model/account_data.dart'; import '../../state/app/modules/account/bloc/account_bloc.dart'; import '../../state/app/modules/account/bloc/account_state.dart'; diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index 212c8fc..d673417 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -1,21 +1,26 @@ -import 'dart:io'; +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:nextcloud/nextcloud.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import '../../../api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart'; -import '../../../state/app/infrastructure/loadableState/loadable_state.dart'; -import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; -import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; +import '../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import '../../../api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; +import '../../../api/marianumcloud/webdav/webdav_api.dart'; +import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +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 '../../../utils/file_clipboard.dart'; import '../../../widget/async_action_button.dart'; import '../../../widget/file_pick.dart'; import '../../../widget/placeholder_view.dart'; -import 'widgets/file_element.dart'; import 'files_upload_dialog.dart'; +import 'widgets/file_element.dart'; class BetterSortOption { String displayName; @@ -78,6 +83,11 @@ class _FilesViewState extends State<_FilesView> { late final SettingsCubit settings; late SortOption currentSort; late bool currentSortDirection; + late final StreamSubscription _invalidationSub; + + // 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('/'); @override void initState() { @@ -85,12 +95,25 @@ class _FilesViewState extends State<_FilesView> { settings = context.read(); currentSort = settings.val().fileSettings.sortBy; currentSortDirection = settings.val().fileSettings.ascending; + _invalidationSub = CacheInvalidationBus.listFilesStream.listen(_onInvalidation); + } + + void _onInvalidation(String invalidatedPath) { + if (!mounted) return; + if (invalidatedPath != _myPathString) return; + context.read().refresh(); + } + + @override + void dispose() { + _invalidationSub.cancel(); + super.dispose(); } Future mediaUpload(List? paths) async { if (paths == null) return; final bloc = context.read(); - pushScreen( + unawaited(pushScreen( context, withNavBar: false, screen: FilesUploadDialog( @@ -98,7 +121,7 @@ class _FilesViewState extends State<_FilesView> { remotePath: widget.path.join('/'), onUploadFinished: (_) => bloc.refresh(), ), - ); + )); } @override @@ -163,28 +186,39 @@ class _FilesViewState extends State<_FilesView> { onPressed: () => _showAddDialog(context, bloc), child: const Icon(Icons.add), ), - body: LoadableStateConsumer( - isReady: (state) => state.listing != null, - child: (state, _) { - final listing = state.listing!; - if (listing.files.isEmpty) { - return const PlaceholderView(icon: Icons.folder_off_rounded, text: 'Der Ordner ist leer'); - } - final files = listing.sortBy( - sortOption: currentSort, - foldersToTop: context.watch().val().fileSettings.sortFoldersToTop, - reversed: currentSortDirection, - ); - return ListView.builder( - padding: EdgeInsets.zero, - itemCount: files.length, - itemBuilder: (context, index) => FileElement(files[index], widget.path, bloc.refresh), - ); - }, + body: Column( + children: [ + _ClipboardBanner(currentFolder: _currentFolderPath, onPasteDone: bloc.refresh), + Expanded( + child: LoadableStateConsumer( + isReady: (state) => state.listing != null, + child: (state, _) { + final listing = state.listing!; + if (listing.files.isEmpty) { + return const PlaceholderView(icon: Icons.folder_off_rounded, text: 'Der Ordner ist leer'); + } + final files = listing.sortBy( + sortOption: currentSort, + foldersToTop: context.watch().val().fileSettings.sortFoldersToTop, + reversed: currentSortDirection, + ); + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: files.length, + itemBuilder: (context, index) => FileElement(files[index], widget.path, bloc.refresh), + ); + }, + ), + ), + ], ), ); } + // 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('/')}/'; + void _showAddDialog(BuildContext context, FilesBloc bloc) { showDialog( context: context, @@ -205,18 +239,25 @@ class _FilesViewState extends State<_FilesView> { Navigator.of(dialogCtx).pop(); }, ), - Visibility( - visible: !Platform.isIOS, - child: ListTile( - leading: const Icon(Icons.add_a_photo_outlined), - title: const Text('Aus Gallerie hochladen'), - onTap: () { - FilePick.multipleGalleryPick().then((value) { - if (value != null) mediaUpload(value.map((e) => e.path).toList()); - }); - Navigator.of(dialogCtx).pop(); - }, - ), + ListTile( + leading: const Icon(Icons.add_a_photo_outlined), + title: const Text('Aus Galerie hochladen'), + onTap: () { + FilePick.multipleGalleryPick().then((value) { + if (value != null) mediaUpload(value.map((e) => e.path).toList()); + }); + Navigator.of(dialogCtx).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.camera_alt_outlined), + title: const Text('Foto aufnehmen'), + onTap: () { + FilePick.cameraPick().then((image) { + if (image != null) mediaUpload([image.path]); + }); + Navigator.of(dialogCtx).pop(); + }, ), ]), ); @@ -248,3 +289,142 @@ class _FilesViewState extends State<_FilesView> { ); } } + +class _ClipboardBanner extends StatefulWidget { + const _ClipboardBanner({required this.currentFolder, required this.onPasteDone}); + final String currentFolder; + final void Function() onPasteDone; + + @override + State<_ClipboardBanner> createState() => _ClipboardBannerState(); +} + +class _ClipboardBannerState extends State<_ClipboardBanner> { + 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 + // - every entry already lives in the current folder (paste would be a no-op) + bool get _canPaste { + final cb = FileClipboard.instance; + if (cb.isEmpty) return false; + final dst = _normalised(widget.currentFolder); + var atLeastOneActionable = false; + for (final f in cb.files) { + if (f.isDirectory) { + final src = _normalised(f.path); + if (dst == src || dst.startsWith(src)) return false; + } + final destination = _joinPath(widget.currentFolder, f.name, isDirectory: f.isDirectory); + if (destination != f.path) atLeastOneActionable = true; + } + 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; + setState(() => _busy = true); + final operation = cb.operation; + final errors = []; + final invalidatedSourceFolders = {}; + try { + final webdav = await WebdavApi.webdav; + for (final file in cb.files) { + final destination = _joinPath(widget.currentFolder, file.name, isDirectory: file.isDirectory); + if (destination == file.path) continue; + try { + if (operation == FileClipboardOperation.cut) { + await webdav.move(PathUri.parse(file.path), PathUri.parse(destination)); + invalidatedSourceFolders.add(_parentCacheKey(file.path)); + } else { + await webdav.copy(PathUri.parse(file.path), PathUri.parse(destination)); + } + } on Object catch (e) { + errors.add('${file.name}: $e'); + } + } + // 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); + } + if (operation == FileClipboardOperation.cut) cb.clear(); + widget.onPasteDone(); + } finally { + if (mounted) setState(() => _busy = false); + } + if (errors.isNotEmpty && mounted) { + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Einfügen teilweise fehlgeschlagen'), + content: SingleChildScrollView(child: Text(errors.join('\n\n'))), + actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('OK'))], + ), + ); + } + } + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: FileClipboard.instance, + builder: (context, _) { + final cb = FileClipboard.instance; + if (cb.isEmpty) return const SizedBox.shrink(); + final cut = cb.operation == FileClipboardOperation.cut; + final count = cb.files.length; + final label = count == 1 ? '"${cb.files.first.name}"' : '$count Elemente'; + return Material( + color: Theme.of(context).colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Icon(cut ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + cut ? '$label verschieben' : '$label kopieren', + overflow: TextOverflow.ellipsis, + ), + ), + TextButton( + onPressed: _busy || !_canPaste ? null : _paste, + child: _busy + ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Hier einfügen'), + ), + IconButton( + tooltip: 'Verwerfen', + icon: const Icon(Icons.close, size: 20), + onPressed: _busy ? null : cb.clear, + ), + ], + ), + ), + ); + }, + ); +} diff --git a/lib/view/pages/files/files_upload_dialog.dart b/lib/view/pages/files/files_upload_dialog.dart index 4c9b1c6..44fe7cf 100644 --- a/lib/view/pages/files/files_upload_dialog.dart +++ b/lib/view/pages/files/files_upload_dialog.dart @@ -3,9 +3,8 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:loader_overlay/loader_overlay.dart'; import 'package:nextcloud/nextcloud.dart'; -import 'package:uuid/uuid.dart'; -import '../../../api/marianumcloud/webdav/webdavApi.dart'; +import '../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../widget/confirm_dialog.dart'; import '../../../widget/focus_behaviour.dart'; @@ -107,14 +106,14 @@ class _FilesUploadDialogState extends State { _showUploadError('Verbindung fehlgeschlagen: $e'); return; } - var conflictingFiles = _uploadableFiles.where((file) { - var fileName = file.fileName; - return result.any((element) => Uri.decodeComponent(element.href!).endsWith('/$fileName')); + final conflictingFiles = _uploadableFiles.where((file) { + final fileName = file.fileName; + return result.any((element) => Uri.decodeComponent((element as WebDavResponse).href!).endsWith('/$fileName')); }).toList(); - if(conflictingFiles.isNotEmpty) { + if (conflictingFiles.isNotEmpty) { if (!mounted) return; - bool replaceFiles = await showDialog( + final replaceFiles = await showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( @@ -160,7 +159,7 @@ class _FilesUploadDialogState extends State { ) ); - if(!replaceFiles) { + if (replaceFiles != true) { setState(() { _isUploading = false; _overallProgressValue = 0.0; @@ -179,7 +178,10 @@ class _FilesUploadDialogState extends State { var fileName = file.fileName; var filePath = file.filePath; - if(widget.uniqueNames) fileName = '${fileName.split('.').first}-${const Uuid().v4()}.${fileName.split('.').last}'; + if (widget.uniqueNames) { + final unique = DateTime.now().microsecondsSinceEpoch.toRadixString(36); + fileName = '${fileName.split('.').first}-$unique.${fileName.split('.').last}'; + } var fullRemotePath = '${widget.remotePath}/$fileName'; @@ -187,7 +189,7 @@ class _FilesUploadDialogState extends State { _infoText = '${_uploadableFiles.indexOf(file) + 1}/${_uploadableFiles.length}'; }); - final dynamic uploadTask; + final HttpClientResponse uploadTask; try { uploadTask = await webdavClient.putFile( File(filePath), diff --git a/lib/view/pages/files/widgets/file_details_sheet.dart b/lib/view/pages/files/widgets/file_details_sheet.dart new file mode 100644 index 0000000..418b50e --- /dev/null +++ b/lib/view/pages/files/widgets/file_details_sheet.dart @@ -0,0 +1,80 @@ +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:jiffy/jiffy.dart'; + +import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; + +/// Shows a modal bottom sheet with technical metadata about a single file or +/// folder: full path, MIME type, size, timestamps, ETag. +Future showFileDetailsSheet(BuildContext context, CacheableFile file) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) => SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Icon(file.isDirectory ? Icons.folder : Icons.description_outlined, size: 32), + title: Text(file.name, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(file.isDirectory ? 'Ordner' : (file.mimeType ?? '–')), + ), + const Divider(), + _DetailRow(label: 'Pfad', value: file.path, copyable: true), + if (!file.isDirectory) _DetailRow(label: 'Größe', value: filesize(file.size)), + if (file.modifiedAt != null) + _DetailRow( + label: 'Geändert', + value: '${Jiffy.parseFromDateTime(file.modifiedAt!).format(pattern: 'dd.MM.yyyy HH:mm')} ' + '(${Jiffy.parseFromDateTime(file.modifiedAt!).fromNow()})', + ), + if (file.createdAt != null) + _DetailRow( + label: 'Erstellt', + value: Jiffy.parseFromDateTime(file.createdAt!).format(pattern: 'dd.MM.yyyy HH:mm'), + ), + if (file.eTag != null) _DetailRow(label: 'ETag', value: file.eTag!, copyable: true), + ], + ), + ), + ), + ); +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value, this.copyable = false}); + final String label; + final String value; + final bool copyable; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 90, + child: Text(label, style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), + ), + Expanded(child: SelectableText(value)), + if (copyable) + IconButton( + tooltip: 'Kopieren', + icon: const Icon(Icons.copy, size: 18), + onPressed: () { + Clipboard.setData(ClipboardData(text: value)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('In Zwischenablage kopiert')), + ); + }, + ), + ], + ), + ); +} diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart index 26439a0..3718c14 100644 --- a/lib/view/pages/files/widgets/file_element.dart +++ b/lib/view/pages/files/widgets/file_element.dart @@ -1,24 +1,18 @@ -import 'dart:io'; - -import 'package:dio/dio.dart'; import 'package:filesize/filesize.dart'; -import 'package:flowder/flowder.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; -import 'package:open_filex/open_filex.dart'; -import '../../../../widget/info_dialog.dart'; import 'package:nextcloud/nextcloud.dart'; -import 'package:path_provider/path_provider.dart'; -import '../../../../api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart'; -import '../../../../api/marianumcloud/webdav/webdavApi.dart'; -import '../../../../model/account_data.dart'; +import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import '../../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../../model/endpoint_data.dart'; import '../../../../routing/app_routes.dart'; +import '../../../../utils/download_manager.dart'; +import '../../../../utils/file_clipboard.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; -import '../../../../widget/unimplemented_dialog.dart'; +import '../../../../widget/info_dialog.dart'; +import 'file_details_sheet.dart'; class FileElement extends StatefulWidget { final CacheableFile file; @@ -26,57 +20,117 @@ class FileElement extends StatefulWidget { final void Function() refetch; const FileElement(this.file, this.path, this.refetch, {super.key}); - static Future download(BuildContext context, String remotePath, String name, Function(double) onProgress, Function(OpenResult) onDone) async { - var paths = await getTemporaryDirectory(); - - var encodedPath = Uri.encodeComponent(remotePath); - encodedPath = encodedPath.replaceAll('%2F', '/'); - - var local = paths.path + Platform.pathSeparator + name; - - var options = DownloaderUtils( - progressCallback: (current, total) { - final progress = (current / total) * 100; - onProgress(progress); - }, - file: File(local), - progress: ProgressImplementation(), - deleteOnCancel: true, - client: Dio(BaseOptions(headers: AccountData().authHeaders())), - onDone: () { - AppRoutes.openFileViewer(context, local); - onDone(OpenResult(message: 'File viewer opened', type: ResultType.done)); - }, - ); - - return await Flowder.download( - '${WebdavApi.buildWebdavUrl()}$encodedPath', - options, - ); - } - @override State createState() => _FileElementState(); } class _FileElementState extends State { - double percent = 0; - Future? downloadCore; + DownloadJob? _job; - Widget getSubtitle() { - if(widget.file.currentlyDownloading) { + @override + void initState() { + super.initState(); + _attachJob(DownloadManager.instance.jobFor(widget.file.path)); + } + + @override + void didUpdateWidget(covariant FileElement oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.file.path != widget.file.path) { + _detachJob(); + _attachJob(DownloadManager.instance.jobFor(widget.file.path)); + } + } + + @override + void dispose() { + _detachJob(); + super.dispose(); + } + + void _attachJob(DownloadJob? job) { + _job = job; + if (job == null) return; + job.status.addListener(_onStatusChange); + if (job.isFinished) { + WidgetsBinding.instance.addPostFrameCallback((_) => _onStatusChange()); + } + } + + void _detachJob() { + _job?.status.removeListener(_onStatusChange); + _job = null; + } + + void _onStatusChange() { + if (!mounted) return; + final job = _job; + if (job == null) return; + final status = job.status.value; + if (status is DownloadDone) { + DownloadManager.instance.clear(widget.file.path); + _detachJob(); + AppRoutes.openFileViewer(context, status.localPath); + setState(() {}); + } else if (status is DownloadFailed) { + final message = status.message; + DownloadManager.instance.clear(widget.file.path); + _detachJob(); + setState(() {}); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Download'), + content: Text(message), + ), + ); + } else if (status is DownloadCancelled) { + DownloadManager.instance.clear(widget.file.path); + _detachJob(); + setState(() {}); + } else { + setState(() {}); + } + } + + Future _startDownload() async { + final job = await DownloadManager.instance.start( + remotePath: widget.file.path, + name: widget.file.name, + ); + if (!mounted) return; + if (_job == job) return; + _detachJob(); + _attachJob(job); + setState(() {}); + } + + void _confirmCancel() { + showDialog( + context: context, + builder: (dialogContext) => ConfirmDialog( + title: 'Download abbrechen?', + content: 'Möchtest du den Download abbrechen?', + cancelButton: 'Nein', + confirmButton: 'Ja, Abbrechen', + onConfirm: () => _job?.cancel(), + ), + ); + } + + Widget _subtitle() { + final status = _job?.status.value; + if (status is DownloadInProgress) { return Row( children: [ Container( margin: const EdgeInsets.only(right: 10), child: const Text('Download:'), ), - Expanded( - child: LinearProgressIndicator(value: percent/100), - ), + Expanded(child: LinearProgressIndicator(value: status.percent / 100)), Container( margin: const EdgeInsets.only(left: 10), - child: Text('${percent.round()}%'), + child: Text('${status.percent.round()}%'), ), ], ); @@ -86,101 +140,173 @@ class _FileElementState extends State { : Text('${filesize(widget.file.size)}, ${Jiffy.parseFromDateTime(widget.file.modifiedAt ?? DateTime.now()).fromNow()}'); } + void _onTap() { + if (widget.file.isDirectory) { + AppRoutes.openFolder(context, widget.path.toList()..add(widget.file.name)); + return; + } + if (EndpointData().getEndpointMode() == EndpointMode.stage) { + InfoDialog.show(context, 'Virtuelle Dateien im Staging Prozess können nicht heruntergeladen werden!'); + return; + } + final status = _job?.status.value; + if (status is DownloadInProgress) { + _confirmCancel(); + return; + } + _startDownload(); + } + + // 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'; + + Future _rename() async { + final controller = TextEditingController(text: widget.file.name); + final newName = await showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: const Text('Umbenennen'), + content: TextField( + controller: controller, + decoration: const InputDecoration(labelText: 'Neuer Name'), + autofocus: true, + ), + actions: [ + TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Abbrechen')), + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(controller.text.trim()), + child: const Text('Umbenennen'), + ), + ], + ), + ); + if (newName == null || newName.isEmpty || newName == widget.file.name) return; + + final parent = _parentPathOf(widget.file.path); + final destination = _joinPath(parent, newName, isDirectory: widget.file.isDirectory); + await _runWebdavOp(() async { + final webdav = await WebdavApi.webdav; + await webdav.move(PathUri.parse(widget.file.path), PathUri.parse(destination)); + }, errorTitle: 'Umbenennen fehlgeschlagen'); + } + + void _putOnClipboard({required bool copy}) { + if (copy) { + FileClipboard.instance.copy([widget.file]); + } else { + FileClipboard.instance.cut([widget.file]); + } + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('"${widget.file.name}" zum ${copy ? "Kopieren" : "Verschieben"} bereitgelegt'), + duration: const Duration(seconds: 2), + )); + } + + Future _delete() async { + await showDialog( + context: context, + builder: (context) => ConfirmDialog( + title: 'Element löschen?', + content: 'Das Element wird unwiederruflich gelöscht.', + confirmButton: 'Löschen', + onConfirmAsync: () async { + final webdav = await WebdavApi.webdav; + await webdav.delete(PathUri.parse(widget.file.path)); + widget.refetch(); + }, + ), + ); + } + + Future _runWebdavOp(Future Function() action, {required String errorTitle}) async { + try { + await action(); + widget.refetch(); + } on Object catch (e) { + if (!mounted) return; + await showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: Text(errorTitle), + content: Text(e.toString()), + actions: [TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('OK'))], + ), + ); + } + } + + void _showActionSheet() { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetCtx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const CenteredLeading(Icon(Icons.info_outline)), + title: const Text('Info'), + onTap: () { + Navigator.of(sheetCtx).pop(); + showFileDetailsSheet(context, widget.file); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.drive_file_rename_outline)), + title: const Text('Umbenennen'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _rename(); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.drive_file_move_outline)), + title: const Text('Verschieben'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _putOnClipboard(copy: false); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.copy_outlined)), + title: const Text('Kopieren'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _putOnClipboard(copy: true); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.delete_outline)), + title: const Text('Löschen'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _delete(); + }, + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) => ListTile( - leading: CenteredLeading( - Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined) - ), - title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis), - subtitle: getSubtitle(), - trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null), - onTap: () { - if(widget.file.isDirectory) { - AppRoutes.openFolder(context, widget.path.toList()..add(widget.file.name)); - } else { - if(EndpointData().getEndpointMode() == EndpointMode.stage) { - InfoDialog.show(context, 'Virtuelle Dateien im Staging Prozess können nicht heruntergeladen werden!'); - return; - } - if(widget.file.currentlyDownloading) { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Download abbrechen?', - content: 'Möchtest du den Download abbrechen?', - cancelButton: 'Nein', - confirmButton: 'Ja, Abbrechen', - onConfirm: () { - downloadCore?.then((value) { - if(!value.isCancelled) value.cancel(); - }); - setState(() { - widget.file.currentlyDownloading = false; - percent = 0; - downloadCore = null; - }); - }, - ), - ); - - return; - } - - setState(() { - widget.file.currentlyDownloading = true; - }); - - downloadCore = FileElement.download(context, widget.file.path, widget.file.name, (progress) { - setState(() => percent = progress); - }, (result) { - if(result.type != ResultType.done) { - showDialog(context: context, builder: (context) => AlertDialog( - title: const Text('Download'), - content: Text(result.message), - )); - } - - setState(() { - widget.file.currentlyDownloading = false; - percent = 0; - }); - }); - - } - }, - onLongPress: () { - showDialog(context: context, builder: (context) => SimpleDialog( - children: [ - ListTile( - leading: const Icon(Icons.delete_outline), - title: const Text('Löschen'), - onTap: () { - Navigator.of(context).pop(); - showDialog(context: context, builder: (context) => ConfirmDialog( - title: 'Element löschen?', - content: 'Das Element wird unwiederruflich gelöscht.', - confirmButton: 'Löschen', - onConfirmAsync: () async { - final webdav = await WebdavApi.webdav; - await webdav.delete(PathUri.parse(widget.file.path)); - widget.refetch(); - }, - )); - }, - ), - Visibility( - visible: !kReleaseMode, - child: ListTile( - leading: const Icon(Icons.share_outlined), - title: const Text('Teilen'), - onTap: () { - Navigator.of(context).pop(); - UnimplementedDialog.show(context); - }, - ), - ), - ], - )); - }, - ); + leading: CenteredLeading( + Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined), + ), + title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis), + subtitle: _subtitle(), + trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null), + onTap: _onTap, + onLongPress: _showActionSheet, + ); } diff --git a/lib/view/pages/grade_averages/grade_averages_list_view.dart b/lib/view/pages/grade_averages/grade_averages_list_view.dart index d4152b2..48c0def 100644 --- a/lib/view/pages/grade_averages/grade_averages_list_view.dart +++ b/lib/view/pages/grade_averages/grade_averages_list_view.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../state/app/modules/gradeAverages/bloc/grade_averages_bloc.dart'; -import '../../../state/app/modules/gradeAverages/bloc/grade_averages_event.dart'; +import '../../../state/app/modules/grade_averages/bloc/grade_averages_bloc.dart'; +import '../../../state/app/modules/grade_averages/bloc/grade_averages_event.dart'; class GradeAveragesListView extends StatelessWidget { const GradeAveragesListView({super.key}); diff --git a/lib/view/pages/grade_averages/grade_averages_view.dart b/lib/view/pages/grade_averages/grade_averages_view.dart index 1a49e0f..828536a 100644 --- a/lib/view/pages/grade_averages/grade_averages_view.dart +++ b/lib/view/pages/grade_averages/grade_averages_view.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../state/app/modules/gradeAverages/bloc/grade_averages_bloc.dart'; -import '../../../state/app/modules/gradeAverages/bloc/grade_averages_event.dart'; -import '../../../state/app/modules/gradeAverages/bloc/grade_averages_state.dart'; +import '../../../state/app/modules/grade_averages/bloc/grade_averages_bloc.dart'; +import '../../../state/app/modules/grade_averages/bloc/grade_averages_event.dart'; +import '../../../state/app/modules/grade_averages/bloc/grade_averages_state.dart'; import '../../../widget/confirm_dialog.dart'; import 'grade_averages_list_view.dart'; diff --git a/lib/view/pages/holidays/holidays_view.dart b/lib/view/pages/holidays/holidays_view.dart index 6e9515c..4fa4fac 100644 --- a/lib/view/pages/holidays/holidays_view.dart +++ b/lib/view/pages/holidays/holidays_view.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; -import '../../../state/app/infrastructure/loadableState/loadable_state.dart'; -import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; -import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; +import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; import '../../../state/app/modules/holidays/bloc/holidays_bloc.dart'; import '../../../state/app/modules/holidays/bloc/holidays_event.dart'; import '../../../state/app/modules/holidays/bloc/holidays_state.dart'; diff --git a/lib/view/pages/marianum_dates/marianum_dates_view.dart b/lib/view/pages/marianum_dates/marianum_dates_view.dart index 081d9e8..74e1a84 100644 --- a/lib/view/pages/marianum_dates/marianum_dates_view.dart +++ b/lib/view/pages/marianum_dates/marianum_dates_view.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; -import '../../../state/app/infrastructure/loadableState/loadable_state.dart'; -import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; -import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; -import '../../../state/app/modules/marianumDates/bloc/marianum_dates_bloc.dart'; -import '../../../state/app/modules/marianumDates/bloc/marianum_dates_event.dart'; -import '../../../state/app/modules/marianumDates/bloc/marianum_dates_state.dart'; +import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; +import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart'; +import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_event.dart'; +import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart'; import '../../../widget/animated_time.dart'; import '../../../widget/centered_leading.dart'; import '../../../widget/debug/debug_tile.dart'; diff --git a/lib/view/pages/marianum_message/marianum_message_list_view.dart b/lib/view/pages/marianum_message/marianum_message_list_view.dart index 7164219..637e160 100644 --- a/lib/view/pages/marianum_message/marianum_message_list_view.dart +++ b/lib/view/pages/marianum_message/marianum_message_list_view.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import '../../../routing/app_routes.dart'; -import '../../../state/app/infrastructure/loadableState/loadable_state.dart'; -import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; -import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; -import '../../../state/app/modules/marianumMessage/bloc/marianum_message_bloc.dart'; -import '../../../state/app/modules/marianumMessage/bloc/marianum_message_state.dart'; +import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; +import '../../../state/app/modules/marianum_message/bloc/marianum_message_bloc.dart'; +import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; class MarianumMessageListView extends StatelessWidget { const MarianumMessageListView({super.key}); diff --git a/lib/view/pages/marianum_message/marianum_message_view.dart b/lib/view/pages/marianum_message/marianum_message_view.dart index f95712a..cb518d8 100644 --- a/lib/view/pages/marianum_message/marianum_message_view.dart +++ b/lib/view/pages/marianum_message/marianum_message_view.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../../../state/app/modules/marianumMessage/bloc/marianum_message_state.dart'; +import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; import '../../../widget/confirm_dialog.dart'; class MessageView extends StatefulWidget { diff --git a/lib/view/pages/more/feedback/feedback_dialog.dart b/lib/view/pages/more/feedback/feedback_dialog.dart index 373883c..42d082d 100644 --- a/lib/view/pages/more/feedback/feedback_dialog.dart +++ b/lib/view/pages/more/feedback/feedback_dialog.dart @@ -1,16 +1,17 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; +import 'package:badges/badges.dart' as badges; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:image_picker/image_picker.dart'; import 'package:loader_overlay/loader_overlay.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:badges/badges.dart' as badges; -import '../../../../api/mhsl/server/feedback/addFeedback.dart'; -import '../../../../api/mhsl/server/feedback/addFeedbackParams.dart'; +import '../../../../api/mhsl/server/feedback/add_feedback.dart'; +import '../../../../api/mhsl/server/feedback/add_feedback_params.dart'; import '../../../../model/account_data.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../widget/file_pick.dart'; @@ -129,7 +130,8 @@ class _FeedbackDialogState extends State { child: IconButton( onPressed: () async { context.loaderOverlay.show(); - var imageData = await (await FilePick.galleryPick())?.readAsBytes(); + final picked = await FilePick.multipleGalleryPick(); + final imageData = await picked?.first.readAsBytes(); if(context.mounted) context.loaderOverlay.hide(); setState(() { _image = imageData; @@ -148,26 +150,26 @@ class _FeedbackDialogState extends State { return; } context.loaderOverlay.show(); - AddFeedback( + unawaited(AddFeedback( AddFeedbackParams( user: AccountData().getUserSecret(), feedback: _feedbackInput.text, screenshot: _image != null ? base64Encode(_image!) : null, appVersion: int.parse((await PackageInfo.fromPlatform()).buildNumber), - ) + ), ).run().then((value) { if (!context.mounted) return; Navigator.of(context).pop(); InfoDialog.show(context, 'Danke für dein Feedback!'); context.loaderOverlay.hide(); - }).catchError((error, trace) { + }).catchError((Object error, StackTrace trace) { if (!mounted) return; setState(() { _error = error.toString(); }); if (!context.mounted) return; context.loaderOverlay.hide(); - }); + })); }, child: const Text('Senden'), ) diff --git a/lib/view/pages/overhang.dart b/lib/view/pages/overhang.dart index f27445f..2489fa4 100644 --- a/lib/view/pages/overhang.dart +++ b/lib/view/pages/overhang.dart @@ -2,18 +2,18 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:in_app_review/in_app_review.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../extensions/render_not_null.dart'; +import 'package:in_app_review/in_app_review.dart'; +import '../../extensions/render_not_null.dart'; import '../../routing/app_routes.dart'; import '../../state/app/modules/app_modules.dart'; import '../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../storage/settings.dart' as model; import '../../widget/centered_leading.dart'; import '../../widget/info_dialog.dart'; -import 'settings/data/default_settings.dart'; import 'more/share/select_share_type_dialog.dart'; +import 'settings/data/default_settings.dart'; class Overhang extends StatefulWidget { const Overhang({super.key}); @@ -50,7 +50,11 @@ class _OverhangState extends State { final settings = context.read(); void changeVisibility(Modules module) { var hidden = settings.val(write: true).modulesSettings.hiddenModules; - hidden.contains(module) ? hidden.remove(module) : (hidden.length < 3 ? hidden.add(module) : null); + if (hidden.contains(module)) { + hidden.remove(module); + } else if (hidden.length < 3) { + hidden.add(module); + } } return ReorderableListView( diff --git a/lib/view/pages/settings/data/default_settings.dart b/lib/view/pages/settings/data/default_settings.dart index 49640a1..63e060a 100644 --- a/lib/view/pages/settings/data/default_settings.dart +++ b/lib/view/pages/settings/data/default_settings.dart @@ -3,16 +3,16 @@ import 'dart:io'; import 'package:flutter/material.dart'; import '../../../../state/app/modules/app_modules.dart'; -import '../../../../storage/settings.dart'; import '../../../../storage/dev_tools_settings.dart'; import '../../../../storage/file_settings.dart'; import '../../../../storage/file_view_settings.dart'; -import '../../../../storage/modules_settings.dart'; import '../../../../storage/holidays_settings.dart'; +import '../../../../storage/modules_settings.dart'; import '../../../../storage/notification_settings.dart'; +import '../../../../storage/settings.dart'; import '../../../../storage/talk_settings.dart'; -import '../../../../view/pages/timetable/data/timetable_name_mode.dart'; import '../../../../storage/timetable_settings.dart'; +import '../../../../view/pages/timetable/data/timetable_name_mode.dart'; import '../../files/files.dart'; class DefaultSettings { diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index 6cc6846..7067c22 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -31,9 +31,10 @@ class AccountSection extends StatelessWidget { await prefs.clear(); PaintingBinding.instance.imageCache.clear(); if (!context.mounted) return; - context.read().reset(); - const CacheView().clear(); - AccountData().removeData(context: context); + await context.read().reset(); + await const CacheView().clear(); + if (!context.mounted) return; + await AccountData().removeData(context: context); }, ), ); diff --git a/lib/view/pages/settings/sections/dev_tools_section.dart b/lib/view/pages/settings/sections/dev_tools_section.dart index 95d8efd..7cada5f 100644 --- a/lib/view/pages/settings/sections/dev_tools_section.dart +++ b/lib/view/pages/settings/sections/dev_tools_section.dart @@ -1,8 +1,8 @@ import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart index 7037624..4a01136 100644 --- a/lib/view/pages/talk/chat_list.dart +++ b/lib/view/pages/talk/chat_list.dart @@ -3,20 +3,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_split_view/flutter_split_view.dart'; -import '../../../routing/app_routes.dart'; -import '../../../state/app/infrastructure/loadableState/loadable_state.dart'; -import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; -import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart'; -import '../../../state/app/modules/chatList/bloc/chat_list_bloc.dart'; -import '../../../state/app/modules/chatList/bloc/chat_list_state.dart'; import '../../../notification/notify_updater.dart'; +import '../../../routing/app_routes.dart'; +import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; +import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../widget/confirm_dialog.dart'; import '../../../widget/placeholder_view.dart'; -import 'widgets/chat_tile.dart'; -import 'widgets/split_view_placeholder.dart'; import 'join_chat.dart'; import 'search_chat.dart'; +import 'widgets/chat_tile.dart'; +import 'widgets/split_view_placeholder.dart'; class ChatList extends StatelessWidget { const ChatList({super.key}); diff --git a/lib/view/pages/talk/chat_view.dart b/lib/view/pages/talk/chat_view.dart index e332150..0b8c78c 100644 --- a/lib/view/pages/talk/chat_view.dart +++ b/lib/view/pages/talk/chat_view.dart @@ -1,19 +1,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../api/marianumcloud/talk/chat/getChatResponse.dart'; -import '../../../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../extensions/date_time.dart'; -import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../state/app/modules/chat/bloc/chat_state.dart'; import '../../../theming/app_theme.dart'; import '../../../widget/clickable_app_bar.dart'; import '../../../widget/user_avatar.dart'; import 'details/chat_info.dart'; +import 'talk_navigator.dart'; import 'widgets/chat_bubble.dart'; import 'widgets/chat_textfield.dart'; -import 'talk_navigator.dart'; class ChatView extends StatefulWidget { final GetRoomResponseObject room; @@ -99,7 +99,7 @@ class _ChatViewState extends State { ), ), ), - body: Container( + body: DecoratedBox( decoration: BoxDecoration( image: DecorationImage( image: const AssetImage('assets/background/chat.png'), @@ -122,7 +122,7 @@ class _ChatViewState extends State { ), ), ), - Container( + ColoredBox( color: Theme.of(context).colorScheme.surface, child: TalkNavigator.isSecondaryVisible(context) ? ChatTextfield(widget.room.token, selfId: widget.selfId) diff --git a/lib/view/pages/talk/data/chat_bubble_styles.dart b/lib/view/pages/talk/data/chat_bubble_styles.dart index bad0822..33db5e7 100644 --- a/lib/view/pages/talk/data/chat_bubble_styles.dart +++ b/lib/view/pages/talk/data/chat_bubble_styles.dart @@ -1,7 +1,7 @@ -import 'package:bubble/bubble.dart'; import 'package:flutter/material.dart'; import '../../../../theming/app_theme.dart'; +import '../widgets/bubble.dart'; extension ColorExtensions on Color { Color invert() { diff --git a/lib/view/pages/talk/data/chat_message.dart b/lib/view/pages/talk/data/chat_message.dart index 358e289..66b5aa1 100644 --- a/lib/view/pages/talk/data/chat_message.dart +++ b/lib/view/pages/talk/data/chat_message.dart @@ -3,8 +3,8 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; -import '../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; -import '../../../../api/marianumcloud/talk/chat/richObjectStringProcessor.dart'; +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; import '../../../../model/account_data.dart'; import '../../../../model/endpoint_data.dart'; import '../../../../utils/url_opener.dart'; diff --git a/lib/view/pages/talk/details/chat_info.dart b/lib/view/pages/talk/details/chat_info.dart index 74afa79..820ee1a 100644 --- a/lib/view/pages/talk/details/chat_info.dart +++ b/lib/view/pages/talk/details/chat_info.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import '../../../../api/marianumcloud/talk/getParticipants/getParticipantsCache.dart'; -import '../../../../api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart'; -import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../../../../api/marianumcloud/talk/get_participants/get_participants_cache.dart'; +import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart'; +import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../widget/large_profile_picture_view.dart'; import '../../../../widget/loading_spinner.dart'; import '../../../../widget/user_avatar.dart'; diff --git a/lib/view/pages/talk/details/message_reactions.dart b/lib/view/pages/talk/details/message_reactions.dart index 29158f3..7f87faf 100644 --- a/lib/view/pages/talk/details/message_reactions.dart +++ b/lib/view/pages/talk/details/message_reactions.dart @@ -1,8 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import '../../../../api/marianumcloud/talk/getReactions/getReactions.dart'; -import '../../../../api/marianumcloud/talk/getReactions/getReactionsResponse.dart'; +import '../../../../api/marianumcloud/talk/get_reactions/get_reactions.dart'; +import '../../../../api/marianumcloud/talk/get_reactions/get_reactions_response.dart'; import '../../../../model/account_data.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/loading_spinner.dart'; diff --git a/lib/view/pages/talk/details/participants_list_view.dart b/lib/view/pages/talk/details/participants_list_view.dart index e58d1e5..e2ec3d7 100644 --- a/lib/view/pages/talk/details/participants_list_view.dart +++ b/lib/view/pages/talk/details/participants_list_view.dart @@ -1,7 +1,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import '../../../../api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart'; +import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart'; import '../../../../widget/user_avatar.dart'; class ParticipantsListView extends StatelessWidget { @@ -10,7 +10,7 @@ class ParticipantsListView extends StatelessWidget { @override Widget build(BuildContext context) { - lastname(participant) => participant.displayName.toString().split(' ').last; + String lastname(participant) => participant.displayName.toString().split(' ').last; final participants = participantsResponse.data .sorted((a, b) { diff --git a/lib/view/pages/talk/join_chat.dart b/lib/view/pages/talk/join_chat.dart index f937bc3..56e99bb 100644 --- a/lib/view/pages/talk/join_chat.dart +++ b/lib/view/pages/talk/join_chat.dart @@ -3,8 +3,8 @@ import 'package:async/async.dart'; import 'package:flutter/material.dart'; import '../../../api/errors/error_mapper.dart'; -import '../../../api/marianumcloud/autocomplete/autocompleteApi.dart'; -import '../../../api/marianumcloud/autocomplete/autocompleteResponse.dart'; +import '../../../api/marianumcloud/autocomplete/autocomplete_api.dart'; +import '../../../api/marianumcloud/autocomplete/autocomplete_response.dart'; import '../../../model/endpoint_data.dart'; import '../../../widget/app_progress_indicator.dart'; import '../../../widget/placeholder_view.dart'; diff --git a/lib/view/pages/talk/search_chat.dart b/lib/view/pages/talk/search_chat.dart index 68976fc..0a58622 100644 --- a/lib/view/pages/talk/search_chat.dart +++ b/lib/view/pages/talk/search_chat.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; -import '../../../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../../../api/marianumcloud/talk/room/get_room_response.dart'; import 'widgets/chat_tile.dart'; -class SearchChat extends SearchDelegate { +class SearchChat extends SearchDelegate { List chats; SearchChat(this.chats); diff --git a/lib/view/pages/talk/widgets/answer_reference.dart b/lib/view/pages/talk/widgets/answer_reference.dart index 825ad18..8171d14 100644 --- a/lib/view/pages/talk/widgets/answer_reference.dart +++ b/lib/view/pages/talk/widgets/answer_reference.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import '../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; -import '../../../../api/marianumcloud/talk/chat/richObjectStringProcessor.dart'; +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; import '../data/chat_bubble_styles.dart'; class AnswerReference extends StatelessWidget { diff --git a/lib/view/pages/talk/widgets/bubble.dart b/lib/view/pages/talk/widgets/bubble.dart new file mode 100644 index 0000000..408564d --- /dev/null +++ b/lib/view/pages/talk/widgets/bubble.dart @@ -0,0 +1,87 @@ +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.elevation = 0, + this.margin = const BubbleEdges.only(), + this.padding = const BubbleEdges.all(8), + this.alignment = Alignment.centerLeft, + this.nip = BubbleNip.none, + this.borderRadius = 12, + }); + + final Color? color; + final double borderWidth; + final double elevation; + final BubbleEdges margin; + final BubbleEdges padding; + final Alignment alignment; + final BubbleNip nip; + final double borderRadius; +} + +/// Lightweight chat bubble. Replaces the abandoned `bubble` package: renders a +/// rounded container with optional shadow / border. The nip is conveyed by +/// flattening one corner so the bubble visually anchors to the speaker side. +class Bubble extends StatelessWidget { + const Bubble({required this.child, required this.style, super.key}); + + final Widget child; + final BubbleStyle style; + + BorderRadius _radius() { + final r = Radius.circular(style.borderRadius); + final flat = Radius.zero; + switch (style.nip) { + case BubbleNip.leftTop: + return BorderRadius.only(topLeft: flat, topRight: r, bottomLeft: r, bottomRight: r); + case BubbleNip.rightBottom: + return BorderRadius.only(topLeft: r, topRight: r, bottomLeft: r, bottomRight: flat); + case BubbleNip.none: + return BorderRadius.all(r); + } + } + + @override + Widget build(BuildContext context) { + final radius = _radius(); + return Align( + alignment: style.alignment, + child: Container( + margin: style.margin.toEdgeInsets(), + decoration: BoxDecoration( + color: style.color, + borderRadius: radius, + border: style.borderWidth > 0 + ? Border.all(color: Theme.of(context).dividerColor, width: style.borderWidth) + : null, + boxShadow: style.elevation > 0 + ? [BoxShadow(color: Colors.black26, blurRadius: style.elevation * 2, offset: Offset(0, style.elevation))] + : null, + ), + padding: style.padding.toEdgeInsets(), + child: child, + ), + ); + } +} diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index ede8081..1ea1dab 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -1,25 +1,24 @@ -import 'package:bubble/bubble.dart'; -import 'package:flowder/flowder.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:jiffy/jiffy.dart'; -import 'package:open_filex/open_filex.dart'; -import '../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; -import '../../../../api/marianumcloud/talk/deleteReactMessage/deleteReactMessage.dart'; -import '../../../../api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.dart'; -import '../../../../api/marianumcloud/talk/getPoll/getPollState.dart'; -import '../../../../api/marianumcloud/talk/reactMessage/reactMessage.dart'; -import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart'; -import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message.dart'; +import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart'; +import '../../../../api/marianumcloud/talk/get_poll/get_poll_state.dart'; +import '../../../../api/marianumcloud/talk/react_message/react_message.dart'; +import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart'; +import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../extensions/text.dart'; +import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../../../../utils/download_manager.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/loading_spinner.dart'; -import '../../files/widgets/file_element.dart'; import '../data/chat_bubble_styles.dart'; import '../data/chat_message.dart'; import 'answer_reference.dart'; +import 'bubble.dart'; import 'chat_message_options_dialog.dart'; import 'poll_options_list.dart'; @@ -53,12 +52,95 @@ class ChatBubble extends StatefulWidget { class _ChatBubbleState extends State with SingleTickerProviderStateMixin { late ChatMessage message; - double downloadProgress = 0; - Future? downloadCore; + DownloadJob? _job; late Offset _position = const Offset(0, 0); late Offset _dragStartPosition = Offset.zero; + @override + void initState() { + super.initState(); + final filePath = widget.bubbleData.messageParameters?['file']?.path; + if (filePath != null) _attachJob(DownloadManager.instance.jobFor(filePath)); + } + + @override + void dispose() { + _detachJob(); + super.dispose(); + } + + void _attachJob(DownloadJob? job) { + _job = job; + if (job == null) return; + job.status.addListener(_onStatusChange); + if (job.isFinished) { + WidgetsBinding.instance.addPostFrameCallback((_) => _onStatusChange()); + } + } + + void _detachJob() { + _job?.status.removeListener(_onStatusChange); + _job = null; + } + + void _onStatusChange() { + if (!mounted) return; + final job = _job; + if (job == null) return; + final status = job.status.value; + if (status is DownloadDone) { + DownloadManager.instance.clear(job.remotePath); + _detachJob(); + AppRoutes.openFileViewer(context, status.localPath); + setState(() {}); + } else if (status is DownloadFailed) { + final message = status.message; + DownloadManager.instance.clear(job.remotePath); + _detachJob(); + setState(() {}); + showDialog(context: context, builder: (context) => AlertDialog(content: Text(message))); + } else if (status is DownloadCancelled) { + DownloadManager.instance.clear(job.remotePath); + _detachJob(); + setState(() {}); + } else { + setState(() {}); + } + } + + Future _startFileDownload() async { + final file = message.file; + final filePath = file?.path; + if (file == null || filePath == null) return; + final job = await DownloadManager.instance.start(remotePath: filePath, name: file.name); + if (!mounted) return; + if (_job == job) return; + _detachJob(); + _attachJob(job); + setState(() {}); + } + + void _confirmCancel() { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Download abbrechen?'), + content: const Text('Möchtest du den Download abbrechen?'), + actions: [ + TextButton(onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Nein')), + TextButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + _job?.cancel(); + }, + child: const Text('Ja, Abbrechen'), + ), + ], + ), + ); + } + BubbleStyle getStyle() { var styles = ChatBubbleStyles(context); if(widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment) { @@ -162,53 +244,12 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM )); } - if(message.file == null) return; - - if(downloadProgress > 0) { - showDialog(context: context, builder: (context) => AlertDialog( - title: const Text('Download abbrechen?'), - content: const Text('Möchtest du den Download abbrechen?'), - actions: [ - TextButton(onPressed: () { - Navigator.of(context).pop(); - }, child: const Text('Nein')), - TextButton(onPressed: () { - downloadCore?.then((value) { - if(!value.isCancelled) value.cancel(); - if (!context.mounted) return; - Navigator.of(context).pop(); - }); - setState(() { - downloadProgress = 0; - downloadCore = null; - }); - }, child: const Text('Ja, Abbrechen')) - ], - )); - - return; + if (message.file == null) return; + if (_job?.status.value is DownloadInProgress) { + _confirmCancel(); + } else { + _startFileDownload(); } - - setState(() { - downloadProgress = 1; - }); - downloadCore = FileElement.download(context, message.file!.path!, message.file!.name, (progress) { - if(progress > 1) { - setState(() { - downloadProgress = progress; - }); - } - }, (result) { - setState(() { - downloadProgress = 0; - }); - - if(result.type != ResultType.done) { - showDialog(context: context, builder: (context) => AlertDialog( - content: Text(result.message), - )); - } - }); }, child: Transform.translate( offset: _position, @@ -270,15 +311,18 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM ) ), ), - Visibility( - visible: downloadProgress > 0, - child: Positioned( + if (_job?.status.value is DownloadInProgress) + Positioned( bottom: 0, right: 0, left: 0, - child: LinearProgressIndicator(value: downloadProgress == 1 ? null : downloadProgress/100), + child: LinearProgressIndicator( + value: () { + final s = _job!.status.value as DownloadInProgress; + return s.percent <= 0 ? null : s.percent / 100; + }(), + ), ), - ), ], ), ), 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 7f7fa39..f5c5802 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -4,11 +4,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; import '../../../../api/marianumcloud/talk/actions/talk_actions.dart'; -import '../../../../api/marianumcloud/talk/reactMessage/reactMessage.dart'; -import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart'; -import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../api/marianumcloud/talk/react_message/react_message.dart'; +import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart'; +import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../widget/app_progress_indicator.dart'; diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index 266958d..a821cf1 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -5,11 +6,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nextcloud/nextcloud.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import '../../../../api/marianumcloud/files-sharing/fileSharingApi.dart'; -import '../../../../api/marianumcloud/files-sharing/fileSharingApiParams.dart'; -import '../../../../api/marianumcloud/talk/sendMessage/sendMessage.dart'; -import '../../../../api/marianumcloud/talk/sendMessage/sendMessageParams.dart'; -import '../../../../api/marianumcloud/webdav/webdavApi.dart'; +import '../../../../api/marianumcloud/files_sharing/file_sharing_api.dart'; +import '../../../../api/marianumcloud/files_sharing/file_sharing_api_params.dart'; +import '../../../../api/marianumcloud/talk/send_message/send_message.dart'; +import '../../../../api/marianumcloud/talk/send_message/send_message_params.dart'; +import '../../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../widget/async_action_button.dart'; @@ -51,10 +52,10 @@ class _ChatTextfieldState extends State { if (paths == null) return; const shareFolder = 'MarianumMobile'; - WebdavApi.webdav.then((webdav) => webdav.mkcol(PathUri.parse('/$shareFolder'))); + unawaited(WebdavApi.webdav.then((webdav) => webdav.mkcol(PathUri.parse('/$shareFolder')))); if (!mounted) return; - pushScreen( + unawaited(pushScreen( context, withNavBar: false, screen: FilesUploadDialog( @@ -63,7 +64,7 @@ class _ChatTextfieldState extends State { onUploadFinished: (uploaded) => share(shareFolder, uploaded), uniqueNames: true, ), - ); + )); } void _setDraft(String text) { diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index b4f9c8d..0f44672 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -5,13 +5,13 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:jiffy/jiffy.dart'; import '../../../../api/marianumcloud/talk/actions/talk_actions.dart'; -import '../../../../api/marianumcloud/talk/chat/richObjectStringProcessor.dart'; -import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; -import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarker.dart'; -import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dart'; +import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.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 '../../../../model/account_data.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; -import '../../../../state/app/modules/chatList/bloc/chat_list_bloc.dart'; +import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/debug/debug_tile.dart'; diff --git a/lib/view/pages/talk/widgets/poll_options_list.dart b/lib/view/pages/talk/widgets/poll_options_list.dart index 4bade65..44f4162 100644 --- a/lib/view/pages/talk/widgets/poll_options_list.dart +++ b/lib/view/pages/talk/widgets/poll_options_list.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; -import '../../../../api/marianumcloud/talk/getPoll/getPollStateResponse.dart'; +import '../../../../api/marianumcloud/talk/get_poll/get_poll_state_response.dart'; import '../../../../utils/url_opener.dart'; class PollOptionsList extends StatefulWidget { 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 34479ca..caa6e62 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 @@ -6,7 +6,7 @@ import 'package:jiffy/jiffy.dart'; import 'package:rrule_generator/rrule_generator.dart'; import 'package:time_range_picker/time_range_picker.dart'; -import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../extensions/date_time.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../widget/focus_behaviour.dart'; @@ -151,6 +151,7 @@ class _CustomEventEditDialogState extends State { selectedColor: Theme.of(context).primaryColor, ticks: 24, ); + if (range is! TimeRange) return; setState(() { _startTime = range.startTime; _endTime = range.endTime; diff --git a/lib/view/pages/timetable/custom_events/custom_events_view.dart b/lib/view/pages/timetable/custom_events/custom_events_view.dart index a1c6595..a7c4272 100644 --- a/lib/view/pages/timetable/custom_events/custom_events_view.dart +++ b/lib/view/pages/timetable/custom_events/custom_events_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; -import '../../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; +import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../../widget/centered_leading.dart'; diff --git a/lib/view/pages/timetable/data/arbitrary_appointment.dart b/lib/view/pages/timetable/data/arbitrary_appointment.dart index d9bb91a..6d2320a 100644 --- a/lib/view/pages/timetable/data/arbitrary_appointment.dart +++ b/lib/view/pages/timetable/data/arbitrary_appointment.dart @@ -1,5 +1,5 @@ -import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; -import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; sealed class ArbitraryAppointment { const ArbitraryAppointment(); diff --git a/lib/view/pages/timetable/data/lesson_period_schedule.dart b/lib/view/pages/timetable/data/lesson_period_schedule.dart index b163226..01b3f47 100644 --- a/lib/view/pages/timetable/data/lesson_period_schedule.dart +++ b/lib/view/pages/timetable/data/lesson_period_schedule.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart'; +import '../../../../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; class LessonPeriod { diff --git a/lib/view/pages/timetable/data/lesson_status.dart b/lib/view/pages/timetable/data/lesson_status.dart index 933f3cb..39eeb37 100644 --- a/lib/view/pages/timetable/data/lesson_status.dart +++ b/lib/view/pages/timetable/data/lesson_status.dart @@ -1,4 +1,4 @@ -import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; enum LessonStatus { cancelled, diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index 3d14aa8..29cebf2 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -1,16 +1,16 @@ import 'package:collection/collection.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; -import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; -import '../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; -import '../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; -import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart'; +import '../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart'; +import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; import '../../../../storage/timetable_settings.dart'; -import 'timetable_name_mode.dart'; import '../custom_events/custom_event_colors.dart'; import 'arbitrary_appointment.dart'; import 'lesson_color.dart'; import 'lesson_status.dart'; +import 'timetable_name_mode.dart'; import 'webuntis_time.dart'; class TimetableAppointmentFactory { diff --git a/lib/view/pages/timetable/details/_bottom_sheet.dart b/lib/view/pages/timetable/details/_bottom_sheet.dart deleted file mode 100644 index c50f3f0..0000000 --- a/lib/view/pages/timetable/details/_bottom_sheet.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:bottom_sheet/bottom_sheet.dart'; -import 'package:flutter/material.dart'; - -void showAppointmentBottomSheet( - BuildContext context, { - required Widget Function(BuildContext context) header, - required SliverChildListDelegate Function(BuildContext context) body, -}) { - showStickyFlexibleBottomSheet( - minHeight: 0, - initHeight: 0.4, - maxHeight: 0.7, - anchors: [0, 0.4, 0.7], - isSafeArea: true, - maxHeaderHeight: 100, - context: context, - headerBuilder: (context, _) => header(context), - bodyBuilder: (context, _) => body(context), - ); -} diff --git a/lib/view/pages/timetable/details/bottom_sheet.dart b/lib/view/pages/timetable/details/bottom_sheet.dart new file mode 100644 index 0000000..d834a09 --- /dev/null +++ b/lib/view/pages/timetable/details/bottom_sheet.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +void showAppointmentBottomSheet( + BuildContext context, { + required Widget Function(BuildContext context) header, + required SliverChildListDelegate Function(BuildContext context) body, +}) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (sheetContext) => DraggableScrollableSheet( + expand: false, + initialChildSize: 0.4, + minChildSize: 0.2, + maxChildSize: 0.7, + snap: true, + snapSizes: const [0.4], + builder: (_, scrollController) => CustomScrollView( + controller: scrollController, + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: _StickyHeader(child: header(sheetContext)), + ), + SliverList(delegate: body(sheetContext)), + ], + ), + ), + ); +} + +class _StickyHeader extends SliverPersistentHeaderDelegate { + _StickyHeader({required this.child}); + final Widget child; + + @override + double get minExtent => 100; + @override + double get maxExtent => 100; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => Material( + color: Theme.of(context).colorScheme.surface, + child: SizedBox.expand(child: child), + ); + + @override + bool shouldRebuild(covariant _StickyHeader oldDelegate) => oldDelegate.child != child; +} diff --git a/lib/view/pages/timetable/details/custom_event_sheet.dart b/lib/view/pages/timetable/details/custom_event_sheet.dart index 3675abf..ce52d8c 100644 --- a/lib/view/pages/timetable/details/custom_event_sheet.dart +++ b/lib/view/pages/timetable/details/custom_event_sheet.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; import 'package:rrule/rrule.dart'; -import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/debug/debug_tile.dart'; import '../custom_events/custom_event_edit_dialog.dart'; -import '_bottom_sheet.dart'; +import 'bottom_sheet.dart'; import 'delete_custom_event.dart'; class CustomEventSheet { diff --git a/lib/view/pages/timetable/details/delete_custom_event.dart b/lib/view/pages/timetable/details/delete_custom_event.dart index 7d29e86..7361a70 100644 --- a/lib/view/pages/timetable/details/delete_custom_event.dart +++ b/lib/view/pages/timetable/details/delete_custom_event.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; +import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../widget/confirm_dialog.dart'; diff --git a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart index 7adfee6..13885c9 100644 --- a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart +++ b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart @@ -3,15 +3,15 @@ import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; -import '../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; -import '../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; -import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; +import '../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart'; +import '../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart'; +import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/unimplemented_dialog.dart'; -import '_bottom_sheet.dart'; +import 'bottom_sheet.dart'; class WebuntisLessonSheet { static void show(BuildContext context, TimetableBloc bloc, Appointment appointment, GetTimetableResponseObject lesson) { diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index ee4ed3a..cd119e6 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -4,10 +4,10 @@ import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../extensions/date_time.dart'; import '../../../routing/app_routes.dart'; -import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +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/settings/bloc/settings_cubit.dart'; import '../../../storage/timetable_settings.dart'; import 'custom_events/custom_event_edit_dialog.dart'; import 'data/arbitrary_appointment.dart'; diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart index e34a5d5..d185f5c 100644 --- a/lib/view/pages/timetable/widgets/appointment_tile.dart +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -56,7 +56,7 @@ class AppointmentTile extends StatelessWidget { ), if (crossedOut) Positioned.fill( - child: Container( + child: DecoratedBox( decoration: BoxDecoration( border: Border.all(width: 2, color: Colors.red.withAlpha(200)), borderRadius: const BorderRadius.all(Radius.circular(7)), diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 18ed146..85ce55e 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -392,7 +392,7 @@ class _PeriodLabel extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { final showTimes = constraints.maxHeight >= 38; - return Container( + return DecoratedBox( decoration: BoxDecoration( border: Border(top: BorderSide(color: dividerColor, width: 0.5)), ), @@ -561,7 +561,7 @@ class _DayColumn extends StatelessWidget { return GestureDetector( behavior: HitTestBehavior.translucent, onLongPressStart: (details) => _handleLongPress(details, dayAppointments), - child: Container( + child: DecoratedBox( decoration: BoxDecoration( color: isToday ? theme.colorScheme.primary.withAlpha(14) : null, border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)), diff --git a/lib/view/pages/timetable/widgets/special_regions_builder.dart b/lib/view/pages/timetable/widgets/special_regions_builder.dart index 2007f46..bffdfb7 100644 --- a/lib/view/pages/timetable/widgets/special_regions_builder.dart +++ b/lib/view/pages/timetable/widgets/special_regions_builder.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; -import '../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; +import '../../../../api/webuntis/queries/get_holidays/get_holidays_response.dart'; import '../../../../extensions/date_time.dart'; import '../data/calendar_layout.dart'; import '../data/lesson_period_schedule.dart'; diff --git a/lib/widget/animated_time.dart b/lib/widget/animated_time.dart index ea9991e..2c00b18 100644 --- a/lib/widget/animated_time.dart +++ b/lib/widget/animated_time.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:animated_digit/animated_digit.dart'; import 'package:flutter/material.dart'; class AnimatedTime extends StatefulWidget { @@ -42,14 +41,18 @@ class _AnimatedTimeState extends State { ], ); - AnimatedDigitWidget buildWidget(int value) => AnimatedDigitWidget( - value: value, - duration: const Duration(milliseconds: 100), - textStyle: TextStyle( - fontSize: 15, - color: Theme.of(context).colorScheme.onSurface, - ), - ); + Widget buildWidget(int value) => AnimatedSwitcher( + duration: const Duration(milliseconds: 100), + transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child), + child: Text( + '$value', + key: ValueKey(value), + style: TextStyle( + fontSize: 15, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ); @override void dispose() { diff --git a/lib/widget/breaker/breaker.dart b/lib/widget/breaker/breaker.dart index 9c9186e..006844e 100644 --- a/lib/widget/breaker/breaker.dart +++ b/lib/widget/breaker/breaker.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; +import '../../api/mhsl/breaker/get_breakers/get_breakers_response.dart'; import '../../state/app/modules/breaker/bloc/breaker_bloc.dart'; import '../../widget/placeholder_view.dart'; diff --git a/lib/widget/debug/cache_view.dart b/lib/widget/debug/cache_view.dart index c92132b..beb46ce 100644 --- a/lib/widget/debug/cache_view.dart +++ b/lib/widget/debug/cache_view.dart @@ -7,7 +7,7 @@ import 'package:jiffy/jiffy.dart'; import 'package:localstore/localstore.dart'; import '../../../widget/placeholder_view.dart'; -import '../../api/requestCache.dart'; +import '../../api/request_cache.dart'; import 'json_viewer.dart'; class CacheView extends StatefulWidget { @@ -21,9 +21,9 @@ class CacheView extends StatefulWidget { } Future totalSize() async { - var data = await Localstore.instance.collection(RequestCache.collection).get(); - if(data!.length <= 1) return jsonEncode(data.values.first).length * 8; - return data.values.reduce((a, b) => jsonEncode(a).length + jsonEncode(b).length) * 8; + final data = await Localstore.instance.collection(RequestCache.collection).get(); + if (data == null || data.isEmpty) return 0; + return data.values.fold(0, (sum, value) => sum + jsonEncode(value).length) * 8; } } @@ -49,15 +49,16 @@ class _CacheViewState extends State { return ListView.builder( itemCount: snapshot.data!.length, itemBuilder: (context, index) { - Map element = snapshot.data![snapshot.data!.keys.elementAt(index)]; - var filename = snapshot.data!.keys.elementAt(index).split('/').last; + final key = snapshot.data!.keys.elementAt(index); + final element = snapshot.data![key] as Map; + final filename = key.split('/').last; return ListTile( leading: const Icon(Icons.text_snippet_outlined), title: Text(filename), - subtitle: Text("${filesize(jsonEncode(element).length * 8)}, ${Jiffy.parseFromMillisecondsSinceEpoch(element['lastupdate']).fromNow()}"), + subtitle: Text('${filesize(jsonEncode(element).length * 8)}, ${Jiffy.parseFromMillisecondsSinceEpoch(element['lastupdate'] as int).fromNow()}'), trailing: const Icon(Icons.arrow_right), - onTap: () => JsonViewer.asDialog(context, jsonDecode(element['json'])), + onTap: () => JsonViewer.asDialog(context, jsonDecode(element['json'] as String) as Map), ); }, ); diff --git a/lib/widget/debug/json_viewer.dart b/lib/widget/debug/json_viewer.dart index 389ddc5..71f85ee 100644 --- a/lib/widget/debug/json_viewer.dart +++ b/lib/widget/debug/json_viewer.dart @@ -1,6 +1,7 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:pretty_json/pretty_json.dart'; class JsonViewer extends StatelessWidget { final String title; @@ -19,7 +20,9 @@ class JsonViewer extends StatelessWidget { ), ); - static String format(Map jsonInput) => prettyJson(jsonInput, indent: 2); + static final _encoder = const JsonEncoder.withIndent(' '); + + static String format(Map jsonInput) => _encoder.convert(jsonInput); static void asDialog(BuildContext context, Map dataMap) { showDialog(context: context, builder: (context) => AlertDialog( diff --git a/lib/widget/file_pick.dart b/lib/widget/file_pick.dart index b9d7dbb..0e678f3 100644 --- a/lib/widget/file_pick.dart +++ b/lib/widget/file_pick.dart @@ -1,29 +1,19 @@ - import 'package:file_picker/file_picker.dart'; import 'package:image_picker/image_picker.dart'; class FilePick { static final _picker = ImagePicker(); - static Future galleryPick() async { - final pickedImage = await _picker.pickImage(source: ImageSource.gallery); - if (pickedImage != null) { - return pickedImage; - } - return null; - } - static Future?> multipleGalleryPick() async { final pickedImages = await _picker.pickMultiImage(); - if(pickedImages.isNotEmpty) { - return pickedImages; - } - return null; + return pickedImages.isNotEmpty ? pickedImages : null; } + static Future cameraPick() => _picker.pickImage(source: ImageSource.camera); + static Future?> documentPick() async { - var result = await FilePicker.platform.pickFiles(allowMultiple: true); - var paths = result?.files.nonNulls.map((e) => e.path).toList(); + final result = await FilePicker.pickFiles(allowMultiple: true); + final paths = result?.files.nonNulls.map((e) => e.path).toList(); return paths?.nonNulls.toList(); } } diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index 88bb8f8..06ce484 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -1,16 +1,17 @@ +import 'dart:async'; import 'dart:io'; import 'dart:math'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:open_filex/open_filex.dart'; import 'package:photo_view/photo_view.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:share_plus/share_plus.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import '../routing/app_routes.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart'; -import '../utils/file_saver.dart'; import 'info_dialog.dart'; import 'placeholder_view.dart'; import 'share_position_origin.dart'; @@ -30,6 +31,55 @@ enum FileViewingActions { save } +/// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal +/// LayoutBuilder calls `localToGlobal` during build, which asserts when an +/// ancestor RenderTransform (from the page-push animation) is still mid-layout. +/// We wait for the route's enter animation to complete before mounting it. +class _DeferredPdfViewer extends StatefulWidget { + const _DeferredPdfViewer({required this.path}); + final String path; + + @override + State<_DeferredPdfViewer> createState() => _DeferredPdfViewerState(); +} + +class _DeferredPdfViewerState extends State<_DeferredPdfViewer> { + bool _ready = false; + Animation? _routeAnimation; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_ready || _routeAnimation != null) return; + final animation = ModalRoute.of(context)?.animation; + if (animation == null || animation.isCompleted) { + _ready = true; + return; + } + _routeAnimation = animation..addStatusListener(_onAnimationStatus); + } + + void _onAnimationStatus(AnimationStatus status) { + if (status == AnimationStatus.completed && mounted) { + setState(() => _ready = true); + } + } + + @override + void dispose() { + _routeAnimation?.removeStatusListener(_onAnimationStatus); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_ready) { + return const Center(child: CircularProgressIndicator()); + } + return SfPdfViewer.file(File(widget.path)); + } +} + class _FileViewerState extends State { PhotoViewController photoViewController = PhotoViewController(); @@ -44,7 +94,7 @@ class _FileViewerState extends State { @override Widget build(BuildContext context) { - AppBar appbar({List actions = const []}) => AppBar( + AppBar appbar({List actions = const []}) => AppBar( title: Text(widget.path.split('/').last), actions: [ ...actions, @@ -55,17 +105,26 @@ class _FileViewerState extends State { AppRoutes.openFileViewer(context, widget.path, openExternal: true); break; case FileViewingActions.share: - SharePlus.instance.share( - ShareParams( - files: [XFile(widget.path)], - sharePositionOrigin: SharePositionOrigin.get(context) - ) - ); + unawaited(SharePlus.instance.share( + ShareParams( + files: [XFile(widget.path)], + sharePositionOrigin: SharePositionOrigin.get(context), + ), + )); break; case FileViewingActions.save: - await FileSaver.writeBytes(await File(widget.path).readAsBytes(), widget.path.split('/').last); - if(!context.mounted) return; - InfoDialog.show(context, 'Die Datei wurde im Downloads Ordner gespeichert.'); + try { + final bytes = await File(widget.path).readAsBytes(); + final saved = await FilePicker.saveFile( + fileName: widget.path.split('/').last, + bytes: bytes, + ); + if (!context.mounted) return; + if (saved != null) InfoDialog.show(context, 'Datei gespeichert.'); + } on Object catch (e) { + if (!context.mounted) return; + InfoDialog.show(context, 'Speichern fehlgeschlagen: $e'); + } break; } }, @@ -86,7 +145,7 @@ class _FileViewerState extends State { dense: true, ), ), - if(Platform.isAndroid) const PopupMenuItem( + const PopupMenuItem( value: FileViewingActions.save, child: ListTile( leading: Icon(Icons.save_alt_outlined), @@ -129,9 +188,7 @@ class _FileViewerState extends State { case 'pdf': return Scaffold( appBar: appbar(), - body: SfPdfViewer.file( - File(widget.path), - ), + body: _DeferredPdfViewer(path: widget.path), ); default: diff --git a/pubspec.yaml b/pubspec.yaml index 776b411..1798def 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,7 @@ version: 0.1.7+46 environment: sdk: ">=3.8.0 <4.0.0" +# nextcloud (custom Git fork) pins intl ^0.18.0; Flutter 3.41 needs ^0.20.2. dependency_overrides: intl: 0.20.2 @@ -17,33 +18,22 @@ dependencies: flutter_localizations: sdk: flutter - animated_digit: ^3.2.3 async: ^2.11.0 badges: ^3.1.2 - bloc: ^9.0.0 - bottom_sheet: ^4.0.4 - bubble: ^1.2.1 cached_network_image: ^3.4.1 collection: ^1.19.0 connectivity_plus: ^7.1.0 crypto: ^3.0.6 - cupertino_icons: ^1.0.8 device_info_plus: ^12.4.0 - dio: ^4.0.6 - easy_debounce: ^2.0.3 + dio: ^5.9.2 emoji_picker_flutter: ^4.3.0 - fast_rsa: ^3.7.1 - file_picker: ^10.3.2 + file_picker: ^11.0.2 filesize: ^2.0.1 firebase_core: ^4.1.0 - firebase_in_app_messaging: ^0.9.0+1 firebase_messaging: ^16.0.1 - flowder: - git: - url: https://github.com/Harsh223/flowder.git flutter_app_badge: ^2.0.2 flutter_bloc: ^9.0.0 - flutter_secure_storage: ^9.2.4 + flutter_secure_storage: ^10.0.0 intl: ^0.20.2 flutter_linkify: ^6.0.0 flutter_local_notifications: ^21.0.0 @@ -68,22 +58,18 @@ dependencies: open_filex: ^4.7.0 package_info_plus: ^9.0.1 path_provider: ^2.1.5 - permission_handler: ^12.0.1 persistent_bottom_nav_bar_v2: ^6.1.0 photo_view: ^0.15.0 - pretty_json: ^2.0.0 - provider: ^6.1.2 qr_flutter: ^4.1.0 rrule: ^0.2.17 rrule_generator: ^0.9.0 screen_brightness: ^2.1.7 share_plus: ^12.0.2 shared_preferences: ^2.3.5 - syncfusion_flutter_calendar: ^33.1.46 - syncfusion_flutter_pdfviewer: ^33.1.46 + syncfusion_flutter_calendar: ^33.2.5 + syncfusion_flutter_pdfviewer: ^33.2.5 time_range_picker: ^2.3.0 url_launcher: ^6.3.1 - uuid: ^4.5.1 enough_icalendar: ^0.17.0 dev_dependencies: From 50d2941e52df484cceec7f04b2907d9aea53e7d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 6 May 2026 16:27:45 +0200 Subject: [PATCH 12/23] refactored lesson details, centralized logout logic, and added resume re-fetch --- assets/img/raumplan.jpg | Bin 103309 -> 0 bytes assets/img/raumplan.png | Bin 0 -> 82938 bytes lib/app.dart | 5 + lib/main.dart | 62 ++++- .../bloc/loadable_state_bloc.dart | 26 +- .../loadable_hydrated_bloc.dart | 17 ++ .../loadable_hydrated_bloc_event.dart | 1 + lib/view/pages/more/roomplan/roomplan.dart | 2 +- .../settings/sections/account_section.dart | 21 +- .../pages/talk/data/chat_bubble_styles.dart | 3 - .../details/webuntis_lesson_sheet.dart | 240 ++++++++++++++---- 11 files changed, 309 insertions(+), 68 deletions(-) delete mode 100644 assets/img/raumplan.jpg create mode 100644 assets/img/raumplan.png diff --git a/assets/img/raumplan.jpg b/assets/img/raumplan.jpg deleted file mode 100644 index 4bdf0e06df855ae8100ef9721bca23560451e36e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 103309 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⁡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 diff --git a/assets/img/raumplan.png b/assets/img/raumplan.png new file mode 100644 index 0000000000000000000000000000000000000000..49938886b4769eaa2093734e7e0be14618772d60 GIT binary patch literal 82938 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 literal 0 HcmV?d00001 diff --git a/lib/app.dart b/lib/app.dart index 5ac8709..9f549c9 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -17,6 +17,7 @@ import 'state/app/modules/app_modules.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; +import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; import 'utils/debouncer.dart'; import 'view/pages/overhang.dart'; import 'widget/breaker/breaker.dart'; @@ -54,6 +55,10 @@ class _AppState extends State with WidgetsBindingObserver { if (!mounted) return; context.read().refresh(); context.read().refresh(); + // App is freshly mounted on every login (BlocConsumer in main.dart + // swaps it in for Login), so this also covers the post-logout case + // where the bloc was reset to an empty state and needs a fresh fetch. + context.read().refresh(); }); _updateTimings = Timer.periodic(const Duration(seconds: 30), (_) { diff --git a/lib/main.dart b/lib/main.dart index a8f88c7..3ce1dad 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'package:jiffy/jiffy.dart'; import 'package:loader_overlay/loader_overlay.dart'; import 'package:path_provider/path_provider.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart'; import 'app.dart'; @@ -33,6 +34,7 @@ import 'theming/light_app_theme.dart'; import 'view/login/login.dart'; import 'widget/app_progress_indicator.dart'; import 'widget/breaker/breaker.dart'; +import 'widget/debug/cache_view.dart'; Future main() async { log('MarianumMobile started'); @@ -160,7 +162,40 @@ class _MainState extends State
{ home: LoaderOverlay( child: Breaker( breaker: BreakerArea.global, - child: BlocBuilder( + child: BlocConsumer( + listenWhen: (previous, current) => previous.status != current.status, + listener: (context, accountState) { + if (accountState.status != AccountStatus.loggedOut) return; + // Routes pushed via AppRoutes (e.g. Settings) live on the + // root navigator and survive the home swap below, so they + // would still cover the Login screen after logout. Pop + // them here so the user immediately sees Login. + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.popUntil((route) => route.isFirst); + } + // Capture bloc references before the post-frame callback + // — by the time it runs the dialog/Settings context is + // gone but this listener context is still valid. + final settingsCubit = context.read(); + final timetableBloc = context.read(); + final chatListBloc = context.read(); + final chatBloc = context.read(); + final breakerBloc = context.read(); + // Defer the actual wipe until after this frame so the + // App tree (TimetableBloc/ChatListBloc watchers etc.) + // is already torn down. Resetting blocs while App is + // still in front caused a black-frame race. + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_wipeUserState( + settingsCubit: settingsCubit, + timetableBloc: timetableBloc, + chatListBloc: chatListBloc, + chatBloc: chatBloc, + breakerBloc: breakerBloc, + )); + }); + }, builder: (context, accountState) { switch (accountState.status) { case AccountStatus.loggedIn: @@ -190,3 +225,28 @@ class _MainState extends State
{ ), ); } + +Future _wipeUserState({ + required SettingsCubit settingsCubit, + required TimetableBloc timetableBloc, + required ChatListBloc chatListBloc, + required ChatBloc chatBloc, + required BreakerBloc breakerBloc, +}) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); + PaintingBinding.instance.imageCache.clear(); + await settingsCubit.reset(); + await Future.wait([ + timetableBloc.reset(), + chatListBloc.reset(), + chatBloc.reset(), + breakerBloc.reset(), + ]); + await HydratedBloc.storage.clear(); + await const CacheView().clear(); + } catch (e, s) { + log('User state wipe failed: $e', stackTrace: s); + } +} diff --git a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart index 625e1dd..5d3bd43 100644 --- a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart +++ b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart @@ -8,10 +8,16 @@ import 'package:jiffy/jiffy.dart'; import 'loadable_state_event.dart'; import 'loadable_state_state.dart'; -class LoadableStateBloc extends Bloc { +class LoadableStateBloc extends Bloc + with WidgetsBindingObserver { late StreamSubscription> _updateStream; void Function()? reFetch; + /// Last time [reFetch] was triggered by an [AppLifecycleState.resumed] + /// event. Used to coalesce rapid foreground/background flips so we don't + /// spam the network when the user briefly checks notifications. + DateTime _lastResumeRefetch = DateTime.fromMillisecondsSinceEpoch(0); + LoadableStateBloc() : super(const LoadableStateState(connections: null)) { on((event, emit) { emit(event.state); @@ -25,6 +31,23 @@ class LoadableStateBloc extends Bloc { Connectivity().checkConnectivity().then(emitConnectivity); _updateStream = Connectivity().onConnectivityChanged.listen(emitConnectivity); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) return; + final now = DateTime.now(); + if (now.difference(_lastResumeRefetch) < const Duration(seconds: 10)) return; + _lastResumeRefetch = now; + // Re-check connectivity. The resulting [ConnectivityChanged] event takes + // it from there: its handler updates the offline/online indicator and + // triggers [reFetch] when the device is connected, so a stale + // "Verbindung fehlgeschlagen" bar from a suspend-time fetch clears as + // soon as the network is reachable again. + unawaited(Connectivity().checkConnectivity().then( + (result) => add(ConnectivityChanged(LoadableStateState(connections: result))), + )); } bool connectivityStatusKnown() => state.connections != null; @@ -55,6 +78,7 @@ class LoadableStateBloc extends Bloc { @override Future close() { + WidgetsBinding.instance.removeObserver(this); _updateStream.cancel(); return super.close(); } 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 f241431..74e9f00 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 @@ -60,10 +60,27 @@ abstract class LoadableHydratedBloc< error: event.error ))); + on>((event, emit) => emit(const LoadableState( + isLoading: false, + data: null, + lastFetch: null, + reFetch: null, + error: null, + ))); + _repository = repository(); fetch(); } + /// Wipes this bloc's persisted state and resets the in-memory state to an + /// empty, non-loading shell. Intended for logout: callers must trigger a + /// fresh [fetch] (e.g. via [retry] or page-specific refresh) once the user + /// is authenticated again, otherwise the UI would stay blank. + Future reset() async { + await clear(); + add(Reset()); + } + TState? get innerState => state.data; TRepository get repo => _repository; diff --git a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart index 1485c60..c0b9934 100644 --- a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart @@ -14,3 +14,4 @@ class Error extends LoadableHydratedBlocEvent { Error(this.error); } class RefetchStarted extends LoadableHydratedBlocEvent {} +class Reset extends LoadableHydratedBlocEvent {} diff --git a/lib/view/pages/more/roomplan/roomplan.dart b/lib/view/pages/more/roomplan/roomplan.dart index efbc774..0b15a20 100644 --- a/lib/view/pages/more/roomplan/roomplan.dart +++ b/lib/view/pages/more/roomplan/roomplan.dart @@ -10,7 +10,7 @@ class Roomplan extends StatelessWidget { title: const Text('Raumplan'), ), body: PhotoView( - imageProvider: Image.asset('assets/img/raumplan.jpg').image, + imageProvider: Image.asset('assets/img/raumplan.png').image, minScale: 0.5, maxScale: 2.0, backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index 7067c22..d4b8128 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -1,12 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import '../../../../model/account_data.dart'; -import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; -import '../../../../widget/debug/cache_view.dart'; class AccountSection extends StatelessWidget { const AccountSection({super.key}); @@ -26,16 +22,13 @@ class AccountSection extends StatelessWidget { title: 'Abmelden?', content: 'Möchtest du dich wirklich abmelden?', confirmButton: 'Abmelden', - onConfirmAsync: () async { - final prefs = await SharedPreferences.getInstance(); - await prefs.clear(); - PaintingBinding.instance.imageCache.clear(); - if (!context.mounted) return; - await context.read().reset(); - await const CacheView().clear(); - if (!context.mounted) return; - await AccountData().removeData(context: context); - }, + // Cleanup of caches, hydrated bloc storage and bloc in-memory state is + // handled by the AccountBloc listener in main.dart on the loggedOut + // transition. Doing the cleanup *before* setting loggedOut caused + // rebuilds in the still-mounted App tree (TimetableBloc/ChatListBloc + // emitting empty states) which raced with the home-route swap and + // produced a black screen. + onConfirmAsync: () => AccountData().removeData(context: context), ), ); } diff --git a/lib/view/pages/talk/data/chat_bubble_styles.dart b/lib/view/pages/talk/data/chat_bubble_styles.dart index 33db5e7..6c7627a 100644 --- a/lib/view/pages/talk/data/chat_bubble_styles.dart +++ b/lib/view/pages/talk/data/chat_bubble_styles.dart @@ -24,7 +24,6 @@ class ChatBubbleStyles { BubbleStyle getSystemStyle() => BubbleStyle( color: AppTheme.isDarkMode(context) ? const Color(0xff182229) : Colors.white, - borderWidth: 1, elevation: 2, margin: const BubbleEdges.only(bottom: 20, top: 10), alignment: Alignment.center, @@ -35,7 +34,6 @@ class ChatBubbleStyles { return BubbleStyle( nip: BubbleNip.leftTop, color: seamless ? Colors.transparent : color, - borderWidth: seamless ? 0 : 1, elevation: seamless ? 0 : 1, margin: const BubbleEdges.only(bottom: 10, left: 10, right: 50), alignment: Alignment.topLeft, @@ -47,7 +45,6 @@ class ChatBubbleStyles { return BubbleStyle( nip: BubbleNip.rightBottom, color: seamless ? Colors.transparent : color, - borderWidth: seamless ? 0 : 1, elevation: seamless ? 0 : 1, margin: const BubbleEdges.only(bottom: 10, right: 10, left: 50), alignment: Alignment.topRight, diff --git a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart index 13885c9..7b88c05 100644 --- a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart +++ b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; @@ -18,8 +19,9 @@ class WebuntisLessonSheet { final state = bloc.state.data; if (state == null) return; - final subject = _resolveSubject(state, lesson); - final room = _resolveRoom(state, lesson); + final headerSubject = _resolveSubject(state, lesson.su.firstOrNull?.id); + final headerTitle = _firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']); + final headerLongName = headerSubject.longName.isNotEmpty && headerSubject.longName != headerTitle ? headerSubject.longName : ''; showAppointmentBottomSheet( context, @@ -28,12 +30,12 @@ class WebuntisLessonSheet { mainAxisSize: MainAxisSize.min, children: [ Text( - '${_codePrefix(lesson.code)}${subject.alternateName}', + '${_codePrefix(lesson.code)}$headerTitle', textAlign: TextAlign.center, style: const TextStyle(fontSize: 25), overflow: TextOverflow.ellipsis, ), - Text(subject.longName), + if (headerLongName.isNotEmpty) Text(headerLongName), Text( '${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - ' '${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}', @@ -42,68 +44,210 @@ class WebuntisLessonSheet { ], ), ), - body: (_) => SliverChildListDelegate([ + body: (_) => SliverChildListDelegate([ const Divider(), ListTile( leading: const Icon(Icons.notifications_active), - title: Text('Status: ${lesson.code != null ? "Geändert" : "Regulär"}'), + title: Text('Status: ${_statusLabel(lesson.code)}'), ), - ListTile( - leading: const Icon(Icons.room), - title: Text('Raum: ${room.name} (${room.longName})'), - trailing: IconButton( - icon: const Icon(Icons.house_outlined), - onPressed: () => AppRoutes.openRoomplan(context), + if (lesson.su.length > 1) + _listTile( + icon: Icons.book_outlined, + label: 'Fächer', + entries: lesson.su.map((s) { + final resolved = _resolveSubject(state, s.id); + return _formatLine( + _firstNonEmpty([resolved.name, s.name, '?']), + longname: _firstNonEmpty([resolved.longName, s.longname, '']), + ); + }).toList(), ), - ), - ListTile( - leading: const Icon(Icons.person), - title: lesson.te.isNotEmpty - ? Text( - 'Lehrkraft: ${lesson.te[0].name}' - '${lesson.te[0].longname.isNotEmpty ? " (${lesson.te[0].longname})" : ""}', - ) - : const Text('?'), - trailing: Visibility( - visible: !kReleaseMode, - child: IconButton( - icon: const Icon(Icons.textsms_outlined), - onPressed: () => UnimplementedDialog.show(context), - ), + _roomTile(context, state, lesson), + _teacherTile(context, lesson), + if ((lesson.activityType ?? '').trim().isNotEmpty) + ListTile( + leading: const Icon(Icons.abc), + title: Text('Typ: ${lesson.activityType}'), ), - ), - ListTile( - leading: const Icon(Icons.abc), - title: Text('Typ: ${lesson.activityType}'), - ), - ListTile( - leading: const Icon(Icons.people), - title: Text('Klasse(n): ${lesson.kl.map((e) => e.name).join(", ")}'), - ), + if (lesson.kl.isNotEmpty) + _listTile( + icon: Icons.people, + label: lesson.kl.length == 1 ? 'Klasse' : 'Klassen', + entries: lesson.kl + .map((k) => _formatLine( + k.name.isNotEmpty ? k.name : '?', + longname: k.longname, + )) + .toList(), + ), + ..._optionalTextTiles(lesson), DebugTile(context).jsonData(lesson.toJson()), ]), ); } + static Widget _roomTile(BuildContext context, TimetableState state, GetTimetableResponseObject lesson) { + final trailing = IconButton( + icon: const Icon(Icons.house_outlined), + onPressed: () => AppRoutes.openRoomplan(context), + ); + + if (lesson.ro.isEmpty) { + return ListTile( + leading: const Icon(Icons.room), + title: const Text('Raum: ?'), + trailing: trailing, + ); + } + + final entries = lesson.ro.map((r) { + final resolved = _resolveRoom(state, r.id); + final name = _firstNonEmpty([resolved.name, r.name, '?']); + final longname = _firstNonEmpty([resolved.longName, r.longname, '']); + final building = resolved.building.trim(); + return _formatLine( + name, + longname: longname, + extra: (building.isNotEmpty && building != '?') ? building : null, + ); + }).toList(); + + return _listTile( + icon: Icons.room, + label: lesson.ro.length == 1 ? 'Raum' : 'Räume', + entries: entries, + trailing: trailing, + ); + } + + static Widget _teacherTile(BuildContext context, GetTimetableResponseObject lesson) { + final trailing = Visibility( + visible: !kReleaseMode, + child: IconButton( + icon: const Icon(Icons.textsms_outlined), + onPressed: () => UnimplementedDialog.show(context), + ), + ); + + if (lesson.te.isEmpty) { + return ListTile( + leading: const Icon(Icons.person), + title: const Text('Lehrkraft: ?'), + trailing: trailing, + ); + } + + final entries = lesson.te.map((t) { + final base = _formatLine( + t.name.isNotEmpty ? t.name : '?', + longname: t.longname, + ); + final orgname = (t.orgname ?? '').trim(); + return orgname.isEmpty ? base : '$base · ehemals $orgname'; + }).toList(); + + return _listTile( + icon: Icons.person, + label: lesson.te.length == 1 ? 'Lehrkraft' : 'Lehrkräfte', + entries: entries, + trailing: trailing, + ); + } + + static Widget _listTile({ + required IconData icon, + required String label, + required List entries, + Widget? trailing, + }) { + if (entries.length == 1) { + return ListTile( + leading: Icon(icon), + title: Text('$label: ${entries.first}'), + trailing: trailing, + ); + } + return ListTile( + leading: Icon(icon), + title: Text(label), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: entries.map(Text.new).toList(), + ), + trailing: trailing, + ); + } + + static List _optionalTextTiles(GetTimetableResponseObject lesson) { + return [ + _textTile(Icons.info_outline, 'Info', lesson.info), + _textTile(Icons.swap_horiz, 'Vertretungstext', lesson.substText), + _textTile(Icons.subject, 'Stundentext', lesson.lstext), + _textTile(Icons.category_outlined, 'Stundentyp', lesson.lstype), + _textTile(Icons.flag_outlined, 'Statusmerkmale', lesson.statflags), + _textTile(Icons.school_outlined, 'Lerngruppe', lesson.sg), + _textTile(Icons.bookmark_outline, 'Buchungshinweis', lesson.bkRemark), + _textTile(Icons.notes, 'Buchungstext', lesson.bkText), + ].whereType().toList(); + } + + static Widget? _textTile(IconData icon, String label, String? value) { + final text = (value ?? '').trim(); + if (text.isEmpty) return null; + return ListTile( + leading: Icon(icon), + title: Text(label), + subtitle: Text(text), + ); + } + + static String _formatLine(String name, {String? longname, String? extra}) { + final parts = [ + if (name.isNotEmpty) name else '?', + ]; + final ln = (longname ?? '').trim(); + if (ln.isNotEmpty && ln != name) parts.add('($ln)'); + final ex = (extra ?? '').trim(); + if (ex.isNotEmpty) parts.add('· $ex'); + return parts.join(' '); + } + + static String _firstNonEmpty(List values) { + for (final v in values) { + if (v.trim().isNotEmpty) return v; + } + return ''; + } + + static String _statusLabel(String? code) { + switch (code) { + case null: + case '': + return 'Regulär'; + case 'cancelled': + return 'Entfällt'; + case 'irregular': + return 'Geändert'; + default: + return code; + } + } + static String _codePrefix(String? code) { if (code == 'cancelled') return 'Entfällt: '; if (code == 'irregular') return 'Änderung: '; return code ?? ''; } - static GetSubjectsResponseObject _resolveSubject(TimetableState state, GetTimetableResponseObject lesson) { - try { - return state.subjects!.result.firstWhere((s) => s.id == lesson.su[0].id); - } catch (_) { - return GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true); - } + static GetSubjectsResponseObject _resolveSubject(TimetableState state, int? id) { + final fallback = GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true); + if (id == null) return fallback; + return state.subjects?.result.firstWhereOrNull((s) => s.id == id) ?? fallback; } - static GetRoomsResponseObject _resolveRoom(TimetableState state, GetTimetableResponseObject lesson) { - try { - return state.rooms!.result.firstWhere((r) => r.id == lesson.ro[0].id); - } catch (_) { - return GetRoomsResponseObject(0, '?', 'Unbekannt', true, '?'); - } + static GetRoomsResponseObject _resolveRoom(TimetableState state, int? id) { + final fallback = GetRoomsResponseObject(0, '?', 'Unbekannt', true, ''); + if (id == null) return fallback; + return state.rooms?.result.firstWhereOrNull((r) => r.id == id) ?? fallback; } } From 86d12884fcfc64bcfa70e9c149bc5394ef1284aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 6 May 2026 20:42:09 +0200 Subject: [PATCH 13/23] custom login implementation, period-based timetable layout with overlap handling, enhanced error dialogs, and unified bottom sheets --- assets/background/chat.png | Bin 69191 -> 12368 bytes assets/img/raumplan.png | Bin 82938 -> 27652 bytes assets/logo/icon-android12.png | Bin 58956 -> 0 bytes assets/logo/icon.png | Bin 28285 -> 22286 bytes assets/logo/icon/1024.png | Bin 289398 -> 101356 bytes assets/logo/icon/adaptive_back.png | Bin 15875 -> 0 bytes assets/logo/icon/adaptive_fore.png | Bin 55160 -> 0 bytes assets/logo/icon/appIcon.png | Bin 33002 -> 9547 bytes assets/logo/icon/ic_launcher.png | Bin 22373 -> 6203 bytes .../logo/icon/ic_launcher_adaptive_back.png | Bin 15875 -> 11751 bytes .../logo/icon/ic_launcher_adaptive_fore.png | Bin 55160 -> 12987 bytes assets/logo/icon/icon-android12.png | Bin 0 -> 23279 bytes flutter_native_splash.yaml | 2 +- lib/main.dart | 30 +- lib/model/account_data.dart | 10 +- .../view/loadable_state_error_bar.dart | 2 +- .../view/loadable_state_error_screen.dart | 2 +- lib/view/login/login.dart | 412 ++++++++++--- lib/view/pages/overhang.dart | 2 +- .../settings/sections/account_section.dart | 23 +- .../custom_event_edit_dialog.dart | 2 +- .../pages/timetable/data/calendar_layout.dart | 8 + .../data/timetable_appointment_factory.dart | 29 +- .../pages/timetable/details/bottom_sheet.dart | 57 +- .../timetable/details/custom_event_sheet.dart | 78 ++- .../details/webuntis_lesson_sheet.dart | 48 +- .../timetable/widgets/appointment_tile.dart | 93 ++- .../widgets/custom_workweek_calendar.dart | 557 ++++++++++++++---- lib/widget/async_action_button.dart | 4 +- lib/widget/file_viewer.dart | 2 +- lib/widget/info_dialog.dart | 51 +- pubspec.yaml | 3 +- 32 files changed, 1038 insertions(+), 377 deletions(-) delete mode 100644 assets/logo/icon-android12.png delete mode 100644 assets/logo/icon/adaptive_back.png delete mode 100644 assets/logo/icon/adaptive_fore.png create mode 100644 assets/logo/icon/icon-android12.png diff --git a/assets/background/chat.png b/assets/background/chat.png index a9f5760ac52508ec59884eb3eafc6f78a7b96130..05664581741365204a37638dd46dafbf6b71d3f3 100644 GIT binary patch literal 12368 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.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 Klq-`l4*FkP-}?># literal 82938 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 diff --git a/assets/logo/icon-android12.png b/assets/logo/icon-android12.png deleted file mode 100644 index 51e192dd8e5947a37d963acb2979f449bec8564a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 58956 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 bS3aG6dp}Ztev-?0cl2<*8&}G793TFF29sge diff --git a/assets/logo/icon.png b/assets/logo/icon.png index dc89db9799b1a01af8fd97a8c1ac986db61f0e3b..f541898b4e7c0d56f0700ff433117ddc6d3f83b0 100644 GIT binary patch delta 11694 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 delta 17720 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 bh^T%J*e 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 KMNUMnLSTZ~kYZf` literal 289398 zcma&NbySpV_Xhk7-62Xh3Q8&sLrRK*pmZxB-7qu^AfPlVATUUSbV^8rl;F@Uozfu< zGv9;9_x;Yh*7sZM`^)3<%$~jXeeG*sd*AoGRaa9YCZHt%0D$IA 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

0N&s(CP#~M!?116-|j}e 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

!D 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 z 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 JC_R7s{{Ss>fBXOd diff --git a/assets/logo/icon/adaptive_back.png b/assets/logo/icon/adaptive_back.png deleted file mode 100644 index 7126e69e2d4608abcfe0e6b51f1889e41713415f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15875 zcmY+rby!s2_XauvGaxa9k}`Bj4IqMahk$@04oD0ljnYUnbW2Jo-6FyWNXHN=rG!W- z3HsCHLalYuk~w&Hfw3NZo`I#L2r`HEszcyexyD#1|(| zJg5!^G{gM7-sfIyv0iRZv&x;c&mu2(yKF5ksmr#R^{vXr;jx`^R7_YVOBRDfg&f2o z$mM8Q6|IHVXnhrgP#v=qC*Q6u zd~U@ik*-aS+afUJ8o)&gJ1o}m?1!LJ>m=h6^XrisBwK}DcQt&Ys-xtC@>!usOeF7a zfRfZNMU)&~7T>0RCri6s;^-^BTTZI!HfMSQoWXSt5(x#1-uoQ{g=(^-lla|xMq5Tb z<{G!^qSgS%h6bSnaQuDZCoQx19MYS}tbAg{>@6^h!=AT60|KiynvRRG(oqrU47iOs z-kBzs=2aPeA#&|d(GKJg=1IiRCLgm$WlbFrm~Kkha`r0_3Izpv^CG@pBYlJ;D}skO z;$Q`wJv5C+p*M55K@!qiyst#qA@H>C<^;SAJE3HPwRBvFLo*gX&~%KUoN zD>iKe3PdP96+5DvU`yBJ0af%IM*$%UdMAksx;(_3uol5kogEjQl0Db&=CKIJu)*7d zgPqBt?Wr0ZKiJ=DB8HvRa}Pnx(bM=hx^egjK75xmrrpgp%^8mJU%7*c&i)wexH3#r zLk{6;CnrBc`JSlkO5$Ad*c1}w3+UDMj>*o&n5*S_XnVW$3lr$WPf6UirYmM`oVjmj z^^btnZ^rP@3m>eZ`nXQ~X>%AmkZ;J8a-v@4tIOd*Ix5_qIWX}>R;Y!OG~t~elrzrB zZO#`{(dFQl0877!?THbY+71P@uOyMNm$K#LEkd#J8?kTx|9oS4lOp}lx zIvLX@8%FX!^{i(dm-=3zroLD;F(rnh11lbelBidR!GCUAPP9(*6y!bp$*~yq|5VNx z6u)ag)1qEsgp-gkbhR@E%KXYriE?S#CJ-8`8UI?TCEiBp#6Lyvmk=7Fz(>Vmwe8n% z>lZvx;F5vi@@78;s#EIZ1Dxdcjy6P!F!&%Ot}D`=H%BQAEgYgltp}$2zI0UVXXi77 z;G&JxMT(FdisFMAfmvAk11zZN*}9B5{xx`888OYc&a3&p+lsGFEfgO3n)-ek&MXB;4q>57SsY- z3GR9Pe|3Kv$M|fH73!`4>w}@whx*#W+q0sMK0+KkEaUgUTWtPHwM0LH_lB^%S6zY; zLxJXymd4#|QP2j&@pVC`Ot-iBoXxyB1CElH4>M(!Nt0s>RZv@-bMhfKy-wVfG8PHH z)8+A0mQYLCW}>wOlHI&$4HHpa4Q7@znGtx01R6R_{Ve)?gtB^K@j2Zbqjlr3kH+^N z(BLRIA__uzQAmSMfNmj*sU&jRpFwiP5O3wov3t}%X#!?s6_{H|2k9pFKySYrO@UM( zZvGk(Mi@Crq;KgUX;0OYWtY?Vxh`sUw`-&L?`DKOPC1ZZ!Lh0bkyM*J(?6?3>pl=! zEExML6@PLwENFsOAO6E*V*L-XCMzgmnK+o@51U>QV%Oi@GTom;ny#)aR45(YnTxSw#2na>j)eO7Vu{_oH@98n;Whls7)vk*PK>a*`CaF4D6CK9 zt~$l;ODSxxursFTSZvT7O1=f{C3acr3Lvk>!_f_{pcyC9+P_L$k#Z1EeX~dki$Ana z=}RM&^WN*M%y{K7_0VP@PyN=(FgITXu~z zm`|wDFGTMvEeN8!7|*F9*t7-dJN!9vD62^jsG>r)D(u(!#42CS9{7^#6ax~ z?8ak#s#UfeWM^S$_*{p(+`d#feFK9+)-pfKAzk4KgoQmo-#=J7DhH&uE>4Z_bS7Wl z5*_r((Yw5~xRkgs|7fp~nGeq3r{i54NU%2PkmS9d$GKLDj)$9E}^oP`pMijv&Lk_P+9QAFz+ zUwPPAXUz14O#fK^NuXytr*gtFMWwOnUGjlIf&9To*A^VpS&1fPMMUvbW#1y{y&9EX z5`FoV-o>jyRcUkbWmGmFdih@G&fo@|0kZf0Yd7)M1sJK7sA`)hUc?$Bt`8rkB}}ev zN{F)v^`nOhbXe&qBvxbu4y^iGTLvT01#^5;))R-d=%y)93yHI0ZZzwbba-E}Z;>1? zL=;chkEkTPnr@b{T7n*Paq{jSZ+~=Q1(98{9T9rC20y^iQgMfr-CUzx?!;p$_VU-+ zZ<@E+Id`hH@KnQrY|k3tJ9)>9`PdIqkEbl5ztt3AzO3*@TQ=*7Q3uCQm=7(5_ z$5Pi=Y4&`m$7ny3mWA3&ckKS*M6-fY%wPn z&!YS1rbYi_BMKxaXE3t)j7>2khtN0*(k8tmjf(tBT9MgF&`+Fg)N%vT@ZwfVhrX4ZK?PPiqeLkpA&tEiq*Fk@@m^h34_>L8@uF*YAz{!9 zD)^gM&vhDYY$9lbJn-#`MQAtd>c zk7X}X5&GH5f;0J1d&>VYw0Zm8AS@JfbY8{Xp?Ao;`$!(&ryM{tk$)J{WF1G@{WQQ} z;L4M(yqh9;UvR_G^SM|Hn-dsO?}}EAHI$q>z#r~!;Yu?)WE3)Goh2;QGNf0a6717L zvYpezW$-7`P&J`3kYgyAf26B7t{8z*U7w`N4ZGdKV%nbi8a{#BC=ZXiROJa~ilPJE zzN!Z2+5zV0eIONOS_%>BCJi}~)p~-Liue@$XNt1W)oV*P)`$u7{b`L(?Jd&8wDXcv zxgfv~8LZ{o+;%gnNB_pDMx8%zP9KMRKlqg=ot?qis%RRu8@dd>FA4zgjj~`n<1GF= z_$K_>5}YbE5=pjNZ>@F768aSWbETVQC7hr(@w+CeeL#)u!p3ruU_81hm4o`)qFYI5-d-S!>)?mv(U6BP2YTou%_ z6<8oA?XKZhc%_PnynM9y|+hU2?bbiYe1SEDz za6fw?I>q{#syx);ZI|z#_qS0kc^xXIwG&l)Y>z7t9PWzo!kKC(BwOepEOED9F6o@P zCRGV>yaIM#LoCd}a_Wogj9K))G7jv3@omBpXHY4SH@N@`C^J7SahP5%98e(nPvJnr zH4FgMFG&t?hVC!)LWl3xKF}=aM*iZEBl4-Ziw`L%-DI2#{Qsog!W{BXzI>c9y)w`4 zAbUwlal$nfER;`NW?8aniFU+-m-KR>b2(-qM^U6xoS$i~7C||72*(AI$SYFl=+W1K zXxo2QJAZN{M{!8mQU<%s-j6&*GSzA%a~qQVe92=m`(13S4)SqwrC=XOYLdV4G(E?p zOP>xwWHDruJinMl<+)z0Th{8u@IERX{wuT|62O1k=HH+3f8O!x{z*4!P1%E}Y;i?e^P|q{R}S)UB3Hjb?90VJ(hIaE6$Fw z;J}+2Ncq}8ESLkNkfA5S*lt4gk#x?#J+tvueg#`(!o$z!y!#~bs_`}oR8;Mx!ip5C zWQk@#d24(FL7CvP-Xcjuh^JDwPP{~0^kRe_l};|6{2-3I+Ywc!H9yDtUHaY%N=WzG={k@uosI7@DzvL)6vlpNnjp!cvjKT&f8Zmy&8+)LN<8Zj$g!wq9);b6A zA08kQkK2Qwj`<2NnHdC+Dk7tNY$f42DSLW;(6N+diW4lbfRI&8Q;7dZFwAxPZx?gK z1E(iJ%3*5i-_=UHe2gMhp&Qg_y@Er*vw8&yrJ$OE8Ol8A-)PQrePg0kHs6rR=Z9T{ z&>VSZ40aN$mwV5HR8KV>P`Y8NS8(?!WD4Y8a_I9ci`CKaEkPvw} zg;y*@1(}Kz(Ujq~7~SY4EW9cKz`cq*>5R(X3{d}iD>BqEQ_3$%R4|C`aJtn~D-yNA zj;{IEI!+3_ zl{`^qASq zBy+wx$?EEJkP117GP68PCzr#+MK{w6FIXWN!}wo!cJh|+Jyn+Q>=n&utxB$A!AuZC z!W17Vml#k>Dd}cj^$j5VNoDB~uUjqHRg9|gP;&5@%eRrX9l0cu=Y5=f?D_`3K zz#zmuwm_B1=^YAkaK#|FDb%or!ztv=0~ok|s7>>QzL22>c6Y5%Q-O+kUZEwJa*@p< zOlZIUGu$N2588!?QB-=HmS;=%uy&9*++!rtRr4#HxMBfH-(eYS z2FP_OsTxMq63t7o^+OHQr5=ixb9XQL60t_wFN8( zX(T1J1+%lgy6$wa>1NZ1A$92YA=(K&RNpRKJ=$24VgWhIYJ4@DH4EN{xG*LKlcRL@ zG{3&B)Uqrif1T%bEk!YB=t48_{{sD0BPoYX{=(n;t6|KhqRk6R6c-ottzR!$@b#HK zeNu-~6e$taM#2JoOj!bkZkLY%aN{aRn@|xAaXmgKL6=7fZ!U{JCjD;x(zT`zlV(}= zqgM0dLdXK_0iNw%!5cztz`6EyjqTFNAMSSmVkS}(>FRVFW$+k!@q)8F`^!?)@82R^ z1ZVkVcJ%wVaJ0g8PN0(4o~2C(=CF+Z0pB5sF-@a0Sx)rvZl%Sya>|7`%rOObh3*rH za$H=5+e40?2KD6~Q9A5g*%s0!`yi%SFlFxqu_=&CzS&_?_66j4xj}}NL_>CO4$Nt; zAu*h(SP8VEy01ZmrlHL1L6RL;7R#Jm$*#ykkHj&61|e;(0LH zW-(z_9`2q|s{0Qauw4Rt_NgSLfAcg_>Uh8VuYhnMpxSzu(&i z$!J&ZIKvo&ss9acP|e1Irg*>6Rz$9nXO$)-Je4!Zy6W=xv)J$0a`|Ah^Nl20szW$_ zl}>~2Gn$J89Z6-@GA9Iol0FGOQ2iJJ5Z;jJPT$~(tR!H!wJ5{`+${DW{%60P>T2fp zt=A)D{EedywOCzn$V$)T{SS+1z3aR{ijZOA7H>XvHv;0v);O|d9j@A-$KS4$@KNRb z_{l`~Kd81+g0yeQ|E7Qy>>RzkwWkIGG=LofH;xQhhpV*c@ptv61Rvh3=62+txy@^Q zxLSnaT<{DqLUo^}x@Qp*4)4_v0}O#zYW7LY|4cXGA|_N9 zfQ#n*aSl7<4yw%`A&Z2S#Jzhv#Ex3Ely2t#YUsLKY27R6qjb5Xk(l;n%M$fpJ>+5D zRdlkvE%?*odI%J1?s4(@#%j2-Gto-{>nN*H|$A zwL}}y^Ta=uKCuMfJbGEd8a1fck4QIlz*;V;y?fl^dHikdtz)w65S+P!cbF`TxK<>F z-VpwCELx(Q_m=%BcuYufFv9w6F3v*xJK}z1k;NPFD<0qt&6hK1DU%-5U^&@*LVUN~ zR7PLKkVWxF9h6+04BN8HtFng~st|`W1eDGBnhipMLnk?K41s=!jUl?>iF+DPC{P1z zA?xEzH%fTdC*K^iuyV`&sOa#eX~i=tofdc-c=kvlu@VO=vt>pVYWfO)`~_O0i;Gk@%*d|&`#}CoOlw5^8*?S^ zF!?WeJBusuDzBn-jzX~6X9KGvkKB5%bARgPT?JV@CFk2#&^M~regG6wpkQn8D|-UN z0A@k&Co{c!WbZOE^WK+p<*f}RFf;Nl9PS8!m(5jA<(;8oReX>O9{aZb&_y6#_m|(J zDz1PZzvg^tzLipbJ&YWh;d`OaV)#UIhKf_u=z_QV)lvk;i1|&!W%-QOiIGdB<2{c^ z%KrYY|DTI14@rA@yI=5(42@jI_S6$`!5khbSO@%ApYs*_jaO3kD{L_ETn7J+mh2%( zl(;0Nra-=9t1*Jp()yt%-HkVI2Q}pANVFIib43Z?O)4al8PQ}v;5c=K@sKz+`%PyI5`#$e5*2Ptcma34 zvzL5v1BsX5ZnB+wA35ItjokC|&($8}TJ*xVyN9N|&KQeeFL8lJR>3_NeT{ytlgHj5 zfK(yF196la@e44Zi$Z>V3{+Id3I-{+kbQh>KzxqLf6V2XS5graP7hF+1Moi$?f-c` zOFhsL^wl?nfuc^Xb$!YiWA;t!9~s%H$5{jX8Pj!hJV1}YepkX6Aww(PB{ojQv2Qfv zH!)`M{LAmB=VWL-GGXDYS60Qp^VSkr9X8S`70Zxun-$dN=VVJ&UghWE5S;g$8Sz5f5R z_FJfCmbY6CUwCOih)(s8^`PBo7ka&-zX-1s_!0Dlygfi^^r`ib4&Eo8zxi?x33Z~L z7`$`+AO>dox+l2l0n4=@B8g&hn5?GXZowe=f||y~)d>j`7lE{J#|jZ0bqRDOvd5^Q zaW}r{j-@&nEZoz7*oPTOc+x)%AUv>sPW+kmca=+G=W@!THG@nE*o3lTdx!8p7BWv| zhJ?CA2ZJN7GJb29XLx*jAwW0q09=AgIJI#(iuXAKG{*OCVq%qA?3JMu=(0k7XimC= zfe&|?z~az1-egwv`$FKv#>UHbKazVDKJgS!8GUCcH2hfK={w{d^0h@s(*sKEYr7yW z1sq*DH74+tFn9wJ#WX2yErPQAD`IM6}gNArCk@|UtScE|Yv0izF&3^85;cF=-`q1<5;=b2_s;*u&y9bQGpxPT z72#`PloVt1J=c=&Jo*A%Uo`W-X0r5Duq;zo?;rPg+0J9b$KS0+a;MwjcySeXqBYMw z$Oh>%;%_-X^8P8w+#0HD-Qj6O^PnQB8xckj_Xd=tOP>x)>2QWw&b zs6)+aF2{31m?K5?;q>@(>?t`)3a>arsqG=l6{!oSD_VZEq-ZD;u8o&G+cETSgS4)! z!hs0fVtBvxXN?jUu8R+0s8gs?jIpuiQT_C=0d(h2K(cJFN#H4DB>C~vY%nbHHe&nq z{t>r6`+Ro}f79z@s-O4g+&ZhO5(oh!vK_t+Mp%Ut7$&`SL0EGI~qt z){BW;QAn7-Z!OVg$`a{V*C*IyyZoyI;+>F%@V-$>+&4^LP-8^J_W%Ytk3*Bke>Dsh z!YQ6f&It6TR{Y%ikw4ppgc~d135iO7ADF-t_C{VD&-0)BY@`{}n}bfo9BPSQu?X1-2FCbrRyz5C_ zx#O~4AE{j07f4I(i)(aadREunb`~vL9s{Dl^}-gg2U4mCoU74UN6tj^+DI)Ks0HAS z|4K;@Qw}JA*~2MsP0|G0QiYJG|o6%nRrk;CWUgxDPtTGE&@+ z321y3ZuW8+WxeKv@^`xXW41m^{ zOrqcG4|~`!M&57HkgmawC!5>1xyW7JP_VM9`Gqzr(QJ!N1IEau4lam)99!R%+D5Jn zgot*YKH3U*oPQIFBm^Cv7*pB6v{F+iZLppcCEXI1!yd1UBXwK!r0X#-uJe(&E4Wu; zmBl{=rL$nNyBKPU{;hbFY!Q2=XL;v(yy%G1JSk@E+UI%`D(~(687wT0mxiNWs5}p+ zsoNr6oRdgnj~>4!GWUk&w^a|~aeiyZFUq$f=0h*A`8IT28*KrMYZWRyGo^8}y%?>N zN2myZH016P)~xKDQIeVkwK|g^mG~5nsTyz6%L-^3PLH%*@w)Z-NW~Rh0E=+tQU=k5 zPl9O{xt~ER++7w7nVt2Qw8!S|O_au2vOP}Du^;nj{VT(A5oCK~3-4^f)?!f{0Ym?# z+R{9zK(AVkaVO*S_`jwMr|wl?C;&87CvSf0l6>`2+y z&a~6q`X)5`_5Wlkj|jk>+o^sZRHy5c+RqUcatlqQ`EFA3v7Vpy_-<*}F;4I#3_elt zMb6=gmuozZ##9|?&PhWbGxQ*jpjgT@yYW7@utQS_s! zc8HVh6RDZESXM5H4{>lblfyUlqx_8L;-^&H>yN*CjD;fBxLXA>$QFZ1efMphRM8DMul*pM|6i-^v#^Sn z#C}(|Gbw?Z@C?Aknj*kO(t~Z?>6_nULQ`sOnZNw`=>3F$&8)**|6$2x7=1mmvWfdo1(ydTQ$QO7@m^VrWCRqzHR?^ixWjK5yV-J6?nYf;DmEY<=R`7Z|F{s~TS zcrS&D_N)6NHwJKeX@^R!2Dqzt;*;4w zaGDbZlT-C!xk_?_^OFNw`M%qH{(zyC+%19~|4bqFPg2CAi{DVeUVhn8!`au7oUWU7 zXT1KY_WH+cWdc^HIhCo|R`ZKCkY}AOBLbCQ^#So@u|spF`4b}aIK^Jyhy6-wxuZwy zYEEhjfe+gt+J^9mjDJ-p`dpi{Q?W|U)=Y-xB&&T4{Ux{7-O;)hy(Yk04cUUF?a(fa|x0V7@*_cKeo^GeL|NlxJzH z`rZX)$7i;A7eR1kKU2l3)$%tV)_}t;ImPP2%#Z%}2<46o?c-g%{yWOoB3C@=2YmGa z>YV_2l{o5UEsKWjQ zsjaiX#|g+Lr+V|lwd)5V=5^cK>A4T&ZUtS@sl44=D+b-P$-n9b#-KVhwYLPCiCfBY zKggaZaRXdXqoC;l9ij2_>Gf?Q?s}n5KS5ogKRfDP%n7gCN(L4Crpl8#Xd7eyG5{Xa zq)xYuRzt}V4gxJPbWz_vf4E+^h26-2H}Vu){3q1g`F}zWd7fkE zz-ZWZsCsimLqioy4aa!Vo{r&@??g?UTmdl|%E{^c?!H(p9W&UIyy1TxLhH`sI=!C! za&3r(qQ(GLBY z*tS2dF&QDr>9-caCAE&>IJ(0q3#xkxypH{!_*-em(%dumR)BKUV}Gc*;|An_UB3~j zeW?64S;&EyzFwv7N-nN0OB*j9BppWJ2Fj2VLxzwYo+)Lb@HJbck?A+D)LY31MEZ@p z+GKfG9`@WwZhZzcl$yE^bUi%h_k<&Bke!3Yb`MYa%5ytmJG7gRy00fUdjKMgr$*#Y zQ*ySoEx*)>#Rbcz?cJKcre)p~2cO_PHKH^xoZ(JaF0@K7(Ay|23%th6 zN0w}^X3E~%pS}$`b(mWXb{uVAP`2}0cyPO1?#53&OX#A~$onV(E}vL2B&Ip6B8jrs zdT6Vmc-ipu)|C?jL^D-U0(?HP55}1>GQ{um>CQOpyaz^Zm!IDNI115%%ExJS9%6^Hr%#^2NUQQ&@u{?N}|fY|wVkJOe2{5Sqg_5bvnX&fnW z0^vpPjC$vs{aZ%%%Fq8BUnJUm+(Vb#VwMSfE!P)pSHYri^ey30%nW&ZxL*+p;?A-1 zxQ9Nq?a{jvu+5KI=+CV1g&|4#`}V%#{`CKnzL56C6j`uaXda|l*FpzMUWJ$3T(uv2bCN=YkmPhf$pccyOO5NspvHeimeS-|}rqS#0-k>P}1iG=?;I|^3W zQV8I?l4~0H5p=n4L1y~<&7_ZOY*@gj*p(Lc++3ZX*-6B6$afW>Q0wz+jL_iOQpy!LS?A>*_$Xay`_j2b6j>AixX`_%7Akmm zl=>C%V2M~yPqcS}p}9WtnOv-~%8rP^7pT+NQh1ku%uo$^?0BhLPfzTB0(br=@DMn! z^sG`q1Y`y@*591_Nk_)51MJ@x_Dp@OCPRYl+zkNmHj_33O>4MMhgEVPhk(j_z=O=- z%;Y^G8;ZVmLw*GATL}N%7Bbdo52UdV&1TAXioT2@XHiGZ6YhRfXY6w(_1+G%GtDCL z-oCUecBTLQSwO50;GMC}CfF;O&`Y_|`87XG%V-R^#lu0XdDfl`Fee7C(43Ov$cS~l z(2`QuR-CHc(s6pyGJMX}ZL}FGB*IT@zJa}lF};+lux$~`l-FHh0r!r;jVb%t_?wAA z$20-(4Cvm?`;*|`d?(y-)jOF0%Tizqy3&U3n9ak3o4`wnzbNw7F)!$jY3^q>>Q1~5 zV^35uo+TXs9}92Cf@JVcfqdv{6~@)_zg^rFV!PS6$D}HJ_p4`U8&pV4myRjct1kL3 z{V?GMi7eg9x_uw|y{*liNc~>B5_Ph#$py=>&2r0AZGi-$Lf1ibcD=uliKj>tW@#g*U<68vsX7#PIN55-_;sC3$g*85O~2>a zIWI1fsv!=Z!2_-CDz>i=`1v3wlip;+yjV84w)aGD9>L}k4JSH-d2t}CRXhvQ&5-=o zZvry8Hy-z0Cn>4D5WGM7fnNZQx9?%l&FGPS=k7Z zIOfEnNLzgBdqMt1Q`czj4dcEWBvt>@7gfYV*Wp|%!HlRm2kkx|TZ^aBr>|=$B8;bk z3J^-W zW~HFfWWkK!VM<5ayF^n*CZ6`(o6`}8$31qJDeb+1M5v=-w%~_Qr%34ag{7}SRCcdJ z%e4huXN_8*yo%+V!wJAN*QmWu6oQV^FIa2CFy_U;Z^LiG(Vmuop_t6R56u+>ycmBmwLd9Udo?aXXU z*^io-{(4YrwL`HrQHJ2LjReV2X2|caIdEO!hC?cj zdiPKG#|;TTw5|sVMgt!o&@9J1U!cFIYM9<%mX|QrmbGVC`3_&2!gq$|03&d!6O2!K zMQD^&Bm2wBBDv2px1`kck{;7CwA4lc+SJ}|)2Z5Fb`4!>hNE3#NZq5Xf_FVtq~zu% zns{ghRiMXODjC<*!re#IT5DU zH(=HFk(l#|spJ^sL3!IlfzVOtNI9@Cc1k%sU7Bq+l@AXP{5zS~&fjz`fVrDstxMvK zl@OhGWag(Q{?Pj4^TUb$>#VWv;= zokK}lD%gSbJp~3s?^)E7l{VyB2gvql#8f`rG}P$!+y}hdi)-IVbmK4c@N99q=4Udc zjS%_C@A{hyaEmmrzG2~7WVcytjNeq}xQxrg+8d|oE_`83x>>Y1~ZU6!lCdpoS_-RL)XS+VrUj2u|cIDKPs^IjAM%R2g zz~yrJLc{6Vb20lNi>c4%8R56%Db57D*FUHMW5Qc6FW)R5x*r`JPf_6)cbeT z>gx@WD0Mjtp~82*%*Vg};F@B|A$sknvF%4fR-Buz$@mz0m9CtJBD*O@DG9|Z&zB{7 zhd=oORkOA<*hjD2HSBN;-=TkSYrAivN`O; z^+9`lRu-W((W+|&7_1*?9O*DqbUgX|dmUf0X2C}LP0&r|YL6?u(e%Svfv)r{of^|6 z;%F{riVtqu#6*=6_{u;j`r+I_d-|I#NkTvF3MMB|dzrgaK>R3Ctlfuu!rdI_oiBX9 z03)eMC25TxI3R1lNYiPOah&c+xS7X{1n}6; z{Ldn9lgXOir?Z~inydCsqr?3aqN4`50Oa!dw61U_Fcuq$IN|$KtyM{fn-HSA;tAM` zx<#IJ?a4*cmEYYn*8X>T$B7(7S{Z=)*XfdM*6$Nu(iw#Kz22S_v;~u$EiVCqOocgn zC#9P8-ODBV)Gzfs<8H8=a3d)I6C*wGA_>)GnR~qXMvSHLHR+hQ_2^WfjT`?FcCkz~ z`(i7{?_Zhvleijf_o4oU&ecw4k(tpHr*D_B#*e+;Y0?*FHvVVy2Jo!ex|>)ed_=pU z*D~XIVd^BYxb#}W_@*8J?49&VE5t2ZlehkQ%?$|OWUd!xbIArTsa(?K?vSCW7Alg9 z*R$pX-?k2N5FIdK+BY>>f#Ivz@l04Bj8~FwWU^vV`y3WKmY>pzf>T z8Ec=bnyXRRy+lqty_05vi``~=UZ3?IK3?|7+!itjgo}<;`FOF3q*VNQ9cFFL^NX~C zyDN~W@zA09r_Tp?r%cG&kfsLA=V#$S)=P-Be-59DiY$M9CrhrykgoZk8qnHqF#TTQ z5PFu_=*c{TG|jB@tBMC?x!+7LL3BrdS-!vXORhn>yF<=N6%W<1vw0i)u)m}5+-e%msW5&Qmg0e3g_GIaJ#I4$NoWn-F{YE z;nn8jGECQ;LrPBF&<%2YIY(jqN6mt?c6T_mpIsvO0c4sS=av5C-3PztGb2wBRjtZ4 z652Fq8|$?9jgC+1*k6m>B&i5o9sN)4^-bRD=SbOxlkm?hW;u%9*iWr4 z!p<8epsA&vP{$8!jnmoy%c)X0e+U$ZdEbutQ@IA;9qy^ znc}ieb0r2|K6Yu0R!3Cws5em1PJ{uu$)oP|df3E`7IE0=Ym=dk!*ua zv`;H(23`;-2IHsZLFtO8`HPamrQ<|(90YLP>uiaqvJDOmevfGzqydrlyo90)?ufM6 zhWkxxUv)4Pz-*33qXqre5oz$pu`LaxwE95zeMmc#*9ISl@tA~=Isr_;N#!A_mNtb@ zyDN2Ca|EfN2Jq@`zXyegWUXw2hoy@(EgB2B3E|;?V(Mn+=fiwaf&{EUq96`6QycPwtXnb;^pYX@XZ#XHf%sVN)GvH0B6UiW66p#&gb z6!hdhYk%W+rj2&H8|7%xk-$6gs=Ax#z-fmxv3e_r-&IpNa9s;ttp70Y;p0a;@fHxN zZl6X7B0emr`pD2W2m_ts<6$}j`a+fOep3HiEa@*GoUo<&2VH$CWIC_3GqI#u>;$k^ z#8iT<@ZAd8+WRG+6M&a|S0Jz7x`*2tV$zIC&%lxz0<)be{tjK$COW^2#m(#Z>H!N< zf~4QXAPo^CbNLe99xVezI(cvKb}0=^0YmFUD?Gh?zpWHKyq=mg;b0ec@Be6P<3qlIfK1JY^= z=nc{*faT#EhtD89Lver!xQZ-Jx`BYy!4uJO62NKCGa?_|aox0q@Zk7?uzmW7L_2g= zIzxC|41irL+D002>5hwmfkj6G*1p5KyH*A7Dgfdb2nmo7oN-7)jj*##;E_ik0M7Lr z&G1KBpw@S!K=87-T|R)9*gfy?27V_3CY++4aK`#*ej>|czOo1=4*mqoh)m&tR@z_L z)21n7#USr#!@lYQvB^-B2zCcTS~?}U3AlrSr1M?{=IkqQ+-UJ9C&yn|90Qh6mt*^H z;_5+%00&m^^}hifj27U&_S8~|?tjm+_^hiLfn{(5eND*z?b|#5J#T^BVOOenqBJhR jRMGPnQ~zfw2s|64PE&vzcnbV;H%LqEzG|g1CglGC{)JB4 diff --git a/assets/logo/icon/adaptive_fore.png b/assets/logo/icon/adaptive_fore.png deleted file mode 100644 index 7eea0233d9de8af201e09a2dbd4fa9b83a77f0ca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 55160 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 aUjUK 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 AV*mgE diff --git a/assets/logo/icon/appIcon.png b/assets/logo/icon/appIcon.png index 5f53154434cf7d23cfbe1ec8b5b1ac5b0c12695b..dcfb12a428893f3fdbe18735509caa25f341fd84 100644 GIT binary patch literal 9547 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%0D1VUv& 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

m$Dc<&~$MOEf*cJ;Q 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 diff --git a/assets/logo/icon/ic_launcher_adaptive_back.png b/assets/logo/icon/ic_launcher_adaptive_back.png index 7126e69e2d4608abcfe0e6b51f1889e41713415f..a4672855c2868074f0f7e627a2b8e8ff4cb14478 100644 GIT binary patch literal 11751 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 JPDHLkV1lUUmq!2q literal 15875 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 diff --git a/assets/logo/icon/ic_launcher_adaptive_fore.png b/assets/logo/icon/ic_launcher_adaptive_fore.png index 7eea0233d9de8af201e09a2dbd4fa9b83a77f0ca..3f96112c1de2873b5dac28d1acfdafcebfef3515 100644 GIT binary patch literal 12987 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 aUjUK zw-VjYdKyYTAAwKa--wB0#n;s#Mn={^>SpPTO%%{i(o87py!H?vsn_W&?0@%nODolU z%krvgS9k`ipgSXJyf@$z&cq!VK_{w8SZ_EN@y6PeH(Sd6_H@XCldBjXs>{|wVpz#a zG3KENhSd7T{y^1xs~YTzgT>S(sa&8L;Lh$3_-OjP?+V??m)fixx1*fSET-MY$%_M$ zpT-0u{D#&ms!jE9dXr>Dh-3QHV?RfctMruhDc|5CTMaL+bwN}eF&fg444A2GB*EvG zmYa5q^nnx1h>z*`>&aqB$CHq>4@W7F>UU|B zcofR(5JpT0-E^gi#Lx!EX5uF(EG`mnFDl&Qok$?<_GmNuv+NryUHp_dUx9^xpu~Ir zwDbzuYvVY^KYCKtyqI{$?*Q=~)SQ3-5!Hv)_5*9?W(+O0Xf4B)RDs0;g^1kn8IeJI z^J*5IWTupWJ>B{-dO4tn_eOCfoo`^FJ+a4h(6Qy2T=Lx&XxB?i8?!T#!9lBim=wN$ zWS(rkNYp6~r0g~kGpm@MFmrn;J*HFG5x)jT_AV+eQM77I$i7W(=SpA6l+Zl;i+Nul z%JI_+5Bov(j(+#>`3Z8FfN#UFe1>^9-RGoQ+WMgufXx`_9Ur6dpfGEsw*)nJ_%X7Q z-^*foF?zl{=YaC^7hZ89jg1VLyReL0$_&apkt!B-^jR>%!2*}& zv|!eNT12$0xuMYyGqPX7m*L;=_`_2zaM336Ez~7&gSL&{b0@9KcK9B7Y4AWcAtt*? zoVkhm@%_>be$A_TU+loEMA7@Cv%9^^hu;9F*j_o-PF+!>DGvGXvXzz%ROc)k`-cWy zfars|I+7~8e$)UFQdY$elboD$4MHp_935f6d^ z9gPS>6Sm7G{xnWbk8ZU&+&KPe2?@}wrT$s}L#X3(2bB40k9Rr=#v-Br^2^Eu9K951LcOHi6$2MS213*->DX}}5RJQ^8EuuHUKXqCLm~pCN zjH^-3kXobf4MZujl0b^@x}8~bC;n;^1wi{ZYjN&QEagQR=;x&trZ~W4E8902f730Q z>SMUg=vmIfm2DF)#ZbYGM*yvasz30L_dDF80}UYVg#7upYnkh(LRiT52kWe?ZU1`O zSDZK?vjru1GO-%7HLIpFP{vT+7hOD-EW2q5SKyJgw2hRz znvVDy$acEdz0!_}*>nL|+hT~;(+RkI@c)|tqnQTLq)7940peL=4-zj49ao-i&G zT{i+hs|c1g!sE+;qI)9c>gxAxv2+%XB&wIn45C}sV($Whe;y1%py>-$RW`)2 z2Puw5)`(OZ&t91Yka6zFOCCLAzRr_6yUHbg(%YE2_h-Itr=%KN$w3$^`<@o^0K>4L zRE$B{Aim+3)?dG3XEXRyGJJxXN=2&as^@#)I(XzF5G#HBJDezJ>S`nQ?u*HV zg!C6}sD-6;?A#{sJ$rE2r8S4u=#hDIO6ZC=N#NL6AOb|i8b5;l#T2(QL*6rj^!dZ{ zOvO7{o4+v=L3#yOc<`v1?2IYyDU%mM(<+0E!-gC_FP?U$#7kR_mZ|>t4hm840L2Yc z1Zm!2Pxs1y7X~)*{j{pkalo(#f$AW2LA6VWRTGM$70*H2nkPm=mTY5(#2NBSef zCwh-NmXr>bSy@oapS^7~=UTAd^ef8*&KYdan&3i9xoG{DN&3}z%U+LQH=v37CmG$~c|$E6kh8=_kMjL=svCr#ZxDZ?ogqv#)A^#e zwGEH7uj;SEN77UsN&}?8Q_Lg<;QOfW(8Dte{b5pe0^kBQwuKHKF@OgZ!nz{vh>$bC z)(3hoUcsaGDn{?ph~F7j94Df?wxQ|l8f_VGR4&g0MTMC)%f@;A)-4x5IZm} zPy4(IC{pd%_AsNsMgjj3=j69yY%NEO*&vZ;9}rqL)jN? zyCNr`=XW&`L6Smp5YFR$Y>Dy`j*}Z-rxlB}YDv5o8R-Cu*dUQEUU@5c=By@7;ij^K zX70&{8d=r_Ab3NP->2@^gaM^pq31N|hDh8)j^| z^jKB9yANvl-%jlRh^x_X(kR2gZ?HA+d4yJKh0r57K-GwDCxKXwW760c8Z(hq1Yj4E z&RuR*!qWVTRO>?B{$(Un^S2k-dtI*kaU87ZkzOg5ZHct;J*~b3IxxZ$!~CMWgn`B= z5mEs#AVF;&H%lL*t^G{b*p|xTSztxA0PCUVO&2?T(meWy!h2=YGpQHW3z;>B=B{eI zUOwZU#FAsRL{g=t$JORi+P%I;udJ)n^$&Z9WR`5DqOm-EXXvO31}J1ttlitb`TEPZ zXMoK&njRv=0h{n`f8>Gdp_2R3;@dwvcWAN2W21-d?)Lxf^_euZD46C$-sj>&gHtm{ zIKjm-F7j5A2;Rpl_nD!BMJ0YIsCq%O(bu6<6C5nqXt{@YjYg!&BmXvG|#NDBjT-iZTcJ}e`$bbH263T#k~P#=*Oqq zGy+@gyzj@8m9L&gb*hosX&@G|5>kB^e-<%X%dJr0N}JV81;~5t$PH5+QvIcn z{8s6uY>P+TE0bs0N~YiHkh(vRVw8&ZBSS9rs^-oncc%z77PC?S^Y$0>ZunNyflapK zHamXy^b!4dsO+&I9LnCxO9gze9*W+y)!V*ghUgbQ#{SH z-Iu#B9=JwtlTXc==rL%Ifp4^#A>eAw17+VIB0y2juVvJ*o(0@b2RlBY^?~v35wESE z*;Vqn#Z`D&ru!rM>dvXG=7 z6ij{o&$k5#rsc=xeBP|fEa*-jTS|x526D5NdfGZx5gc3-bB+P!@!%yb51)D9c3MrM zIxOma#!BN%6d4D7sMx~Fg+B^1w4B<<&&DN5+4%+PD)DIXhlE@}lsS~OCt2h^|5PXD z>rtbPmstR_Te8aba(toe%lc1&-|kobTYQrH;mJ*|=hC6h|CatWAuKneIEWb74yS4r zY81jbw7M7Z=;@b8=x)(#9ypHoWN)-@`mKUf5^+ppEjZK^==CmU!@xvsa+!f`&?)Sy zM(6S2{9YsQ`emC1xBh<(VL&>e;%Y%zouhwwZ2r}PH_=XUGARnu@(28Z?vN?=VQUc22vsu1;arj>rG>!KmWi|=sbX@z;< z2I%h7N=;YBJupxjdc7~iPI}=>OPt!w@8%DB(w*`g#LEQLo%NhRtwsfq=$I# zECM%?aKWb6DDyx`siofbLn*Q4UE+}$xGu?)s8)xf;w(;2Gco^U0MpCK>c9gusgQIF zb=PKxU;6~(+>m%X3_w(_=$2<~3k^Bk|Ab@RzbwFmIiX6dvEoFwpMWB5|Kr2JWEu&K z(c=X0|JMSb#^G+YG5IvrW6FaNNeaz{BuLw*PQF1kIgtbBZjzK>PBg}eMxv5;1-$Nd zvDj{^dtIh&3%TIBBQlC15pUxeTFcR|TL$RBJ+a^+linMG=X6$$-rfOv+heL7}Vz_3@V^*&ScS*W4?%Ajf;y1+kg2dd|;5XoF zJ`buW*4*&e&*BFr-3TcIq+22@!E5PVb^Wv-OLV;ASor}QX!a}czG6b)n6?;W$w48N z*mTV1VfO}AtQ`7D{ALWDm;r5Rg$vu%X%K?>)^{bR<6WLX{!5O|FR#s_;TgZ2Bd}24 z`P7Ah&fui1D&SyIX&3mA5m@})tDElV9K?+{}ZFmi^2^Gxnq{Do88tZQCO~2}$cK5kS|3fst zAZ5laTOhF~9g&yeJ;g=m zRYIQ>Yh;4lvcnyRc|4h@=4so0K#}Q=RbXr2{b7679y*oBu14;1GW{$efhDS2N1^&r zZ$>uByIKU(GW)(9+6G28HZ?Gv8t6z~%vp_J?9adcs)ukOdFMy18~XmKC#X#nJ-@P= zh4JS(6Kkwl6AN=}!wYe1`3kW`lq&EM_e1LHE0RY}&EZ75LO!AV<7klv4d>h!@Tk zD{t_s8P6f#5hHWstUiqOzyquVAKjMz02SHxeftapV5!Z-n1O40qAtFC`uNbau$siJUP;p0*A1 zmN1)W{BmBH)7U2hNita%$JO<|TrVBH_^c3&%IeZol*WgqWM2ZUs6JVM7s}sem@G%x zZ6Nn#so3iqRh&`7U%qczKaDA;nBz49`E*ie}7GY0j3Bv}P z;tnYwJ|1Hx=3wXd7RW|Ycif?xy)q^HKV`Y!QXTfXj~T4$25kITYC}1@Z=;545ZJHGXiPxIC7>#1Z&S1+Qp#M%0x zU4)$J0p@%M8@*u^sm4dihti15+cm#^=gX=C&#zdSqLpa33V;qawb};X#CN}iYe3cxT*AY{g)mLaIJpw?wcxu%GTFCNhMKhQU|9LV7 zC!fRkv%ykojo3T*b)y3eWC`B`eH|rQyX4R%$Zc+rKqMHcE!=p&g`Y+syPjfv!EsT* zS8F4YUy3BQq$NUj;DkQsU>ex=U|VK^yGA-RF*^lLrwYfcbm-cM6_7pq(s zlF&V063TAk3hc6uM3h;<$oGQA8EAnyuF#9*wioqI|7cNYBW>lc&KGPZL! zgoN(hrf{(o8fDhF{Ed~l?#xPm z*d>@*B}lv0r*u5U>WqYSe+6@DbqnUsO;P=DES_xtho`S_i1H1#es}4T?(UWa36TbA1f-E#8l)r?kXj^_ z?oJ6o8YP$RMkJ(rLAsItmfyYi`w#Zr=Xqw%%$zyL&^x?RP`pzgODJ2W&1yD9SXa`Z zvk(I{_V?mdBTEm;pByZ&-%n2m)KEC3tah;fF16es+r+<_*Ftw?8tDwQw#QvEHvJ5< zrm-M9lPRILPfyuw>3IZ-=8%ne17ZzWc6w=GEWnn_Agbf==X>>^jWI)l6BL-EuB?Y= zl~ijs5BA@uZL$*wejD6bY8hT37UG|sS@1NfPjY+P4mLjUs&SS|>J)FMK$;piG~W01 z;|x_sF*2q&ptv>mzCi}FF{pjAm>nO?>KgO4m6#G|4cNV_IV|kGB=lBHHRG}pZ#RB> z`=W~$QOM{E>AMh4Qkky)JdpTp2jksn-uU5{@qa07hS$5nhADFT4|1g)Z@Yhid3|*;%bx#AAZ#GJ~j2X_FcwN73e<@9f8}(M|x!65(vl!s&j~1 z_v$zx&vVGf_uPvVj|G(4U(G0Y>Elch2QO?+{Vjl_qQEd7@s_%B#hxd~T#6<%Z+vb9 z;Z5LP(%_1Eb}iKy1YLHUM1?OObnT{r%NLTNJK$IMJ{yXS@~*G6(=@u|<{7Xtgei!R zTcO)gz5iSoF^;H~gAM{tkoL*W5)xmD-6n-CpX|fKMeR0JIxGwlg0LJ$H;TuI_D9LQ@;N|&E5tt(a(syQADjVr4K|HTMD6-nPAE-YKc|c>d z;3W!K->^!JAjVwIBF>t@hWN`*V+UUCU7CQveYzA6y4Li$ zH%!^T@_-2Cmp0E{1ZvixITfyj4ww_COEC7BdqK4+g9_W~z4^=)>4MoQQzn@__ap&Z z+*2E=mD>)ocA%8jpmx{trK&uBW2oj{vlg|R6H)QlkB{-)PU1g=oao07pGJDKaH80l zO(@LCcye6RtJG&54_)#caS=;;wAB7^CYd$sQMz%H>xb;{POO5RWc%$E6nJISKxOR- zUtg&>vjz=23CNoBZ)S0wuv@4F@a48&yi%gJ1(EGuw{)wfJ$2Am z=H$GjIJJ6EM0=lr!AdRfr>K4T4sMx9j=u5{@WAu%R%LLt>OnV0YV=95?QxLG4I7kH z@#A*(EuM`fe))1y4+feEmc)7n+i&7FwbXMpG-}4qTd6$yGm-KfIg#H_62v}WCtlq2 zC(OeN)@pfSc_}%y48=g+R5?gR3+uz5kGtj8t>gXjVImjXzq(?T3rfXbz`xv9pe!`< z4;o7h8?THxnh^uTS%)g6;yHpdNx~0>E1Od74-cNt+ARx%V_%CQFTZMl-I>yRJRLNM zXeC}aa-Gd#3+3g_pM&-@F=B#-*OAqlze|i=i5j)VZWenS8qt_}Pha6t>xw)nxX&pM z=O+hlgaBd2w^pVeWO>FJRg0o^!ufl|bxcB}BJYBG6apa|(({VPld0Uq=}qGgi`XQ* z<5P$13l#dl$!{5yuLux>bQ%nL-5-P3B+C1kj&b$sHb}Sy^>- z*l5_(fFxs#uggjAn}v%7nu0eMUJ<2MG6ae~J#oO7HK6z;j^efE_vkNb`%4`GUn8f@ zeG`htr-&2~`u-03Z`pO1TH)kP-pWvuwFXi*0+3Fk$4^^Vk(QVBuI9IVJvfE_gvP76 zk{**)F3s%PWHLwikR`9H5Lh3e5&01?n_9aYICFMjonlgFNC=>WN9V0nUugi1jwz4( z@s}tbZPh0WjdEBH;CJN8ohOe<^W}vH2^L4h(!Xm&{f<-yZ@7pgJ)#_*h1R9Hms831 zhac8oitAlb=1VbJ{j)5^L%w%i@3ON@aXdj3s1qC_YuJ?*^TjX_7-IEO0;_?cyaep6 zXr~9lB8n=o&o4sdjs@1foKD!6X!mE%mh?Znva~2m(-uoQ$^jAB8l_e(5e8FT*4|iQ z5hB|C*M2$_zPaNHJ}nT=J=;=6q^nyxtNe+Z{+6MgI~ZfGUSV$f>VjCo7mujtX*H>Q zv=5%NrP*87bOFN;%vMeOCkPTWh}%Z*ECGw|NlftV36JTbpT9j<9Dd0LpF{c2Y0b{C z!`1zzs8J-%h~WW0U?X6;;xg%c@!Rw3f6JToW143=M*nOvnQ3K1X)RZyZ3S_E+`C}qzEu!?cG zVT(CNT%c>82AE88d&PWI_Oahz*(21~6_+)&=sF1YtDbDDSy!Y9684_L+a{*L3ClHG zjkZ_9Dkm`-Ym59H@0;@fi4-Lf#TPR7cE`aQS!F#4FwAilTYoQ=ZQJbC7J`cb3l&VOGaC zG(f`CcV3tw^u+$`LqPWBW-Z=#s`=8b#;zxf%p>Fa)lV3&iDea*QW0YR9XI?elZi^n zg6Y~S`Uk`U1qON^R@QcZE&B};jB2+w12qm9(PzeIZW;eD?^KVKc#h-LXGMoyx12q` zY33=W8WL48`Ai&xxwUXFg~i5h|7PMqXns_$h(WC$+bLj|@}KQMar|6}4ROqq>swYY zO&dRfL8Cj$!-Mc|1TJMLZr#EK7WWkMw$k1LrlW`p5xRv7q(j_Ws4Diq@vxrMwkDJh z>mvI?pdTU}mp>BDp5e9MUI`8=Du=t9hx1kf$$nOn8gRYd6BAqC^6?E9XvMCGmyrvw z5~20ZD;@<}^QQ<$Q`sFl&0K473h<68*+@7rMtWdUjzo6Qn}y#aBb|rZDd}U;nrh*s zEsr7(sBN$ji7ecWcV70czsT{kc@i#KX2f9L@5V{i{swyI1j<^-!pInc|N2Wn$dX9z zq7{N7-H-_vsJ$RtZEjm%Jnv@aKhfNJv#c+`x@gId`Z(< zFJShQ@zkFD1-IgmMT$lTMdJB*6AQ?oTQ;%}*ULFLQq>y6Dggu31KRjj-cB5g&c2MPemSQfENbE#{Fc2f1VrlZQh*K!erN4F^7KUASY$Jo zP=j~rh%1?;+8fN+;4-8xYjYTX^8>|9>e2xWMx7^87yC`Um$E1I#cDguN@}k|x4*iH zj?bDBYrg}s5ZBm{Mn^DE!&)Pm3W5U8g2B-P!#!CpNDGH&Ad?N(~RYB{THgzYZEY9og{+O^ufov~r0 zpvoUdc|-jkd?wS|$xbeNHzQ;POo5Uf5i%JO$iL>kqJ-F0C&;QYO z(6lQY==HAI`bx&s3tU;k$AWs==H2-Thww=PxOJ$3w#O(P&z{MYNUVulp zp$_?{o^mLF5mNj-@wPk+OC)-IuIMEhaDlsIvHWNPH!0ISPxX1L?&my)^uAkXT<_OG zq;Yb86??$gZAuC271Gkf6wgko?{)<?HEfr6<|A?1Hd?DX%KF;#qUnFnMaKH*xN zbU+bTNL%Es>RcL@18yBK~j#_=*+tvKCeBh&*2>bgd7mL4n z1iWJ<(97?|NM@t9dY}Z7DyjTWe{J;Ju|Dk0z~=R^+>EeL4tlT$__SE>$#p)9xV!9$ zh55}i?1%XEpZ7kSv64+TKIBJKMx#sLW63`h6KGiFz50qDAAe~@!>6!S52={C;%$6` z*A%?>3x6VvQz5fY)@t+bD}(J**^J0Y{p+}4A6DJ3w$8_H2r?wg);!y}V)(E_)>j(9 ztwYe<55n_Q2bE9+$lGtL@?0DvsFw5hYqEd+-*M9@*C073lnG@{$ngsWjL_`{ zy)YsBC%~qVI-z9*hFZ`V9a&v4ELbCgZ}D90pP;$a_sKmI!UA;(rPY4l~R}CtX-SMiJ%Dsh|WyZj5X{X_f+?h*5yX@Q#;$mL zd|K9(N3tt~H8NcMUeQ#oo?XxzRhOOWq^W?mw+XGY%MPYR*5p`gK5iT9StoMW6iUNu zSX7X-L)KUI_Jn@1SEv0?&XoY?s#WPMvC0A#c5^V+)oc?gR__c-@N&v}utE;Bb*Ksl zG%DZU(}dQ$8~NemJg}Zq z^YNh+hRa=DTU~@GE>*M`dxrNq^%#p!oZI!P3q3W?sO{#rkT z-*RJh7@>q8UIld%+ncv;Gi{{bJ`G6tLS4li!y@ka`Ami=AC%zVN9W@R*DT6JdG+8b zUdFm1)@X(=jZL{0EB+=mMvMgi7~-%1LYz~kB)YxP7ncj?L>PMpbu^G?7x4I|;Xp}1 zt?9;m2?8rFz(dgRY0DEvD=E?5R7v)YSZiaBL#Ev;L+ zQ#aU;h#j({JYgQ<_c&PNuk4+yc#PhwSd-e=_Q>n(`ZEOh)Ye{b^Tx`q4KVQM3e8^T zy=_5ZsDVwXa5#%AG{%^?bafw3noAH}D2d)vr1ohq2Yuaq80vC+OJ>%BuTDf{eI5t0e`gqeiv^RMb+ zwNlE5L2#3 zK6z@ykBk(zIXq+(154m>6-A-Tf(`U#O|*}`sMSHUaS1IRVMjpez=HqpwkC^2t}yRkA1@w_)Jr3 zU}L2={2uFJWF^y&(Z4%sWk6r-JY)H`h?8mVTl{~06(B*`s~fJA{4^Zsj78) zT(oV#efqhJqOW|SjRe08*Xt@<|22SPZj3zc7@b^CvEWPs0mMw|9NK>$ob}%a0|qn- zESy(dC9AfToeRJH$7gav<1SHB1Nc#1>yfir)nVX>Rmcck|C-(&$|Qx=VVsH`?+j~B zN&&gu&m3xBA0B57XcVC<>p zQsG%rV*H$VIh6#1mw?~g2tAFoFaFg#{Xy*U8V)<1$(fBFnZq_*y`qhVpV;B+A?m$B zMqJp8DqPZfn7^p{m`=^?Lj{B5o-kVaQ(g;dPf+%f&>+8`#CuK^Ia$FSv%*9TdR{`o zERKu6dO>dLl}C^M02RZOmW$84Nm#fQQlenidt=X9lKV7!w(VFjI=^7Tx{jBptqqb= zrymc-{92Bww7}DA6rF&3OJ)_(nrJ;os+X4~7omaHeoq8SHB2Ppv)_~nLN~#~>I#;~ z8`H2pD3=1*y#KUYYyVawA>hT?}b{JMwsW)8q{hlC>kff-hAO zB{qZAJOFbDlqpFD7cz(Jl6X}H9dRtP(Ol;N6f?KHOXZvb!rn2WAgG6JLgH=o#Dn zl!0gmS}^7e$nGnoH33MgRLHVtRr6$0FiFNT{6teEquKAo0))jgU-`_7~>`1 z$BKt4&|6Bty5`G|MM(5yfwHkRa=oRzN?92fw}4lo5{-#$n%;V2Uw z8q79>x?OQjoID^TMjvx7dHo=gcM^dMpTIe4s!Kf75%k7nketMTv}J#0J zQ>o4TEAqp)HWo|BtB+B)rO}2U3sr85(4Vh#@qz&eu>wm@DTbqjVaTjw@SkjuTC`QP z;z`l)`y(y{!IydBijcD41#s#ejhrM`9qRZWr_rQ6OPN0I5;&dq&Z11V@5#XT+EIP@ z3UgkY{t@B>lF09by^xK!D3KkLaLVB#G=iom-i@*#1ZRkF#5YiGyDdk{y$We&DAxH^ z)s0V)tgZZ1k_?E`GGy~GL~TzUn#)>f2)*Tlf0vS3pa|KU$+zf2fBx;hn*A^8<3(wG z^-nv#-pG)whbJVa>eIPixsoVdQOI~ zAPqZQ?yy_N#9J6X^fKah@QJdi;J%1oSm#l?V&|-SY80#^ z|Csw_GLr{Y*dcqc+15*dji;COSi1OGeFNSIuXN*zHJ;PNLW<8Y?VGuKt{5X*;WOQ{ zxPq-0{l|yezmXYlde3R;elo^3?xzSH#hPU9(~w7VM>Bdt_i4LM*({_qq~%>~7kA+@Rdh)_{##UhkO zvU4G0V5{?p?>(|dD`C;XVRwk!Y$3NX%lOnWk^Mncdv=`;@tx`m;SfK}O{BB%H>j$w zFq={O5`S8BKIsZR>qgV!I7&0iVYEvSbFvkTbtCt%JitpXKRWlm3gVK#IRAPZBFyGx zJoJqIIbp5{Z0JvY2*=5bmobG$TJ)r|UT{7+uei>Bw`_bfko< zc|Y;$jam95hseg1RzgGrMHyh*G8h}Yqw_HuJ@%qE?VI5rX1i1tF6osKx)f`@Y&qG?)!d@z0ztXgx1vj5G=Fpaav*2m&EM{R8v;Az@ zOo(C)5o4EwIfs7_9eejbCPsxcGY7Oibt}m|eK)n~`~2v2>5~q+QB6Jl&iJn5*pE$4 zKvX@D?+04D1oosN!dQS%_Bouf;W9-83dPNhoQ`Nj9iAzB*>Dh@|G(9(ubK6KQpel$ z`Z*(3(-vQ;1ezO9q%eajr8<50dKbQu5PtyiV8KfP1Ey5lZH~X1S-3yEH&ian`p(So zbkLE10HBfiMQKW!6i~%x{?MapK*P65d#OUeejj|Cv_sJAwc?cZ#79#+>)`+@Z%LHd zhNdjT^MTs!uJ9Fx($~@A9KNBGO3)_Iop@o_g82_Qw(| za>uG%1V4QcE$ie?jd*rPbPeaZ10SWniL|Fa)$~gZi{I^e@ip}jGd{;9>52;n3XJx7 z88<5c(_okr;v;l>+pu?`=mh;#e?E4S$Gq>)DdAE4Jz#87GU)nqV|u`)v#`y!V+@1^ zAjSd?p?A?TAaro-HV-6uE&#xH3I@Rc|M`p!(b zl#4KM(D={CW{@~rhsYfJ(!?4ewX32h!!YZJ`Bl7}^0tr=+CJw6j_G6?#aV+mDYTAw zv&`nDTeB?uPbkd~uWD5l!cqhP1+6_JVCA{1DB;D6uI&7+*^7i%P#bIWitE1w$DTtz=emIhT^t$|BY`T2#pN zenN*Ku@s4ZyTu(L>(UkSnT{fB8{2iA6CR=){~|c>A$FlM`@kap7mX%P_C&Im8XosE zRXhAQ_Z2)V&xbJhuA)3Isl_R^L*zQ6607G(W@S4embt0Uo`}zD1Yh@8jo`ecgMIGvrtvnr8M>-jnvE7a_+z3MR>$~Ke4r#)iIryu#bCmX z)1EH%Fhtk8Zjh?yhr^_@de*fC-4&NeuUK(uq!u!gk*%nggzMl1qgIh2(%6vRawiV# zCQ+z}KCCI))$y)Zn6KwL>29C6i)#fzmQauS9p;{%M9#q-X#(OcZI;yFjCM!VCt~OT z(Pt6ndhTqOgb!qZ*ST5mQiIY0A zlHPK>AUUdif+XVI;=r@r;}}2B6Rq8d$$z-a=W0p2bO%NHHY}ATI$9yTp=%Pdhs(*n z0?d6NK8Lc%)}wp>4w}q?&k5FM9BJ zTn-IGbsE~Q42lRW# zzX`<4j+SFS#E$Z1CWbBn`VrZ1;_a1|YUDsx9#1N*5^PX@%xF0W= zhdMsZPZTz0CoMx(JM2f8=CFfycTb{^4uj7tvuB;v?5Uza5dC$F_QhE&eU7UI)M(+Y z>ZKi~RculT>&i_RMSML!E?I!8gS?p-);1!LYi@WK7|H@_=<8}aAhynwA#rbvxFL33H?Cd z9T5TX7^d*@lSwx5MY`B3hSh42<|zFMhD_jM;nBM_`KKYH>RM+{>S)b7$o}|iO-Z^l z=e&i?IANL;WdZGKutvKb;khWt=6$Suc9Fs13??%bzjJj60VtK-E6+$ZJ-~-@t$kkSb<7^~JS~fNxL5 zj80U02-;BiGHDiiED@zoMt;P~(*ViNz$!0v(wtr{R8f*jw(_oqfsH_`w!VDWOi1N4 z#+Xppi5zS=@mCFiUVB1MApRsezIdHKVc`z*#hZCBVfJMAqf!Sdqh$pKw4$CRpF9xe zCN?lwgwDL)9GCui%K%uve5uzWnJMSIJ&wVlZ$s4Br7KSuq+jp~bdJu*YLLt4)>0VNg^V0CjeLrc57uQN>faXc57yfbcT2 z(6L6_AtVtI-c12zYeQd{s>xWOWO}u=1$|j=V}M<83$Rl@h|6Vr!nZT@*+r4$KSh*{ zmyam@n#!)3iwYA9${TPsWTtLi62p8Kt$B0EMixjzxUb<`r2J+H`zt;9Mbw->*6UUM z;K4O)`kI{c#=1G=?ACHl0=H!dnPVuEe( z#vU^FdVCog!br>ZgaM>+r`q!F+tgZ%RwpB*9u1!xa5+2qWly7!sP+%5WB>9=PS{s$ zAaysdQDH)4$*|WL&u{dZ_NMjOC|2G&fxsYOPyIMOJ7W13u7=0L`t0Tk>ioputt16w zTSr-m^|z&i-h~kimR0AVxFihmyBh;bEfBEhQHN^u?|N>=ASKBZ^Jkro3rUb% zv~BeEFTLc4%wxM^RYHe#?@2`urS%+z8TDktJqcvRUr@!+EBsLCH`D7m@O*gl(`Kiq zt&h}y%wau!s^`Ty>t9nH%|luF*&xZNcq{P{l>{~UKX=zFc0Cpz<28rR>J&eZ zt5H$j_@Y-@p3|Yl!B5!r1Ze*kezagZY4`(c!~{rO9o-o#FI#_4)u^-PN-2k`nav0= zEvJd3kKX=a1ZM9lv~vHBG2qTpzxYTLA}MU=1#@8bDKqgijE|<2a}gJ)C1B0#Z~lAZ z9t>K~?@^u--usC~X^N(KX!{;D+_^_QHIjGW=ItNF)`gQ``@_IrjhX!@iIvtlAWzxw zdIH(R2jJg|we>&qEwJJ+I}6Go&;M1Ovwi}rv8Y6VM)6kBZ7*= zqujj8^|<{Ja7tI&%^Q>Gu6rMfm|ar0#tYSLp9~WMG3VTJP8Q_Czqzn8s9~sw=cqsL zrA8z_@8D0DE@q)6hU|Th_Te)Wf)>KV%i!%#$_$7hJ{;)jJjZp@e}mtJ8{G}U{tp`E=zT8Oj4Xl61yX#_tx+>$Q^hQbc z;7iyU>N4?I*H9x)j6+p_IIw37II5l|$6>0iF68M9HmLeX@I>CS2G0w}FMIJMfWF#d zi4NXscJ)%d(T4J1yN@~Jt+0w=&Ef^wHyE9nyd|S^KcCV+loc)YufZp{2KY3%7G2T{ z>r?&e6D0%L7hq(-5`O*CR$)u9icU(*`{ zsyNiipsFT{p3s}bf6BKw(pJ+3hW0N&$>bP*>sg*dgV&JlGc zGPT$gS}TzRm8eL?-TPu_CZe&8h?8|ki6|A^NAuCce1ondE~U?6zc3W>W&mHV)R|6> zHIE9H<>Aa!&h`T^m%m5Kg;q$=Pk405d%L$-U^25t=tk@Vh{c-m6%O>6^X%ssZ~cOWszsw|g2NVFWZBCX7vhMCYyP$iIydrAEYUB%PlWbiRg4-jU+wWgtgfi^alzNGZs+tB)*^=*X%$pFK3}BFyo4S%5`F$wW@;R3b+Fq#qLzp_k<6CE?Dd zmimrG{=FHq?H-kQnahxW=NI};?1h-CdXY^Ly5IQ7F_?j2dbdtVG1)3GQKJ@}yvJnb z2(&*BKW#IgQcOMM*xtkAk+(ZFDLHbL2*A@6a1kLr>3I#rl#D3dPj>|K#;iDGcqUHh zxvb;ibvI`yd%25Q;mB2k+8O-eFSmc-!>%yp$P}uN|W6=vzU-6oHB^kM3G1Y22^;b6olW%?vVnPJ-Kmt-R$N~8_9$wCS zVi6vsqe6pk>7az8pp8Z>qoGUqOEs!~9+aCSkoZfv!g^|+p@{JkuQDW>6yj z!U(FtHUA7-qIWI-%o*9tpODI&A(wEL3?hqXLZrverY!V(VZwP7hwn(}R`!RL%N02e zn7HNO(xq=`WOO4MTAA8^XN@nj{@~9hhSfha{k`j(l=3sg14hfaF1SD>TE^3RjWy8I z2TW**9lN_teoz=CXrrb3ZKAXBJno{iTMe8b2s2FdUy#K-SFBjd{f*L);%wX)X{xU2j=j!sUYv&fXP zc~au@G8WLlz0t8$mgS5w9(&fE4rGS&8tu2ik&0n#*iS*@Y3xr3G2`V_Zh#PW0$sa%xJ+8(9Y^enps zFp&EKSVazTs_CVgfH0*h+0e>fsuI@P7MzmOJ;-oI93L;JRUBa0~AZ_PJL)yI@ z8MMgOU_x7F%aUhf;_ixwLC2-U@y!RBq^LWnoO+6TTAL4r#Xt>`^WcIyb>dQ}5nnl_ zB6QV*Is1wwa;DcOc!Qrbc?BTKe5I5SyOm6GPWv}n=0c7~G_ff(!Gu5slp_qCuC*P* z11Y3!JzL5vAOhY70qV0UWFLPhaF3jxM7^$XRq;wL%}hAc3XLU}4V^O7q8~c=uTdr& zRibHzq&7&h&9#=H*8pl(tRn%12bTVS-{Ri#ZN4nQX+XLgmecFLae0pMwV5EN5XeZ% z>(9Zgw#;Ipy&MvZqqxO3?_At8R<;Oj%4vwi&i-|J_MLC*Az}AJA>FD5g8^u#|`~MXqJ0H?^zk>zM zz~A`e#b=}NFcZDmk)PBD_8#8yLuMf2;POxC4%O*f5; zbd6Fv*P!pXoeKT}L?jzI^lr2ekVWha*ax-$_6|JCHVjgWPX!UQ2_}k?GKhjKdgz(? zRNCKrhm$auth5F)-<%@()y_Db>Fkh78|l=a!$qsFY0r^r=GcoZIHq%NCN<%ijp8VD z_X^ubGJjyQqY!r^0tr3K)uG*mO=GJ-4Mf#PX-FKJq+bjtj&J0f0V5+S*&);n6$Z3W z#>%}Oio*2I_QZaUmlN=sg1ZAz`?r^ukZt6?NM5f_De^`$ z(lI8*6$a)5YytwxG;TC&Jxj4<7N1a0RUA+RPTG7gjk&+$^rb-W5ehJ8-C52_&p{Ch z42nC<%AQ!68eu~MqL{wKw9fB;FIIx^dtc)EWcOQTmy_o{dp2jQ#5(C@oFn?v3s2zq zfI&$u_{D>XB)TSJp*Wb>4d}aPo%{%|#5v|79#T;D0X&FEt}Q`Ma<&+$DHh_F1kRD5 znApD*h@Rkg=5Rs#E#HOZi)Pv{LUc$2AdNiSB%O`zLHN3&m)ShS<-U^ogm-IL3aVR0 zX#`bi{{D%xHKr(%ciJ?x>p6W)aEHanJejI;iOgVWeTrke`GGdTa1ILb2UGl$uX_`0 zCf^WC3FmMtQ%-yJavU_f!pO-iRi5hLG>uM*fKU39Io{bxNZXw@=2ax29bE>rinRGt z%O)T0#Di?4T<@C;N=E7nhI@Ytcn)J+?MMR6;H1^wI7bqcmlIhShu5Kt=Z(@wD+=pw zpAP%bDz(Uzd?MUcgVT$MX3*5BGNvif?+vwLo_-Q>#nblChx55LMyCnd$0v@u1#*w5 zo-d31OCGAgFfh7m|-QC;$C!->&ONoG+vV|aCy@`5F2j~9!U&iAMz<=5Er2W-Aw z`e4j3SzzSuS+1+7X2omXb$F*TXL&pG2rTqOUm=%UizY~_F@ z`FZ^CBgI2SfwWuTiC6SFu>+|QNB+S}S(wmZ>J;{^w>3`qoa^t~SM*^+Yn?iY*mN5a z8n6LhNtA+6KpOu~DgA-#4(W%slR<8c z5a2}D%vA^85T99HJsfry;Sd>gR9(U9tkg>GHYWLSduoA)-K)sQut>Yg3<^nCgt6W9$dN!uTT}$jFrvl9NX(KCy|4Q2PJNR| zB(gtbx3$%nd!UBIuI@D2EzfI_6>MJm01ynLjOLs&fvfA zEgYMv&XzLDX+Q5;0~s=D-ojT!0EX90NFH$o2893>xRf79qO{en*ziDw6iA#)fS zHUrxYqKYsq{5>Izb77N&x3pl6hdg|jV9ZNW0&75sCuZ&UZn?M;dCYnv?TAZ4%4QHn%G&P>*SM3|%pf<^r zT|-uHGkJIci`;s4%KIo5%M_c2bYZ(Vrbo#J=EOXF)?#%_>7By$1TB!oqcE7wHu`q-;5msS~TqMc=ylOt)$!0ez z%bP<#`*&%WZ%ttprLR1BPrb#O^l7lvX*=jMzF-Ca@b~CA3_)eOY@8zH<=>I61VN!k z1a7kZm}oEZHYV-WILCqG&M*o!4flew-NiTWg53YMXR{ zDYkquWP1-Z&+EfV`4|U<4=D?aOaZT%Tt0B-u`2>TYIBtohDC~Py%>s}Al*+@$D+Z3 z_oJqr2hZ`=C!3XdAY5FOYwmW$zK~^k341Ky7YG~VCNQB}mR*x(H4js)my*R})&5!+jsz80c5O)^iY5X*6NXK$c5}fIljG@Ir4(f zPMrXN>{9uqjE=`;g9a7@b6){T(r}bArt3hf-%Z8G+vC1Ntmk)sbi?r|i4JbWa(@;W zB|vcC#+1N`+BDM*p1X^N^0`m+=de^XB5Ua(jJ6*W>h_nSSnq1Y%Xy9l!A&;VwFC+; zklE!wK4kCY)5n;D`1#8{;%zQAtOv%rvf+;u!*mtejQ%(mLHxxiq7hQ!56Z@jPKz~; zDEo*jc7!$`&9dBvGtU=qy_wM^`RAqE-ol@g*9NYPTn>9`N^XkCr7u$aoQ60IW7xqK zuq4IFe17s6NciME&2n921IRz#Dt`L;I_FfJ&tW?r7?DVxtl)soez5m*jdCJ951r3Rjs~%33PUh{iwn;$u{-{2*FmMZ9si1?&3-i7w;3N? zi2df2b)CPG#_EJvbEn-13ZPb1-_zk*LLezxRhPDYnGJANiXya8Jb z8}SZ(iqX;t3YF(CY|Yu%&}v&CFO)Cs(*A6z98NRzT4Bd_{GVr6#EpM8dN72^ zlx)6F=R*KkZyiMwVJpAF0%ghx7!u&VO}cstdb;avcr3}fB=n9ici4I8U1WKP0h;tb6q~bbkqtyk%g4y=T09OTwCUJ*N)BZMix7yZ+SO z0uE9Bn2b$XN1G%_$I`ajzwH^1VPn=n)=$A*vp1jt44yMamOa^Z)$I&j5nqo-ykgDL zM}-&Q+nYjXx40`0d$b z=Aiiy6Z?<4+lyJSST#;az(Y#G=I)+9m+6AKfou41RHM^zDcM#ns(FV?eDhDrv&#ph zG>xlqfcsYczk61wVAxyAhXVCA?HLo#MLDlGTD=2QEbI25H#xr!v|CkcYR_98O*&ZC za}eIvA^3hPD%V*y9JP^AJ|i!ayu^N6ae5WD$uma!VN%jQQ6g=ZO|?mS{SrktoaQO3 zBzi59ijT)sKwkMl{Qb^kp3cu$>~r*n{BrA)jR7&4hUY^EP)SD`hW8Z!g~z!I=;Q9{ zz+I|$`IM5uz3^&hC5o_Ce2Mv~5@k6FYBqAL*$z(PW#|qLnHK^i%8OrJOM!5B5V!W{qgm6Y0Zlu|}bC+i75I~2%m@I9M&S=I?V<%fNQx?bN}541(}*jw}r7h-?D_ADwleRizBTnMY7zD`BV`#-9z!Q zxqI~iCP6u5&Ixzc5`5eJ_ z>pyZ-98gTbT9*s%V^1YMo>BC9<~4eHBct6|h@ERA+S9A%DLte2&8wl*qZnmtJ&Xxo z_R~e`oeCrM5ap&>l782M?I)FG+v2-ObIEkxsg*)e`s14JE+WrEsbGxFN}7ZiSn}e8 zI0OB7=||JnRo4!tq2EKzVb;>@e76*x-=^dhIOOP6)G=E;wvmW)vt{t3cp~~qIF0;P z=bQelImi0N4>wt85CeZpbbLBr$_VWbUoj6n4WBt+By;@bRQ+18;o0bCpM*^-5F<}? zECx&`Fe^lQQD}OQ7YinID_Unq%kwcNcLQ<0rWI&YKbSXH(JluTf z-CK)`X=B2D_`>@R*E)BXjXl^@Hp;&BG3@oPS31esAscoZRG$m*0;F)nD_lFV*!$C= zHm%qM-sA-7@&-pnlXO2KAU4UaOg#VoB9-K?z}|+x89ax5AFY$6kZK|U>^gQ^CBw#K zYY#nU$7T1lpk%3(&0RL`L$2YxFbvWWQUPM9L(z zZF?Btega^yWG@3YK2<>VV#~`=9=DZ#w6-n6%XE6Yz+1)tAO8S^l;6v6brkNii}E&Z zcXOaD;(v-Cf{%X%{a)kq912aW=LtTt)^qF5r;YgJl#A2$Vufmk5tTEnpSurRE|-QJ z$7B#*A>%8K+ZVOABk9%&A*`qz?)qy;@whG$5*M8I8g~=xzHD^xBV$Y>eQT3CL(tGV zkedYB_(H4H_nMd(>?Pa3Er{gS$ZekoevDMMxV3z`jrvRaL-9 z8D9?cT}R%zNT&oO%JUQxF_A!tC{Na?UARuF9PKm_RXo~N;m0F5_)Q;`Pn*)wp3VS; zq=@o~F|JFUj_;|{vX4>^S=ex7q?7QS5*IfMxCpT`nS|^2OwMer**BhD7 z^Kkt*M3JQnlDBjA-5u)9rXLeFDg6tQuTi3L-NM6g@e>nHMp~)T&Yj*yG=8#+qemO| zFhO>1=gUx=CpS|?iuUx9hCX=cj1T9ilw#op<&PKVgxd??-c#A2Y@I=}70Go1YCKn9nEU3~j@+;^-$ll<(jY^-tR zbonOfJu&Z+|9Vb8(=TrI=;fc%L!vU>SJm12$UQ#LFj`sua!4G1np~GyXv4 zHGa+Z9gcOb+`vGylzg8qyXaiXS@X1lh_&fSK7LT0HlCGz?ll51nb=i(4UmGwNC<4e)#Aej2GbOo%=?I zTzP-TYJ6vs9If)KxODLm;XJGSk!>W8VrbsYl)G84e9e2q6Fz(1a$;J<;wFdm-w=3f zX!?4hT3lKUWsK=&XsKR&ku>SQn(zMrh`Qgv|10abqnf<-36Ydx>W2*Fvv2|y2#Af0 zPz7Tnt85ZTAfN_Bfe=cq0|W$35QG9nb^ry!5JJKp)>o0?LmbEm5U^1xM-(s?v9jvy zyI<7y^t}9+bJur&d))iGQV^Jv_lad4)jO7LOnOJM2Bri#nzb-ebup@-$p zBe#_5>*s!5>q|FYuDSLg<_ztJJMk6{DF3=mK|e)4xw#7woLk4=HTnj*A#1-pUT&=^U3R0Es2RPRhL@nn1CrCSxaYJTWg>;l(*71ddKS(@s!H>+g)!5;oPuc z;v<7XNx0&U?UotXw8`rbz*N!$6Xj;hCgJ5MBJMCbl|dQ!#^l+JXmZQ$MR75|(c_+l z_i*VDWD%^F7te|tPP}l7r^E~>hmVY{gfg{Mbh5M9t2!y*?uvUx>!O`Qs%DCwJIOTE~v!v6|(mpl! z4$EqRCQ{)4KxU-o;&B9`8g9gj$L+elIsC34-w0|&*pktnxAA*Y3fPmHk^PYM@$X$0 zx96k57Xf==#Ykc218ML7ZitAH+gkP{Y<+kD6ge9R+uS_^+t2@OrF5)x*>ZRpq?Z1=&Q_OFXps6rM-Y1m zrP$w=Sk%oiCepRy!$1l`#JEos!nZIj_Qd^JFF!^n=8LpC@5nN*4`%|LN;z+o127SI zzdut3s4SpsfE)9LUS5)az$u!%n1qlziCBTq?t&WI=7 z%b00vwaf1J(G=><>S@M=7d<}AzLOhjCt6@&O7~)vLIT`dv~&+$;@j-~L2=HpRC0m- z&X&kkVrp1bMbR<0VK%mJ9O`J|S59}J=Kawgo^2e#E*u>k3~5sCxnk?E{%E%Y2(rj4 z!1MCP%FVNx{IUiZ!sVk|YJy}gPojZ9?i%mEg6;Gc{ibDCg{r|aNsu$fiQ<9FiD!kD z+a!irmgxeU_aTqI2o<=ms)Tyid7V4ICG!gb5YAFiO1_ zw!tg$Db(N5XSI>DJ34RdIRfU}wH_D5STnXjEv@6tKv2jRKz0(g9#E(sJw%QefqHKu zm~D><{WJx&zhkG{^B3tAZiLO@H041{xu4}jjP=1X68(y+GAYtekN!AYP$%r$~%^q zQ1|8r5b#`=vwfu$3ILBy5bJ$@3mC1&Qezc2T~uOHGO8=-7$XuMgcH3Fx+ zPm@kTj1ihbg_-Y|Ho`Da;uo_BG+qgKB0y#ga#QR1$W#ScPX$Ib!9{0af_jKvKU6@C z_%MuG#!m!Zt6S$T8J$GMfYUk_q(*sMLE?V92MVIsLAd+vBt<_hdZz=Oa!Fx_wo>9* za-MeVq4pm+f_~Tq^KsC3cE4Ly=`(-b*8n(vMZ(<>qBjAB3fmRX)?p93lbc^}Hdp|I zba6~beWhGts~fS95uaDQ=qya_D&9mLX5fU_k})p#-o^x5-`2Jyev<$5ec+jQv0;<| zo`+yIsBO3-_sH?m<;sH&i~69Nugd-EQaVm+l)`|-c{vkZ!a2tLLB2D1(1>vf$H3?T zjsREI8~`jf#6&50|7S1YddQo2m3Y4`4#{pEL}bHlC-x4s!efBNKdA=A0?l>M@%NAbXNV(BMijARM;AVc{HjKX$WQcg5rCJyvSLdLwEPd5MK!|n>k!l{s&1&U7SwermbbWhJqIYBP_57T zd@qT*7vr?od@@bzN{ou`(p`eiW^Z9va;~q*yP+{an%W709mO1hCI`Ra1Cz|w(ae7+ zkM>x2rmBM^f_b%!3qq`I3j(5MjoTxhD!TuyQy1=xuGidWtOxi^fK7n5g*y5)YS{ND zSfs1nQZ6s)4>NdvGD24^xZrs~Sv5R=0&WI+(Kz46aX}?1TArbFEcl3J-+yIKrw;37 zbAr)QiV7sw3|5F3UaxzfDR;Y|&@HtAc9Br5fOJ9%plWr>(7^p0KUasPV_ab&kb6p* zwkuA(;?f>>w8u|;4uzCm!?XOSy9Uxd7U>?K5R;#}_p1N#i(#9LDCKhSzCb#Dntv7@ zTlZN_p*z4Fp%UnePhYg8g1DWXzsQ_HOY>#>2~Eu%f44!%B0G>pLap)B9kJ^|LWkTr zTM)-skvo_#a^nhSd89Y6{FR8O?2a)2VoTv|%p(?p85%I&cILTnzZJtOmw>wXSEDKm z)J|fB7gWK_7uHl|fGKc&Q&CCHwnlB*sP^qQ8^4)j?W#Gr69s;*PF{|U_Q6U21Nm`C AV*mgE diff --git a/assets/logo/icon/icon-android12.png b/assets/logo/icon/icon-android12.png new file mode 100644 index 0000000000000000000000000000000000000000..e625664e1cc1915fdb407367253243d79408284b GIT binary patch literal 23279 zcmeFYcTg1V_Ac6!$T??5a?UxnlJk&*fRb|_vcL?Ik)$Xff&+*oL83|)QF0iRAP9m; z5=0~mh+h15`0ZWyRNcF)&Z#qk`Scql_(FqI_Hw zUAffNNK_(}E&%-8&`!t*KVSb4r3h87zi^cNEtC1 zF$qzv2(K_HE;SOQO0cWDl9{IV-ytr}RJlCS=s+cL@$m3)v2bazfM5@CNkv6PaS17L zDJjtl3DJ;9f3#DCsDB9eABewUXu5^C1bYRdy#oA^e=wb#147ZNTwE9P$bZ=97ieVk zPk8^3zq4?`hj@fjptz)%gt(ud_`hm|ptZs-K>lvf|578wA~MiT+{`T`AT-#;O)JdJ zAI<%*5Uwu&)DH{|_WjEpR~KHUha|qU($by2dQ!)e-UzBuaJxNBmWxuTSb|>1^w;pZ>PRq zf0Ysv`BzaWIl27Jg%GDOH`l-HyukX~l#8d6zlYmJ_xO9d{$t$he~}6@3X*b)PHrw2 zv6pZcm2q*Cx=00S=Re_eb#;=Hkav>&8%h5}4+(HbhdTwkUG})(@q(+1^!$q}q`+T+ z68v{t!ad#oMB#!kQ3+|${~=7_?}Ul}-C^-RJ>wr9tBC)MANcg|m^;s0jW z|2Dcv{^xPZ&Hv&qDE#7K3D#@OdGVkniZZm&(g`&D{r`I#bzFd6C{m!!jLm`lkX7}! zh)SEzObQ_u5|#GP0DuJaG%s63%za(>knUg&AzZpMxH4ksP9tP<`91}AJkpa0o@S%0 zd1LoYNeVrJUE75#=h@2TIYwr#O1eoZDosu$xVQjy@*_s-YNzFNE;~N`hDjPJqj&bc z$BRo3VjO(7WBBf@pT~#~K4=;rUhZ$(4rM0$^UU~v%l7SEVL;bW*DLjp^A>FL534-Z zoBE18OoVB|cA9L5uP&O4d>wn3>N=J^snN$w?)?>3kJEyFc_FmFMsVD! zUEevJzx%T_oSrG?{0-4pm*d!A)wu8ZT-<1dtT*f7yDNdF^qMu$wyf~&yuD{*;H_q8 zTT6RbRb_mgu*@=vr_XbZn!LScLD}WZ-QMF0ydsIP|>t=xwdJuYQ~rOCOt%Qx=vKFp{tY`6Sf$<~p#9yYHRH4z|q*#U=6HtxE9 z7)6h3%waKZsX_Kiv`rrx&V-E2ymB0i+bceZ?zy?I#aPHuxM^b>QSZpfj_?eWEh4q^ z4u7~&;KS}yc&)Y#JbAu;e@@6j9?i@C{@pOZYk=JgXPwy!kiYY6J8J)UBk|`YxA#6} z{v$b$C>NT&^)P$1-{XVcC)|vWT1+kuczz|iaP_htCJlC7u8iZyy(?dvQWdCMRgG#G z2t8jsAnQJGn#{8DfqI-#O7@(S z@XGn>sIA^wmI1-bs&ca*i`i;ve4rL=&>JBk*XVE)hNS>8YQxL}<0veIOT5g6yRp42 zx%z$!5Pi+bPEkQp1B^~ZOU=nJm`q0PMDEmFL_Ov?jt`NDyCX$AhtrnAPJ9TGhM0+^aE<4vNi-Io>T zSN?Kg)hl52DIbF!#QT}FIIj+rI2%ZW+|Q7YvNWvQigj5%wMsT#cR{yj3Ca2 zn9P2N&^YsN2YX&WA}SXsT23jWtV2qKDRgy))t(O72NzKL_HC*Zs?@FFXvjl?qQae< zV%*b7HvGT;^lr=0!N3KG@D^wr{9R{ew2ek>c_cLwVwa*ksisHn{-QJP@j zo|o#bZvqaZjR#jM3dVpU>rAKxQ)rY}Qj1ZeU!aK(YrgEnxt!gveeCJeje48`J4ES0 z>~(AHMbYH5SMKqEnW@S<}7+<#xB1$wYE-6;~yw(~Z`gfNf7Sk7U^+}0X1_H6oTtPuuY zS^TBIYprBP31+e2!bCYdc9Fr#)Q0d*JI*O;`{|zp#tyiSa^+gOXkRX-h+3b@>##+aEvVXeD~Q^OsJVe9weGUIj6#RTT_6sx*+?n@8w)g0pQ zxNRL)ycis^4p+f@1KT89&zq(ef={A0K3qoRih68|vUrqoiW8%D9BRxZH{6nvGWTiL}eBB-kqt`RcN1)+xs0y^n$b)<8QX{HXmHo zla{crt0U#@7KcNIlBGh7S7xU0%Ac5gw`>mH4S&nv4`{U-0{kjI>_m2MyZd>Hm6fFk*9_z;|OQ?+iI&UqWM9i)0LZSJqlUjAttc9Ow#wXE0P zQJ@BwyjPKS=teprz8`c>&HFy2*ZJx7F0T{ahC3Dcb0@K_JiE>g5Z>c04jH1d4fX&( zi@Yk=%hr)qKSIzdXi3HMz;;r!!O7vr)sCXJi(*!CD4Ea8QH|JR{SrDF$~fQ`Lf6m1 zQMYey!Z6k+eZ|W!M55{5%|^b~pvHnYHFi1wl91ZR<3_5vF$Ll+!WVr)50TrwSC1>c zjE~}dYA_RCjd}cmR^#AZu>>Eb?8CeO+3K5i*2~vK-KI6(P4ZeI=x#MH8y|ehOd9vr z)Q(56c@XGf)Eb}vSg!G!Q8qBX^|<@q=1?rSfNH3U)ZB!(AYl-{K$Ypyv&Dzg zwI7_L6IEk4lNRPZ2{-JIDfdbW7^~=v(N^F^eTL1ZTPz<{y3|G9!0<^MU@Ny-_n-OP zz})K~_Z+Ou0A=lvTpDd|3Rz9|9pPb22!(qFcZCe)6thg9iPZ)Cmc44< zc((KdD6+{su6r9|y-bYbPyem1CI&KAS7Zq)5ggW58u4^l8WM=22|Vi@mCg}b{R30( z@+Py7=x?4X7qUa{+p;!k-R%-GS^R+x%wDM9SYh)>yl)i% zd#z*N`m%pGExFD86tk9oPcxm1uTFmk_H*J0C{fjB+;n^g2{m&vrV)yq`dvLbUS0iTV;sW7t{$ zzGkl2WBE$bD}A9+K{i$~oPaW7nH)H*tXGWog! zaQR`TA*!u=qiu5Wt_pLZb&oGV(nxLhzVuPO(PZL3f(w~xt#ZN`lA;Wi;yGW6`W|trodx74sZQKI*MQfkv(y^?zI+A zPf$K=ba)Uyq71${xsKam)~7j}mcQS&#BR0&(4XUy@t5FAyE@`<pVlKBp< zRngwY2B)q{K>+iFrzof)I=>ozZ1Alq`4UC|4E*v}`Wa~2Tl~4Z;R)y0t}xD@o*04^ ziWG(ZooeI#-K!&hj!xs}EVl@VP!w|!zZPzvghZ-EeAXYB7X%_efit&mvHtDC0MY>a z3Cb7<28jGP{!uYh-#GtQxThdIc+kfregWtJGI9-iTf~KSG>s3Y88^N&O0WTCb>tsR z;;L%9=o5FZ`a;*K3<<#(H6`Om@+|>c@cZr#EIKdIfUpf>{L^4T_Aw2#p+sC9vkTZk z+FZ4$Hdg$bk3{?g;9C%H^1GlF9rm8cJ@Nf84yhy<%M+b;_FB>(G3RVZ1?1 zYj~2CA+$l!!ulnKs@MGIO7)b#w7e~)O5H80$~GcFatyuin}}tr59)ZUvP|WBDg3=( zN>n7ba2jvK-zDD#U1WZ!UCY#6K-%mVd1VKBM-OmNatT|7ysCM6MTAm7myIc~X`771 zb!rgtlF0dl3^wEOqUSST5hc40_B}Z;`QC$xJO_*jm^U;;?8)!PQhv?1q#_SOa)CEWOWXsUiu zPiE-9*&=eCaQdtcb#b3TkPsz&h<<#eiEO8Aw`xq!TqkZP*z&!C=OEG?rd21M9znj} zc$l^ES+cj*SSQ@zV(=j>#GQiCmg|?x>1*b1>k?3-VUxM$N4vw<@lQ%N#D#GtxRK35 z0%+Bk@hH|tt?;Fm(;_*MYoQq85Z)*(n*SnO54p-zq_r&hI;$q<5I^bhHu0+KrQBn> zd>QkX@l93%r>gOYJGYvz5NR@AiHw7mxf3Ja;^vQ&PfJIpJ&rVx!X{6fldn%bX$3XJ zh^T=nPYY*iftB3qJ5>+9 zsrS^|LexU>sZ%&va5Iyh9D5~QRe!}Unw6}Oe%>SgT}1{H%t@=yk=Qj)&pVD(r0^2- zQ<+a8E zGym3vW7VW?d^=mAiY8((R%9D{co`n`UfSeIx&~57yss0=XWUKtdH_Q@*fyb>*g&jV zNdJ7jXTVGQ?lmivZ00d}rm8e{qpPx(w>~({lsb&Q{&FF~OgI+<_A4#ba0RD)Ldv}4 zX!1u=tjJb!eFpF0264_?%?{R;7x2=uVLA7gV^xU?p9>fs#i|luowcl+*JEqRlZ*w^ zmL;s(0#;qgphj;_f98Fbq{+6Jr!8FiLQr2|k^zQM&f{cYaosmyCd1NeddHf`n(4%H zv{zCG9^TSQ9;jp-#MLoQQmg;dR=;(&hJges{?r3`|n&3y#|XO|G_4i z!MRqAGd|R!^pb#$sut=@Tz~fz(o1yEm8xERCN2>jC9yWBi97@!TWBx5qurn%Cu4HB z$90e!wA}otrBfYx$fUQnwiX7fdpI`gmDwCxAMK61Mf+S3d+$gK$pwB}Nv?V3SH?fL zoqjy2@m2Wxk7h6kYh&G~CykwSsngdEW^4K2cSx(~YlvNahix=dQ~&nH4svbA|1EN6 z^sVR$xr2y0{6htFI4%2D|Gs%Mys@xNVd0{3J8ew$-Ntd#|$qnd-Z^qsDfRlzdN90uzeQLPz3gbB2sF+ zwt+h`_UX9?7Z0lF7$@b3QEM(TZzX$ppk1f&-VT^f~(}_OVH4}T;a)4 zQ>yPA8lHeF6dQhDSIGtmZzcWM&mNk0HAeM}?>_{H^;I^#g^DAmXdesWZ$onoHppqx zFF`vK)CV3jF8n%@R2HDKO~8`s>DUI{-RF27;O{|6azNwWTX^p&=qWL)H^RuadSd+n zf)$d`Q2JtH)j;C%Tz7L!PXGR7AKEtQ2|t{aQxw2Qb{~N72kZdCUV4#u`A_K zDQ?mt@b2C^*=S-s^b?m%W4+id(B+_ca~Iy`|M3e{;YGgXH>W=6)9L1;_WmmC)Eyod z_!a4LDy#sh&jCBdwYUUWgWwN7nzUlGjQ6C~XkU$ll|VR`B@3k{)Rqg2Uy!R_3{2>r zl@(`)vDsAi-Z%*nY-`1@(N~UFOvgYoc48THaJG9haOfxKB_!&KDcMhkVl(q)Mh*?^ z5D&RrIgRIiZd7JqIlsWgi#pKhWzXx-so_sIOYd&1z0wA8RwB?@@*8|EZfX?fejIHfg6jAC>#cJ>G^ez!7b>n>P2_kpcSD$~7S0 z1gTuL=)-W( z?(_bR2%|~NO=2BY6_P79&)5|2%r62gM$@(zc0vr7EeHHHyx!Z&t^uCU! zICj%gH949}%=tPI6!Fm0+=H09vOhd~^yN&B%B0NVeB@>9UIUR#`c5WFRfh=sLL+E` z(}7oR#H9B^z(OYVdpr(296$?^?-`&A+zfc}QfV*Q)NGZu_O+;x``&^tdwRh#akm^v z>S40_?hO6QV-uw^cR>Z#ETV&sKqCx&N}XHGGSkQxT@eP^g&nt`rOWcKR0Yiun{RE5 z%Rp6qT`cGmJ`W&De7cB#eFL)rSzVi8RC_}C(19x%_&5av}W`? z2X@aV;^cSVyFH zyqIRrv*H|i@QEohFiT|ef_4}k^lo4>v-^AT2>ov~o{E*_I{@}}Dr;MDSA0IpxXwWm zHA3j*azt`a?O3RrZzIExKIw2O|96$cIR=+;?Giy?*I>GmRbaT(MzDvn8J6$o4ZGy+ zTYG1(zBxl@FSSQcW5B_rFucF5{$2Ur-ulKJvYq-ePYoWbqTx>)#F~-%N_HhJ{6Ur8 z<|srecw9!@Y)PtLqI^sKi9E+$5w-d&$;~v3_eZlSu6B_qR0??a-&`%f`YFY^D0+nX zvGF^MAd5LpW!1`|y*sd-{X3OiG)+pk*G`;Y6;T7tee&LIx~=rfjHqPE+k$w+W5;p< zMo%bFt|4yc_rBeD3O`D$!-g>CWlG4S!1N_jiw{Yfw{?e;WBT2i6>sF9zefAz#d9B+ zX1%n&Eh2O9#yOZ7t>N|a6MYKj9b2b6F@y==fbzxnLf5#xe)!xww6W&Ff9qXOADX`Q z5_??2dOFLx*E3C0dt{e$M8ERBxPtx)?lw~RsOf7hx`-fj)RZY)kaD*E^`-ukhTuT) zcPnO(prfQDE9}oV>@Vq{ebZuBV)OW^x&Zlo{V*{Ci&%;c= ze%$kVB(e1SYte`FLNDf*(giESiWXE3mFEKtISvlJNHh4XMCW(o-l&N~=e#V>t3f`> zWJC@DWH84_r}s5l)UTFGbtfc-QB(;M=C5gKltiLRzTyqn@}Dg(UjMlNCGy=@2gk_b z8ora$mhZnJKCb^1DdZ^zvTCtuAHAREwA5UGjODXleazt~r5^Kr2@=C~jE%}c;J1GL zCLek`hfiI9>p3Tv_0%TW2Pw;c<1x0*O-X*OE#IirxzX>bI{^Mufp6)T`yt8h_if!nrB^9?$rZIhY;87mQCU5h8l?>t}JzX zBBcA?cVip4fDH4Gw|NvG{XGb{!>-4UT_L zia$#tcng%Cr0wpkKAnoYJ^s+_F1twVNakVAk2<4xvSqN|=DUnIkv=X~TK-$RG_DA9!7+7-YORoe(sE6X} ztQE^+0v1K9268<8QFtZ~_`Znc9>M*H(X9p-D_E^6y7QgvLxR)vcHtoc&k@HxO-OSp zA#E4ACPd12jYVZ3@_O{gB!bhdt0X=!O|lv+1RotC@#zDIxk(xAE7~q9AdIGHosaSl zn}lq{oZc2iP{}d=dgzHH#ZkjiV$UFR^rCLzX3E1582U)Z`{hky?Y4&iPQ;=^L8h8ocm})HBb;>( zoTsc^PMXiXb{TW!PLDmtm>u)LF1-ta4|sXwi^pQopb3bHH9W6pAWX3_ba>)Tu6O^V zu?W(Hvz?qxwv3}qMSkbkNhT@&asCSl+i?po^10y`U5Y$a(cYW3)CW~tfdw08HOmJ@J4m| z?U@eUGLe!WS5Gk4DCV7uuoP6}`gbBbKeI}EwE~@khxr6lX9f)5Mn53}A2D8m6~T}g zHyA(4E7yKqtoZvWkUdS2ACeHH|x-=Gz)^ z{uW*vmh#0=h+-%vvMBYUK3b_E4_6P8LJw{B)ktYOxn)ojhKuRDWOAVI%O4X{1t`pB zzA?F;Tt|@&0ICX0no8fMHwyc18#C~Dtv`o2`~v_sb8Wxi{#w^36jqT$fJd8R!bDe5 z62P)4m+jbYiCajx6S?Nif{`7rjOh$6DBW#W^Tmw@a(PO-XZBx9C{USOg%9wc1}RRd zCKe5N3nvkSJl%0vLRuOyP?)nm=>tdS>|2OXO213&uT(8093SJ98qNc*)H1TG6rgyH zrm~?^tmx7#22Cjvxv)1GjQa{GUx)<(5H{%ek%sR)AP}gJQ;?@0OltG#Yr9|FY+w8X%P+K20_hAlGonQgvQsc58EeS(@ax5(^ zPMNPZi=X;Harz=Ms4J~ik7F@iV$0fIr*qu?x~h4=LeJ4!_M=T05sUG)sou<$-jysU z?g<%d#s;T81$g%!kmB9ZT_{L4F?U0MF&#+c?lOL2OqRfOW>?)Imc(x#cwP$3(KgAG z2Mhs~L?e-d4P4JAZ6vcASKc3Wjen;?u_K z@lQPoT&9~|kC-pZ7qQ@2|M4wA>Rgw>k0=L7?m+#7?h~V3=xgCe9+Vl~Q}Ga`zKg;q z2m$Pxx(!_)-^t*T2b?*6jbOVqe|=M5gP}X5n@anb$0e@Ycd;^nCX5Kr%Dmr?h6Jh~ zD}nv5o4ceraiP$Ke8-sXkgc7DPDQq=X#ZOMb*o(E4KK&V8`M0;~QDF(zV zr&;c>G&UI~uc@-|dc>>h1C7hf=2r2*?bK@6S_d;Q;n@gm4BBJHl)lcL3AU?yT`@FX ze<|s9S#Rnct5y8Zv(f=+GA2hTu5Au74zVKylx_ol!R>mQe6!(A;(Z-ge~GH(UkB1Y zLD{H~%SlFBxf|pJV=QpI9s%IVMY3x-$|a83oTytuemiLt$S8wWztKPfVAzN62u3SyZM?8zcZ~1|r}?Ca8mn59`GUt!yDdS#n$%i~_GCU4CcX zmUvdAlM3|qImZ&?2PrrV12l`D_dateVdXF_Dt;S*PA z!pRQ|58@7UvC(4KE(M{ds56N{XtN#AP!U%Pv5+FwqXYRRGg&G1=bQ<4xPYL5&DjM_ zi00!vyPw2aZ&T6YEd9f4zOc!hRz9e$eXz9xCWPw^uDzY| zVL0}E(^3w}eIABj*Tsm*QQ>erloV|>FUJao!oJ-HWUE{jrS5&RlRLUlzp- zA<@5hpEUWLs)^UyGWPY;a_IIH@{+dqrCoV)G=}0e6(a;sSVc}H`$hotP~6$j&MnV% zA6laqgX2-g8YvNPoiq;-kd5atcYk|s0fS3O!i}d@q$o#k92uJpXzPx7!1A(^6lRg_ z@ReE=c0Y`UgsQdk7p~y55;(FKI|L}*GoUfV1j{67%4GACWHvr&gw72Y-_wv}4Z12) zrQ+Ps>+L<=y9LOuiZ{zUu4>#mes@S>0n3o!vgLN>dq#_tIzRq=n}wpyV{0 zT?M0J57GXxW6)J_NnluTsqVvL57fBsYO`xnE|W-1h*&&Sppbqf2PT=;|M&r^yP2OvO9My-Z59`e zHUIdI*%T?kZe+fQ_2%K{0WBaIw2!fEQbef0^%t)j8ynIAgooBO5h5y5A=;)d9emzO zo{)ZmfDKbX)rw>zIL`~2G|=4J)M!qGBb8Jo;H48Bnk5n^NNL@@hE!ViNGR1==BuvI zmcfl`C|W>@b{bYy+#2|bLu3ySWFx3D19*aSl#5gb%-1+*R%=6q& z?}{q0dqgOkx3GDMECQCjC|sNBV1+-685^<=LnIzxMOe+=MUbl|Om4CJZCTS+e-;51 z=OCY48+Fi9W_FsQmcsn%qtCW7P|XQ=ZvI#wHdU{#CV~|TEQQYtiC1toLoh51;1l;s zCv-@CL{Azgjx>&s(M@la!2*Q9a#P|Plh^32TB_3oz_VA+A&g)rua*Q1gt(Cfkm-<>Nr{t~7z?Pm;5Zp(l1lSqL{wec z=Adwt#Kj%|T`FAtiX4Dq->kcIsVk3}F*0V(gsvi!^=V_x*O>w}+7YS@v>yei_%(~t zKp*_Y>O0P55!0&WH1K#XWyzNWhAusiGCsLV``fV>Mm1Ox=}aUrEVM|tmU{x+|9)Dc zYtp^w3WToQoZpPR`LlJJmVlB_fF-A6tJ((V4QcN8Ywn`0`ubIED+t+b=+&Df5owRYo`XhPDKyS0}@Ut1M+NJx7(-jFgbL@Req* zsRHaLlbl0)Lum%-oVec&;c8F)+^pI1%GddA)j7mOn^M;$(O0Ryl@>muPI~+HE>R-d z`cr}XZ9T69!lW_pguDfde6fz2?_CxVx)o^i&$ejc2NdP$G)}WYUlodvZX z%MCRu2N&kUQ`Ye|_D9jAxVp5m*hj%k*H|$eyW&0y+n#L;9<0~wO^)utRvcL$#}+vo z=#IGnR0fvc4@W;tJD8a$S^i$(Jbh2Fq08u}usCB?YV|li^QG@nvQri3-1CHhVbxe?D@ zC3d5r`*CTNtxz7cvX@eMjlBeL_;k(qNbc&gGCP6JTV~uO-{X<^@8Yx~ z)sCnQ@2i4&Z~^_p+YEHLCkf(p{L$&;dK`x5NmKNZoAo|rF%F-ksW)3l2rL_hX6~r_ zh+h?Ad}#8a*m)m64w|I$`E4^;8_7u^xW%X|5H}F!F}rEW^YiAphXjGc4tK zXXeN9YC7E%V=X?X7)-1X(53+-dc^KsrvOC+;|3`U>t-jvA9;L|WW9`F8E`n%qXwHanBV6l(kB(! z?Wi0LJu4fH0n7QkWU2oV?pTZ3cu& zSM;$6;k$ly4jkPDBU^2}>RJ|9Vl*?b#-GG7|5XQ*KZc{{% zQ+?iXL^yD19v#`OAjUpl!AOxJ-7<1UA`a~_IB1M@oRI}U)t>mV62-=Ozjc`K1&wd( zE%w}ld;S4~Ssh}xnj%Ty3H}MN*U5<%#5nwu?boV7GgJAN_49=ls36SrYZFpBB4Dx? zvi$pwy1PsXw*+gOzt2p@BVKTMuXXF94ZpHn`&~C?jw25V3B*8N|9ZJ_ODP)Kl*XDw zFGv-z+$uy+DSU)s6vnq%AuA;baI$DtQd zwx=Mu>POD4h0(NHx4oYS#ziW3bSNi z|GsnhGf;J>jtVzPDA2_VdM9x~=-9w@{s5>a@}?{qF3cFdsG?wveKy(d#)zxUbmg09 zsdaUNz&V*Y{fN|ay48HDSfYF_cXEJ@_}=geT`Ur0Q~)`V(M5ytfaf>^9VxPJL|XP8 z0w$n-r;ZkZx5Yx4-P0i6x1ot1bgFtp|U z8jzn9BEy4$B0Lueh*qR)t`CPpKp0-&+9R+$5!n0oW)KS5D7u2#WM(4IfXSE|Q`CW? z{O!|%1eJP+AQp;tKYOhPDxYdW!01F0i=e$T^N5`U0V)U`5=5#`G$TG+fA=SN*u7_{ z8fK3b8Z%m@`=~q%v{Y5bDgw~huj_%%2K2KBWYfQ12OD=<;nJ@{b0;vkCEu@d(TV_j zfhm{{z;!KTCty03X^`rn$Zpq9kc0gbz>hU~fG~ys`@`Y5pUaB3#?+#J{k-Nsbtt{z zVtY}Cae=b7OaSDm2ON@?h08jpL+ILD#oaIbMfg0ksb$ul%zhatUx6Y>@dXH~_hEtX z-TMrjP9fV@Ng)oUX!zGz_15#(4V8$b^T4j@0`55@0EiMJ)o# zTQNN&f!rpQSb2kDgjH_Y!vgj0vsWKwPQofl$1cP0`FtU8QoN6kJ(}EVcIJsd_f5!T z2xODAqKXK|gb=t#YCUiTF+fvfoetySpH$ZL3o2>Nof&2gu);l+nww=417jj!E|7y9 z_(n4oj9i*Lk>wfy!7oAy^6*ggY>(bGt%91aW7=x#|322fg&cEf~c$P7H?r)F3$< zQvXrX6OdNoPA^Q?&%Eyja>Cr5;rdG4hP^`XF7o#v_{iH2vB|_q3N-6ca$iTBQXM${ z0Lr-WuzjtsNg&UC=^tt~4NZU_wvZHRLptc}GmL~I?vs0n> zh>g-mR%}0j7^IH`9Y_gkr;ifq^McW0pgaLGybWfXP10O?&T2zBnJZ12tU|UAsUH5i zc^@u_^~iaTd_MTrNe)ac2bp=zZW4ML@Mw7mfKPeBGa^uDugr}Y54biv6Qg>CWqnUc z(Atz-frzwF1C@E(N}&2Xi~_Mi0KC58FKuQIpeYd>uOJ%^QB+uAs<`=`L728nDW8AZ zm;2n5xJ*}W8g;?mBV-jwC;%3~s1Um)!c^kKQT+(r(Vz5$4+v34useI>odM+Bv;cdZ z7(n&r`X}M+H3I*uwx+bWlL`rp>u`yH>{gGdhZ&YaIJW(bE~(`vxh^;9NFNPO*RrMe z1dIwIP+!viCRC(ZA*TQu`PfyM;Yyg_5(9G$z+Cw{)e~MR>M>y12aUqmB%e{5Ki_}+ zwEq>lfFZ`qLBIfUYS901?snXpHzv=>L8&+s1|jgL{bp+acra_*vIXq*yX(bPw+;|U z*w;W`>qPPq3~npnY9C?R!-lx>W{+?$fF+f!T6XPN7jl5qV7M>0J?pkOK3_YU&H`yB z0}kJ)->+9d1w2fbaxL+C$7Y+A!$}MNz6RXYlIpd4=DfyxM1`=E+ zmZg-m&G0LRV))6PKeC3Sa$;0n?zYFZG93UBAWKfgN)txkD<@X}%>oMNuYd9?FV^XX zfl*8Y=}_?XYtPCHc`FL3!Apf-`mb9$d2oPxpZb2?y~cQN_tD2^L;RbDO^~6f^>UUz zstM|6Y$QCua#qtfsB7NEwa@P*qwxh)%sgzPlLyxOg&5~%iu|Van|-Yh0`5*mPf84r zF}lB}?JKU7B4@=FGkI3vhumqQo_cgCjCHZF$7&@|BUR1;7Hhc!v&S`?R|YL{wt7&W z`L79p9RuRjtXLr2=|OpAWgCe_OW)lS4J+$O@)88qexbo=ZnVw~g>w!_vkZyF-~;lG zdX=_s(1U@th-dQ0LYOcW4A(D4Wj5TUry)F;d)ipGF>iKwe5*Licq*tAJw` z1Q2zg5Sz&ZF;fGqkR0f?)f#?&m&ukIBxJ+AOJy_A1`?lwB)I#q>~e4-?tOdVv|N)g z@bW3I?IMk%p@Eaim2`$Sri3;Rwd6&a zC~uYsUpgjCNth)ZeCDjD_bs6E+p*&D#~I0mFKhFohCH^8KR$IFf1VGKz=_;9mG3Z1 z`GQ#?@t;K{pM|zf(Hg;TdzfEif;qh4sfp-}G*#I1_DX_;zDaLG^behLv9PjZSM+@d z#hO9xV`;c2eB78GXa2pVTmmNz-+(~UgrKdMHi7Evpz22XH`tsptQm^A_#;Ifk%h-c z2&1FbY{)c62SNArWDs!fYCML-{cOnh&b#b1$_1JG3Q_*T;S4H3r#_kX9oJ_<)jynW zpDu_OJ?-2DELZGm3Ct>g8)D?5Upjs)Xy>9*FMPq_BOUmmqS`yWqni@vpxSG->M8)$ z0xFB%hpH%C+K#3s`gngav3adjpmYD?vpR29aF1ArS^x?vFOA2t+N53u&yF}fL)LG$ zUmA$VkWgg{Zt7v2q3})arG-_|q%%~z7i zW4Hx@EjJxj?gkxz(?GL4yvbj~f-+ZcVF+yL7G8XKf5y%U?3X&Oq>Lrk!Vv-IG*GE+ z6Vewe`S9j4h$_bB$6IeWB-9s1Mx;CTDjhHQN64Y)MbDD5A0s}anEUysJKr>+jXgC^ z@)%*}+Y!^dWR-1{_M{t)c>&P58X)(16S6qgavQR7Mo2hzbALNQ#Z zN~>zN=^!wa0J;16D5M2q{`7(=nC{)Ht*ydPNp|c=*hJ!-qX=e?|&#@<^y4s?uoB_a71**aNTf)CDe-E7JlPy0q3==pzDJvLHsXxEZ2CLfsn& z2#u5UKSa2S;K+WxOM)!Cz+Ai(eHWlc=7|iZ@3Bbd!at@Yt^D;HSZ*0UqB&fqAaT0{ zMxRg+X#NSj0olg6117)OdGpdeqnO>gg18FEGT{;8!eb@S`^xZ}5tgLOB*-N7)HigXicPBQ`y1>@9A+1>qk4Ecmdqz9g1~!~hUdOfkg0mY_^n9-@chL&yLy)4 zq)#m%2G-nrmxRjdAUGg_Z4QqurM@8Hd&j(gM*_$1^Kg&2?@qb^=f!#*9i&ftd{|4M z;PT@obqgVBU{cznfma~N{oB-4kePWY0ykGmM8}ET&5p8_i;E=FT_JI(eqz;m$PJO* zmS+azq|fvi@Y3wB@6W%z&4s#!F;1^POC!~7DXXyq6$?pWI=?9W zfo2+*1EEQMJgTpw{t$}U3r=%r#H1aic{`d!3#Hky;IfDTO={0~I*bppgUQ&>(Zqn6 zXe22Eirp~Tjs$;NHhcp>4L|!?LPW=i!}3Yx)f_iG*xNk38+)uR+b1&Xo2j(HIAf-l zVmsCH2Jz_rzQ|Oaf>g!ga9}r_b7+gf1t6NfHc%P&ojV&1X6??W{$+&M_d5HSyD=cF z;7iC`kEVtN0x3?@``$m-VHnh<4kgR=6pHMzsf{5YhGa6UB|Jv^fo1PO%v}8QhxE8t zMHnk$E!>N~a#GK$U4bcuGQyyWn1d~tS3e}ZcvFR@VlSNvSzy>*cv0d$zBMwvX~sq( zg9hbJph>=hK+%W0(H~;9$8Ig|>0ATw4R>_HjHm^KK;Do`8eLQbxl2bE|M?5*1(b{s zkG8B=Wsqs$E@U$WHF*fx{Y^oQv`AAC52x3<-Aq}T#3Fd3W*mP}Ch(0C3Sq-*7QVW5!A$Lx41b$EMM|~v$)jxbD%1-bE98#Mbcz})6 z_gXh<)uM3wDSxm$uRBH-5LCh;cTwch1s^W$44h$5sVapj7dQ-{hV;X#)CWa$pd!%* z4Nu5M>S#&C42&o0HwJo(4(kk?l)(Im9TVw)g`pwxj2{wDDVuh8fSEPu>EK+F_$=AW zt1f!?jmb%f2Taz)h;R~$QS$tMnz{0CDE}}1%#1Z#mO^1hw#kx6M24qqEn;LV`@U!S zL_@X*mB?0zXn3+jw$NlL#)phbVagVvtTU8l7~6!tKL5q{$9v8_=f2;s^E$8BJ@=e@ zk76eq&GHST-=VBR-nb=?7u|WY9 zm!Kj*qszN2v7u7^KB&0%UekQ>CtQ^OOiFI*Av?lahZminwB4_bNM@(L$el@(1g}WM zjowPt9EQxO1W#W02JeN>E$!{C0YazcLA8{}92x`+2*N(0=TKAZg=&~OVs-x!X_(B3 zwJ2q9QM*hq2(BRAKTv@^s2weD(dl?%8Ibj#=;42wzOPpb$XOTLaCQXp_ECVeh*I}m z6T@@MWj=9M!+>85VRffa$+OVv3D|clNdL=&)4X&srw_Ggq7dwJ3@?ewi&8|kW_F*4 zq{5-SUZmiCfFcqnH7wp#Tm|$9NyOy=84GXL6oK+Z1WVH3${8+vlpuXqjsJ`s=RxHW zMPOV?c3ax?;46Kt72rkPG?H}za!UdWky)HjgLN}qkq4#IhK9Y>Qv~5ubkP#WW<5>0 z?CP0iF#M==Hj6Ak-|s@VkY4sQ>`p`v2|%^p>$$w~Bat=!mP0 zcHH~Qq&Fd)ap1V>(JkRQ_Xhwxdmo72fDAW`iU==8lAAwD5P-b2u^A(zh$Q^d0o1z2 z(Zrl)?x@SSKR4rtTTjB#2zu5YhXtS$uSyQ_8sv-2Vh0wrovppv`@?S$*#KhFwk9H3 zI0I|1Z}tFO2tbyKtulxLrLin*w%X;N|%aZ6~eqJbeIES&t{g zB>)r-pE#$e-}vBUKrWmC_-^K5X|Vw1J6;1%~f zaKglQmsIh|n}}qZ-Yt0=i@M-YuK3sjso7Z_fh28DpQ%l=g+>ADzb*YXzDWvFaA`P=n2U2xlGzOf zBakAtZ0DuR(=FnLa4HO!N={9o#;_bTUwN1UCPvSAzefz6Z95DM3@6=5iH>XY#Cz%)A&bYd}A&A_daGM=6)@@OtVx;>B^^##-ED!iz zTAmGC&&6htf#PDuJbLl@9jJ%rK|Lz&xQe!7!!~erk~aXptH64${)51a7JOvge&`k- zEVI06vt1{SNX8=TMzq-m>W-h|qAGrY$mc2*trJWKYWg9L{i*b3q|x`w`=3qtTlZJ) z??hW7R6h^Jzej5(yaVi#Q9Kq?C%6%LA)q@;IAG(aQblH2I4z^l-Rk#VXkW=U8XIhi zNxV=7BV zS2i;jK(+m$Q#81GGKTV$1?pE4DLi|{j;*P(K=!UbbJgDaH9sO5N!7R*lTmv8FN_$M zeZC}#MLGzX4w3YpPTs?heI_S6H6Fm*BStTwI>}ya`zG`;cGjI{h46bILEH3&q}mvW zRvW0Mj~yQQ^%fDvjkkgx{m9kP6Gh^!(Ov*3Dsj+!OiV<~Y*lStYs<{Sb~9QCIuTN- zfaB-JJ3vN2hIrA+KUOrelwQDZ*7=+2hzqAe_*56i0w4)e+8{nSF znjA}Px{iZcAv_T%zuyBW5WnL6wtKk zp-YGLRVp=3$frkl0AXvuq5wDkR%KQjn2*eQ+4=SK!1sCeDj|5cMIO8D8>2yUD+|Yl z)?Y2qoRJkn`1wOcv0bxiDZhN@qg23GA3c@km4?e^W9tbmz~UAUNePUI8_6ths%qw? zhU$YOD(v%5^2Vq1x1GZRb&a9hA!EVcG|=t9-9N_OUmwbim^|E2`)&k1dVg1I^T zF=bDRghdR|oIGDP92{;MXb+9LFx+lTGrS@;&yBvXI`nE*7R--;9)B{3kH;-397Tp2 zLv~eC@Le;Df~H5Giy|ov@3!}RNJRbB>&HLYC5+Ck*P)mu2tQA#UgqHRIH_UVk93%W zzPm?LJ(MtLwr_QU2$tKls-7IU$V>emLRJftKdz<}3H$E?VXHR}%_UohU%M`*#pZJv zl2-#6BTqcotvA5kwSuzbLw^D+65H^PQ;0|O!8PQ-DnR*R%F2xca6Chkv{&uQe<$8f zb7F{fE&fn}c5G zKbPmBT1DLfs%`zg?iUWUMlq{e-q0}ogxj+g=Wj-kZc};({Y|*3$-wJ&yKBRlo8X@| zWhAr7p?Xt**EyB2L*!-bDTiG(dnNx|!+jfjf6=Tgq_YDlUsHyJoG)U-*aT}iOb~cZ!aVbtp^8p zRCuYk7^&IqfOVNh27K_wm0l7p?Ft?h;_0> z=ov!4Nq>K9OGm*)UaH@=2g9AXdZgny(cz=PFVQulZ?AV;daoPw-{JI8mFP_dRQN>B z(AIu4FZRvd7cC7Cyq7AMLZO>26_K|c!Z@-(cIyln3P1!HRQ)AsaX7`T-l6QOF|r2Zr`#C$?me#U2!`$ z-ce_3T9@jA)Nm{m9ye1ed3(jyV+>3C)_CNEpaJx?>?5a>9dG*hhv99Ce5Xe}$)8Ui ze9153vUB`O@A6c;eZk@6PYaJSUCCA2>^_0D@!q)d-{lpQ}Z9 zsd-pl3tpo0q*lePY@14)V?oW2$*7-R;_xYyBwjtyN!(i7_&bV8(&Vh*#Igv2@QTSA zaYy`%gKqslDclQjkMocst=XLPb5z?>neR*OLdx@yUbV_PII@mIp%s()J^?*7bFYd< z8W%>+3k;kbt|{?&lT&zavvBs_QQjvrQsvU%7w7(_r+s7xWxpkY4&%Oo0~O_2Uv}h2>)0?ZF5CB6&s(S!16BREO5q#vb2$ zYL!1Y>3w5svd5BfvTZm;SP~9z0S>*-7R|IQqv9^AXugCZs%29=!=g&)R>qr&<(?<2l3baCo7DD|)(>*(Rv6H)7$dBV z-s^Is&UZ`Dv!fE{e@Dg&{Ng9{>V37+s9=Jhu0wNw@e%&4NuJT95EOHcnQbPgS9trn z(mpWWN{CVW>ccmpqA$y+se@=Mq*YkZWy-1deAM2YmeaE<`&b&c+Vu1#>i6qP6vcKM z521RhZGX)!`$+OPK4qux(G)Xj=4AJ!ce#k$LPk8^NN>k zyW;dF__}mBo3FYf1V!3>BR`3C;+|e(tS$Qrrp*{;6w*|E1=5aU23IBCe^l0Qi*T8x zsL5`gS)BNFJMaSoIgmt9jWRhK?2vCBQzQ8Qa>DDscR<{$v4?Ai0 { themeMode: settings.appTheme, theme: LightAppTheme.theme, darkTheme: DarkAppTheme.theme, + // Brand-colored backdrop behind every route. During the logout + // home-swap and route pop animations the framework can briefly + // expose the layer below the topmost Scaffold; without this + // the dark Material default shows through and the user sees a + // black flash. + builder: (context, child) => ColoredBox( + color: LightAppTheme.marianumRed, + child: child ?? const SizedBox.shrink(), + ), home: LoaderOverlay( child: Breaker( breaker: BreakerArea.global, @@ -203,14 +212,16 @@ class _MainState extends State

{ case AccountStatus.loggedOut: return const Login(); case AccountStatus.undefined: - return const Scaffold( - body: Center( + return Scaffold( + backgroundColor: LightAppTheme.marianumRed, + body: const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - AppProgressIndicator.large(), + AppProgressIndicator.large(color: Colors.white), SizedBox(height: 16), - Text('Konto wird geladen…'), + Text('Konto wird geladen…', + style: TextStyle(color: Colors.white)), ], ), ), @@ -234,16 +245,19 @@ Future _wipeUserState({ required BreakerBloc breakerBloc, }) async { try { - final prefs = await SharedPreferences.getInstance(); - await prefs.clear(); - PaintingBinding.instance.imageCache.clear(); - await settingsCubit.reset(); + // Reset user-data blocs whose tree is no longer mounted after the + // home swap. We do NOT touch SettingsCubit here — its outer BlocBuilder + // wraps MaterialApp, so emit'ing a fresh state would tear down the + // freshly-mounted Login tree and leave the user with a blank screen + // (the MaterialApp.builder backdrop) until the next interaction. await Future.wait([ timetableBloc.reset(), chatListBloc.reset(), chatBloc.reset(), breakerBloc.reset(), ]); + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); await HydratedBloc.storage.clear(); await const CacheView().clear(); } catch (e, s) { diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart index 712301a..35680da 100644 --- a/lib/model/account_data.dart +++ b/lib/model/account_data.dart @@ -3,14 +3,9 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../state/app/modules/account/bloc/account_bloc.dart'; -import '../state/app/modules/account/bloc/account_state.dart'; - class AccountData { static const _usernameField = 'username'; static const _passwordField = 'password'; @@ -54,11 +49,8 @@ class AccountData { if (!_populated.isCompleted) _populated.complete(); } - Future removeData({BuildContext? context}) async { + Future removeData() async { _populated = Completer(); - if (context != null) { - context.read().setStatus(AccountStatus.loggedOut); - } _username = null; _password = null; await _secureStorage.delete(key: _usernameField); 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 03916ca..0bae6ec 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 @@ -55,7 +55,7 @@ class LoadableStateErrorBar extends StatelessWidget { if (technicalDetails != null && technicalDetails!.isNotEmpty) technicalDetails!, ].join('\n\n'); if (body.isEmpty) return; - InfoDialog.show(context, body); + InfoDialog.show(context, body, copyable: true, title: 'Fehlerdetails'); }, child: Container( height: 20, diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart index e56eb29..dac7d3c 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart @@ -58,7 +58,7 @@ class LoadableStateErrorScreen extends StatelessWidget { if (technicalDetails != null) ...[ const SizedBox(height: 4), TextButton( - onPressed: () => InfoDialog.show(context, technicalDetails!), + onPressed: () => InfoDialog.show(context, technicalDetails!, copyable: true, title: 'Fehlerdetails'), child: const Text('Details anzeigen'), ), ], diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index a2d22cc..87aed0d 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -1,15 +1,17 @@ - import 'dart:developer'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_login/flutter_login.dart'; +import '../../api/errors/auth_exception.dart'; +import '../../api/errors/error_mapper.dart'; import '../../api/marianumcloud/talk/room/get_room.dart'; import '../../api/marianumcloud/talk/room/get_room_params.dart'; import '../../model/account_data.dart'; import '../../state/app/modules/account/bloc/account_bloc.dart'; import '../../state/app/modules/account/bloc/account_state.dart'; +import '../../theming/light_app_theme.dart'; class Login extends StatefulWidget { const Login({super.key}); @@ -19,88 +21,354 @@ class Login extends StatefulWidget { } class _LoginState extends State { - bool displayDisclaimerText = true; + static const _marianumRed = LightAppTheme.marianumRed; - String? _checkInput(String? value) => (value ?? '').isEmpty ? 'Eingabe erforderlich' : null; + final _formKey = GlobalKey(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _passwordFocus = FocusNode(); - Future _login(LoginData data) async { - await AccountData().removeData(); - - try { - await AccountData().setData(data.name.toLowerCase(), data.password); - await GetRoom( - GetRoomParams( - includeStatus: false, - ), - ).run().then((value) async { - await AccountData().setData(data.name.toLowerCase(), data.password); - setState(() { - displayDisclaimerText = false; - }); - }); - } catch(e) { - await AccountData().removeData(); - log(e.toString()); - return 'Benutzername oder Password falsch! (${e.toString()})'; - } - - await Future.delayed(const Duration(seconds: 1)); - return null; - } - - Future _resetPassword(String name) => Future.delayed(Duration.zero).then((_) => 'Diese Funktion steht nicht zur Verfügung!'); + bool _loading = false; + String? _errorMessage; + String? _errorDetails; @override - Widget build(BuildContext context) => FlutterLogin( - logo: Image.asset('assets/logo/icon.png').image, + void didChangeDependencies() { + super.didChangeDependencies(); + precacheImage(const AssetImage('assets/logo/icon.png'), context); + } - userValidator: _checkInput, - passwordValidator: _checkInput, - onSubmitAnimationCompleted: () => context.read().setStatus(AccountStatus.loggedIn), + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + _passwordFocus.dispose(); + super.dispose(); + } - onLogin: _login, - onSignup: null, + String? _required(String? value) => (value ?? '').trim().isEmpty ? 'Eingabe erforderlich' : null; - onRecoverPassword: _resetPassword, - hideForgotPasswordButton: true, + Future _submit() async { + if (_loading) return; + if (!(_formKey.currentState?.validate() ?? false)) return; - theme: LoginTheme( - primaryColor: Theme.of(context).primaryColor, - accentColor: Colors.white, - errorColor: Theme.of(context).primaryColor, - footerBottomPadding: 10, - textFieldStyle: const TextStyle( - fontWeight: FontWeight.w500 - ), - cardTheme: const CardTheme( - elevation: 10, - ), - ), + setState(() { + _loading = true; + _errorMessage = null; + _errorDetails = null; + }); - messages: LoginMessages( - loginButton: 'Anmelden', - userHint: 'Nutzername', - passwordHint: 'Passwort', - ), + final username = _usernameController.text.trim().toLowerCase(); + final password = _passwordController.text; - disableCustomPageTransformer: true, + try { + await AccountData().removeData(); + await AccountData().setData(username, password); + await GetRoom(GetRoomParams(includeStatus: false)).run(); + if (!mounted) return; + context.read().setStatus(AccountStatus.loggedIn); + } catch (e) { + log(e.toString()); + await AccountData().removeData(); + if (!mounted) return; + // 401 from the probe means the credentials were wrong; everything else + // (no network, server down, TLS errors, …) gets the generic mapped + // message so the user knows it isn't their typo. + final isWrongCredentials = e is AuthException && e.statusCode == 401; + setState(() { + _errorMessage = isWrongCredentials + ? 'Benutzername oder Passwort falsch.' + : errorToUserMessage(e); + _errorDetails = errorToTechnicalDetails(e); + }); + } finally { + if (mounted) setState(() => _loading = false); + } + } - headerWidget: Padding( - padding: const EdgeInsets.only(bottom: 30), - child: Center( - child: Visibility( - visible: displayDisclaimerText, - child: const Text( - 'Dies ist ein Inoffizieller Nextcloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\nKeinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!', - textAlign: TextAlign.center, + void _showErrorDetails() { + final details = _errorDetails; + if (details == null) return; + showDialog( + context: context, + builder: (dialogContext) { + final theme = Theme.of(dialogContext); + return AlertDialog( + icon: Icon(Icons.error_outline, color: theme.colorScheme.error), + title: const Text('Fehlerdetails'), + content: SingleChildScrollView( + child: SelectableText( + details, + style: theme.textTheme.bodySmall, ), ), - ), - ), - - footer: 'Marianum Fulda - Die persönliche Schule', - title: 'Marianum Fulda', - - userType: LoginUserType.name, + actions: [ + TextButton.icon( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: details)); + if (!dialogContext.mounted) return; + ScaffoldMessenger.of(dialogContext).showSnackBar( + const SnackBar( + content: Text('In Zwischenablage kopiert'), + duration: Duration(seconds: 2), + ), + ); + }, + icon: const Icon(Icons.copy_outlined, size: 18), + label: const Text('Kopieren'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Schließen'), + ), + ], + ); + }, ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + backgroundColor: _marianumRed, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Column( + children: [ + const SizedBox(height: 40), + Image.asset( + 'assets/logo/icon.png', + height: 110, + fit: BoxFit.contain, + gaplessPlayback: true, + ), + const SizedBox(height: 20), + const Text( + 'Marianum Fulda', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + const SizedBox(height: 6), + Text( + 'Stundenplan, Talk & Dateien an einem Ort.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.85), + fontSize: 14, + height: 1.3, + ), + ), + const SizedBox(height: 28), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Card( + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.35), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + color: theme.colorScheme.surface, + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Anmelden', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + 'Melde dich mit deinen Marianum-Zugangsdaten an.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + TextFormField( + controller: _usernameController, + enabled: !_loading, + validator: _required, + autocorrect: false, + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => _passwordFocus.requestFocus(), + decoration: InputDecoration( + labelText: 'Nutzername', + prefixIcon: const Icon(Icons.person_outline), + filled: true, + fillColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.primary, width: 1.5), + ), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _passwordController, + focusNode: _passwordFocus, + enabled: !_loading, + validator: _required, + obscureText: true, + obscuringCharacter: '•', + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.visiblePassword, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + decoration: InputDecoration( + labelText: 'Passwort', + prefixIcon: const Icon(Icons.lock_outline), + filled: true, + fillColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.primary, width: 1.5), + ), + ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + child: _errorMessage == null + ? const SizedBox(height: 0, width: double.infinity) + : Padding( + padding: const EdgeInsets.only(top: 14), + child: Material( + color: theme.colorScheme.errorContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: _errorDetails != null ? _showErrorDetails : null, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(Icons.error_outline, + size: 20, + color: theme.colorScheme.onErrorContainer), + const SizedBox(width: 10), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle( + color: theme.colorScheme.onErrorContainer, + fontSize: 13, + height: 1.3, + ), + ), + ), + if (_errorDetails != null) ...[ + const SizedBox(width: 8), + Icon(Icons.chevron_right, + size: 20, + color: theme.colorScheme.onErrorContainer + .withValues(alpha: 0.7)), + ], + ], + ), + ), + ), + ), + ), + ), + const SizedBox(height: 20), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _loading ? null : _submit, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + child: _loading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.white, + ), + ) + : const Text('Anmelden'), + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 18), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + 'Inoffizieller Nextcloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.75), + fontSize: 11, + height: 1.4, + ), + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.only(top: 16, bottom: 8), + child: Text( + 'Marianum Fulda. Die persönliche Schule.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 12, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } } diff --git a/lib/view/pages/overhang.dart b/lib/view/pages/overhang.dart index 2489fa4..01b4c50 100644 --- a/lib/view/pages/overhang.dart +++ b/lib/view/pages/overhang.dart @@ -127,7 +127,7 @@ class _OverhangState extends State { }, onError: (error) { if (!context.mounted) return; - InfoDialog.show(context, error.toString()); + InfoDialog.show(context, error.toString(), copyable: true, title: 'Fehler'); }, ); }, diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index d4b8128..a6190cc 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../model/account_data.dart'; +import '../../../../state/app/modules/account/bloc/account_bloc.dart'; +import '../../../../state/app/modules/account/bloc/account_state.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; @@ -15,21 +18,23 @@ class AccountSection extends StatelessWidget { onTap: () => _showLogoutDialog(context), ); - void _showLogoutDialog(BuildContext context) { - showDialog( + Future _showLogoutDialog(BuildContext context) async { + // Sequential logout flow: dialog wipes secure storage, dialog closes + // (single Navigator.pop), then we flip the AccountBloc state. The bloc + // listener in main.dart pops the Settings route and runs the in-memory + // wipe. Triggering setStatus from inside removeData (the previous + // approach) raced AsyncDialogAction's pop(true) against popUntil(isFirst) + // and could leave the navigator in an inconsistent state. + final confirmed = await showDialog( context: context, builder: (dialogContext) => ConfirmDialog( title: 'Abmelden?', content: 'Möchtest du dich wirklich abmelden?', confirmButton: 'Abmelden', - // Cleanup of caches, hydrated bloc storage and bloc in-memory state is - // handled by the AccountBloc listener in main.dart on the loggedOut - // transition. Doing the cleanup *before* setting loggedOut caused - // rebuilds in the still-mounted App tree (TimetableBloc/ChatListBloc - // emitting empty states) which raced with the home-route swap and - // produced a black screen. - onConfirmAsync: () => AccountData().removeData(context: context), + onConfirmAsync: AccountData().removeData, ), ); + if (confirmed != true || !context.mounted) return; + context.read().setStatus(AccountStatus.loggedOut); } } 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 caa6e62..a85b652 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 @@ -115,7 +115,7 @@ class _CustomEventEditDialogState extends State { Navigator.of(context).pop(); }).catchError((Object error) { if (!mounted) return; - InfoDialog.show(context, error.toString()); + InfoDialog.show(context, error.toString(), copyable: true, title: 'Fehler'); }); } diff --git a/lib/view/pages/timetable/data/calendar_layout.dart b/lib/view/pages/timetable/data/calendar_layout.dart index d24729b..b026edd 100644 --- a/lib/view/pages/timetable/data/calendar_layout.dart +++ b/lib/view/pages/timetable/data/calendar_layout.dart @@ -6,3 +6,11 @@ const double kCalendarViewHeaderHeight = 60; /// Minimum pixels per hour. Below this, the grid scrolls vertically rather /// than compressing further. const double kCalendarMinPxPerHour = 56; + +/// Minimum height of a lesson block in the period-based layout. The grid +/// scrolls vertically once lessons would otherwise be smaller than this. +const double kLessonBlockMinHeight = 50; + +/// Fixed height of a break block in the period-based layout. Independent of +/// the actual break duration; breaks are rendered as a compact indicator. +const double kBreakBlockHeight = 28; diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index 29cebf2..985dfd7 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -72,8 +72,8 @@ class TimetableAppointmentFactory { id: CustomAppointment(event), startTime: event.startDate, endTime: event.endDate, - location: event.description, - subject: event.title, + location: _collapseWhitespace(event.description), + subject: _collapseWhitespace(event.title) ?? event.title, recurrenceRule: event.rrule, color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name), startTimeZone: '', @@ -83,19 +83,38 @@ class TimetableAppointmentFactory { String _subjectName(GetTimetableResponseObject lesson) { final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id); if (subject == null) return 'Unbekannt'; - return switch (settings.timetableNameMode) { + final name = switch (settings.timetableNameMode) { TimetableNameMode.name => subject.name, TimetableNameMode.longName => subject.longName, TimetableNameMode.alternateName => subject.alternateName, }; + return _collapseWhitespace(name) ?? 'Unbekannt'; } String _locationLabel(GetTimetableResponseObject lesson) { - final roomName = rooms.result.firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id)?.name ?? 'Unbekannt'; - final teacherName = lesson.te.firstOrNull?.longname ?? 'Unbekannt'; + final roomName = _collapseWhitespace( + rooms.result.firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id)?.name) ?? + 'Unbekannt'; + final teacherName = _collapseWhitespace(lesson.te.firstOrNull?.longname) ?? 'Unbekannt'; return '$roomName\n$teacherName'; } + /// Collapses any line-break or whitespace run to a single space and trims. + /// Returns null when input is null or fully whitespace. Webuntis sometimes + /// returns multi-line room names like "A30\n4" — this normalizes those so + /// the tile renders the room on a single line. + static String? _collapseWhitespace(String? s) { + if (s == null) return null; + final cleaned = s + .replaceAll('\r\n', ' ') + .replaceAll('\n', ' ') + .replaceAll('\r', ' ') + .replaceAll('\t', ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + return cleaned.isEmpty ? null : cleaned; + } + // Pure: returns a new list, does not mutate input. static List _mergeAdjacentLessons( List input, { diff --git a/lib/view/pages/timetable/details/bottom_sheet.dart b/lib/view/pages/timetable/details/bottom_sheet.dart index d834a09..c0066b1 100644 --- a/lib/view/pages/timetable/details/bottom_sheet.dart +++ b/lib/view/pages/timetable/details/bottom_sheet.dart @@ -1,51 +1,32 @@ import 'package:flutter/material.dart'; +/// Shows a modal bottom sheet for an appointment, matching the design of the +/// other sheets in the app (file details, file actions, overflow lessons): +/// drag handle on top, default theme background, ListTile-style header +/// followed by a divider, scrollable body below. void showAppointmentBottomSheet( BuildContext context, { - required Widget Function(BuildContext context) header, - required SliverChildListDelegate Function(BuildContext context) body, + required Widget header, + required List Function(BuildContext sheetContext) children, }) { showModalBottomSheet( context: context, isScrollControlled: true, + showDragHandle: true, useSafeArea: true, - backgroundColor: Theme.of(context).colorScheme.surface, - builder: (sheetContext) => DraggableScrollableSheet( - expand: false, - initialChildSize: 0.4, - minChildSize: 0.2, - maxChildSize: 0.7, - snap: true, - snapSizes: const [0.4], - builder: (_, scrollController) => CustomScrollView( - controller: scrollController, - slivers: [ - SliverPersistentHeader( - pinned: true, - delegate: _StickyHeader(child: header(sheetContext)), - ), - SliverList(delegate: body(sheetContext)), - ], + builder: (sheetContext) => SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + header, + const Divider(height: 1), + ...children(sheetContext), + ], + ), ), ), ); } - -class _StickyHeader extends SliverPersistentHeaderDelegate { - _StickyHeader({required this.child}); - final Widget child; - - @override - double get minExtent => 100; - @override - double get maxExtent => 100; - - @override - Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => Material( - color: Theme.of(context).colorScheme.surface, - child: SizedBox.expand(child: child), - ); - - @override - bool shouldRebuild(covariant _StickyHeader oldDelegate) => oldDelegate.child != child; -} diff --git a/lib/view/pages/timetable/details/custom_event_sheet.dart b/lib/view/pages/timetable/details/custom_event_sheet.dart index ce52d8c..1a66504 100644 --- a/lib/view/pages/timetable/details/custom_event_sheet.dart +++ b/lib/view/pages/timetable/details/custom_event_sheet.dart @@ -11,51 +11,49 @@ import 'delete_custom_event.dart'; class CustomEventSheet { static void show(BuildContext context, CustomTimetableEvent event) { + final timeRange = + '${Jiffy.parseFromDateTime(event.startDate).format(pattern: 'HH:mm')} - ' + '${Jiffy.parseFromDateTime(event.endDate).format(pattern: 'HH:mm')}'; + showAppointmentBottomSheet( context, - header: (_) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(event.title, style: const TextStyle(fontSize: 25, overflow: TextOverflow.ellipsis)), - Text( - '${Jiffy.parseFromDateTime(event.startDate).format(pattern: 'HH:mm')} - ' - '${Jiffy.parseFromDateTime(event.endDate).format(pattern: 'HH:mm')}', - style: const TextStyle(fontSize: 15), - ), - ], - ), + header: ListTile( + leading: const Icon(Icons.event_outlined, size: 32), + title: Text(event.title, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(timeRange), ), - body: (sheetCtx) => SliverChildListDelegate([ - const Divider(), - Center( - child: Wrap( - children: [ - TextButton.icon( - onPressed: () { - Navigator.of(sheetCtx).pop(); - showDialog( - context: context, - builder: (_) => CustomEventEditDialog(existingEvent: event), - ); - }, - label: const Text('Bearbeiten'), - icon: const Icon(Icons.edit_outlined), - ), - TextButton.icon( - onPressed: () { - showDeleteCustomEventDialog(context, event).future.then((_) { - if (!sheetCtx.mounted) return; + children: (sheetCtx) => [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Center( + child: Wrap( + children: [ + TextButton.icon( + onPressed: () { Navigator.of(sheetCtx).pop(); - }); - }, - label: const Text('Löschen'), - icon: const Icon(Icons.delete_outline), - ), - ], + showDialog( + context: context, + builder: (_) => CustomEventEditDialog(existingEvent: event), + ); + }, + label: const Text('Bearbeiten'), + icon: const Icon(Icons.edit_outlined), + ), + TextButton.icon( + onPressed: () { + showDeleteCustomEventDialog(context, event).future.then((_) { + if (!sheetCtx.mounted) return; + Navigator.of(sheetCtx).pop(); + }); + }, + label: const Text('Löschen'), + icon: const Icon(Icons.delete_outline), + ), + ], + ), ), ), - const Divider(), + const Divider(height: 1), ListTile( leading: const Icon(Icons.info_outline), title: Text(event.description.isEmpty ? 'Keine Beschreibung' : event.description), @@ -82,7 +80,7 @@ class CustomEventSheet { ), ), DebugTile(sheetCtx).jsonData(event.toJson()), - ]), + ], ); } } diff --git a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart index 7b88c05..2ada2ae 100644 --- a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart +++ b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart @@ -23,29 +23,24 @@ class WebuntisLessonSheet { final headerTitle = _firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']); final headerLongName = headerSubject.longName.isNotEmpty && headerSubject.longName != headerTitle ? headerSubject.longName : ''; + final timeRange = + '${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - ' + '${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}'; + showAppointmentBottomSheet( context, - header: (_) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${_codePrefix(lesson.code)}$headerTitle', - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 25), - overflow: TextOverflow.ellipsis, - ), - if (headerLongName.isNotEmpty) Text(headerLongName), - Text( - '${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - ' - '${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}', - style: const TextStyle(fontSize: 15), - ), - ], + header: ListTile( + leading: Icon(_iconForCode(lesson.code), size: 32), + title: Text( + '${_codePrefix(lesson.code)}$headerTitle', + style: const TextStyle(fontWeight: FontWeight.bold), ), + subtitle: Text(headerLongName.isNotEmpty + ? '$timeRange\n$headerLongName' + : timeRange), + isThreeLine: headerLongName.isNotEmpty, ), - body: (_) => SliverChildListDelegate([ - const Divider(), + children: (_) => [ ListTile( leading: const Icon(Icons.notifications_active), title: Text('Status: ${_statusLabel(lesson.code)}'), @@ -82,10 +77,21 @@ class WebuntisLessonSheet { ), ..._optionalTextTiles(lesson), DebugTile(context).jsonData(lesson.toJson()), - ]), + ], ); } + static IconData _iconForCode(String? code) { + switch (code) { + case 'cancelled': + return Icons.event_busy_outlined; + case 'irregular': + return Icons.swap_horiz; + default: + return Icons.school_outlined; + } + } + static Widget _roomTile(BuildContext context, TimetableState state, GetTimetableResponseObject lesson) { final trailing = IconButton( icon: const Icon(Icons.house_outlined), @@ -193,7 +199,7 @@ class WebuntisLessonSheet { static Widget? _textTile(IconData icon, String label, String? value) { final text = (value ?? '').trim(); - if (text.isEmpty) return null; + if (text.isEmpty || text == '-') return null; return ListTile( leading: Icon(icon), title: Text(label), diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart index d185f5c..180a6ac 100644 --- a/lib/view/pages/timetable/widgets/appointment_tile.dart +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -4,6 +4,8 @@ import 'package:syncfusion_flutter_calendar/calendar.dart'; import 'cross_painter.dart'; class AppointmentTile extends StatelessWidget { + static const _radius = BorderRadius.all(Radius.circular(7)); + final Appointment appointment; final bool crossedOut; @@ -14,54 +16,51 @@ class AppointmentTile extends StatelessWidget { final isPast = appointment.endTime.isBefore(DateTime.now()); final color = appointment.color.withAlpha(isPast ? 160 : 255); + final locationLines = (appointment.location ?? '') + .split('\n') + .where((p) => p.isNotEmpty) + .take(2) + .toList(growable: false); + return Padding( padding: const EdgeInsets.all(1), child: Stack( children: [ Positioned.fill( child: Container( - padding: const EdgeInsets.all(4), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), alignment: Alignment.topLeft, decoration: BoxDecoration( shape: BoxShape.rectangle, - borderRadius: const BorderRadius.all(Radius.circular(7)), + borderRadius: _radius, color: color, ), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FittedBox( - fit: BoxFit.fitWidth, - child: Text( - appointment.subject, - style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500), - maxLines: 1, - softWrap: false, - ), - ), - FittedBox( - fit: BoxFit.fitWidth, - child: Text( - appointment.location?.isNotEmpty == true ? appointment.location! : ' ', - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white, fontSize: 10), - ), - ), - ], - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + _ScaledLine( + text: appointment.subject, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + for (final line in locationLines) + _ScaledLine(text: line, fontSize: 10), + ], ), ), ), if (crossedOut) Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - border: Border.all(width: 2, color: Colors.red.withAlpha(200)), - borderRadius: const BorderRadius.all(Radius.circular(7)), + child: ClipRRect( + borderRadius: _radius, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(width: 2, color: Colors.red.withAlpha(200)), + borderRadius: _radius, + ), + child: CustomPaint(painter: CrossPainter()), ), - child: CustomPaint(painter: CrossPainter()), ), ), ], @@ -69,3 +68,35 @@ class AppointmentTile extends StatelessWidget { ); } } + +/// One row of appointment text. The FittedBox scales **only this line** down +/// when the text is wider than the tile, so a long teacher name does not +/// shrink the room number above it. +class _ScaledLine extends StatelessWidget { + final String text; + final double fontSize; + final FontWeight? fontWeight; + + const _ScaledLine({ + required this.text, + required this.fontSize, + this.fontWeight, + }); + + @override + Widget build(BuildContext context) => FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + text, + style: TextStyle( + color: Colors.white, + fontSize: fontSize, + fontWeight: fontWeight, + height: 1.1, + ), + maxLines: 1, + softWrap: false, + ), + ); +} diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 85ce55e..08a1384 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -132,11 +132,23 @@ class CustomWorkWeekCalendarState extends State { Expanded( child: LayoutBuilder( builder: (context, constraints) { - final hours = kCalendarEndHour - kCalendarStartHour; - final fitPxPerHour = constraints.maxHeight / hours; - final pxPerHour = - fitPxPerHour < kCalendarMinPxPerHour ? kCalendarMinPxPerHour : fitPxPerHour; - final gridHeight = pxPerHour * hours; + final periods = widget.schedule.periods; + final lessonCount = + periods.where((p) => !p.isBreak).length; + final breakCount = periods.length - lessonCount; + final available = + constraints.maxHeight - breakCount * kBreakBlockHeight; + final fitLessonH = + lessonCount > 0 ? available / lessonCount : kLessonBlockMinHeight; + final lessonH = fitLessonH < kLessonBlockMinHeight + ? kLessonBlockMinHeight + : fitLessonH; + final layout = _PeriodLayout( + periods: periods, + lessonHeight: lessonH, + breakHeight: kBreakBlockHeight, + ); + final gridHeight = layout.totalHeight; return SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), @@ -163,7 +175,7 @@ class CustomWorkWeekCalendarState extends State { today: _today, nowNotifier: _nowNotifier, rulerWidth: _rulerWidth, - pxPerHour: pxPerHour, + layout: layout, ); }, ), @@ -271,7 +283,7 @@ class _WeekGrid extends StatelessWidget { final DateTime today; final ValueListenable nowNotifier; final double rulerWidth; - final double pxPerHour; + final _PeriodLayout layout; const _WeekGrid({ required this.weekStart, @@ -284,7 +296,7 @@ class _WeekGrid extends StatelessWidget { required this.today, required this.nowNotifier, required this.rulerWidth, - required this.pxPerHour, + required this.layout, }); @override @@ -296,7 +308,7 @@ class _WeekGrid extends StatelessWidget { children: [ _PeriodRuler( schedule: schedule, - pxPerHour: pxPerHour, + layout: layout, width: rulerWidth, ), for (var d = 0; d < 5; d++) @@ -306,7 +318,7 @@ class _WeekGrid extends StatelessWidget { schedule: schedule, appointments: perDay[d], timeRegions: timeRegions, - pxPerHour: pxPerHour, + layout: layout, today: today, nowNotifier: nowNotifier, onAppointmentTap: onAppointmentTap, @@ -321,18 +333,15 @@ class _WeekGrid extends StatelessWidget { class _PeriodRuler extends StatelessWidget { final LessonPeriodSchedule schedule; - final double pxPerHour; + final _PeriodLayout layout; final double width; const _PeriodRuler({ required this.schedule, - required this.pxPerHour, + required this.layout, required this.width, }); - double _y(TimeOfDay t) => - (t.hour + t.minute / 60 - kCalendarStartHour) * pxPerHour; - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -343,8 +352,8 @@ class _PeriodRuler extends StatelessWidget { children: [ for (final period in schedule.periods) Positioned( - top: _y(period.start), - height: (_y(period.end) - _y(period.start)).clamp(0, double.infinity), + top: layout.topOf(period), + height: layout.heightOf(period), left: 0, right: 0, child: _PeriodLabel(period: period, theme: theme), @@ -450,7 +459,7 @@ class _DayColumn extends StatelessWidget { final LessonPeriodSchedule schedule; final List appointments; final List timeRegions; - final double pxPerHour; + final _PeriodLayout layout; final DateTime today; final ValueListenable nowNotifier; final void Function(Appointment) onAppointmentTap; @@ -462,7 +471,7 @@ class _DayColumn extends StatelessWidget { required this.schedule, required this.appointments, required this.timeRegions, - required this.pxPerHour, + required this.layout, required this.today, required this.nowNotifier, required this.onAppointmentTap, @@ -470,66 +479,6 @@ class _DayColumn extends StatelessWidget { required this.onCreateEvent, }); - double _y(int hour, int minute) => - (hour + minute / 60 - kCalendarStartHour) * pxPerHour; - - double _yFromDate(DateTime t) => _y(t.hour, t.minute); - - /// Snaps an appointment edge to the nearest period boundary if the gap is small, - /// so small inter-period transitions (Wechselzeiten) appear as part of the lesson visually. - double _yForAppointmentEdge(DateTime t, {required bool isStart}) { - final tMin = t.hour * 60 + t.minute; - for (final period in schedule.periods) { - if (period.isBreak) continue; - final pStart = period.start.hour * 60 + period.start.minute; - final pEnd = period.end.hour * 60 + period.end.minute; - if (isStart) { - final delta = tMin - pStart; - if (delta >= 0 && delta < 5) { - return _y(period.start.hour, period.start.minute); - } - } else { - final delta = pEnd - tMin; - if (delta >= 0 && delta < 5) { - // Snap to the next non-break period's start when the gap is short - // (Wechselzeit). Skips into a break never extends the lesson. - final idx = schedule.periods.indexOf(period); - if (idx + 1 < schedule.periods.length) { - final next = schedule.periods[idx + 1]; - if (!next.isBreak) { - final nextStart = next.start.hour * 60 + next.start.minute; - if (nextStart - pEnd < 10) { - return _y(next.start.hour, next.start.minute); - } - } - } - } - } - } - return _yFromDate(t); - } - - /// Returns the lesson period (non-break) that the given y-offset falls into, - /// or the next upcoming non-break period if y falls inside a break or before - /// the first period. Returns null if y is past the last period of the day. - LessonPeriod? _periodAt(double y) { - final hoursDecimal = y / pxPerHour + kCalendarStartHour; - final tappedMinutes = (hoursDecimal * 60).round(); - - LessonPeriod? upcoming; - for (final p in schedule.periods) { - if (p.isBreak) continue; - final pStart = p.start.hour * 60 + p.start.minute; - final pEnd = p.end.hour * 60 + p.end.minute; - if (tappedMinutes >= pStart && tappedMinutes < pEnd) return p; - if (tappedMinutes < pStart) { - upcoming = p; - break; - } - } - return upcoming; - } - bool _overlapsExistingAppointment(DateTime start, DateTime end, List dayAppts) { for (final a in dayAppts) { if (a.endTime.isAfter(start) && a.startTime.isBefore(end)) return true; @@ -539,7 +488,7 @@ class _DayColumn extends StatelessWidget { void _handleLongPress(LongPressStartDetails details, List dayAppts) { if (onCreateEvent == null) return; - final period = _periodAt(details.localPosition.dy); + 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); @@ -550,6 +499,56 @@ class _DayColumn extends StatelessWidget { onCreateEvent!(start, end); } + void _showOverflowSheet(BuildContext context, List appointments) { + final sorted = [...appointments] + ..sort((a, b) => a.startTime.compareTo(b.startTime)); + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: sorted.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (_, i) { + final apt = sorted[i]; + return ListTile( + leading: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: apt.color, + borderRadius: BorderRadius.circular(3), + ), + ), + title: Text( + apt.subject, + style: isCrossedOut(apt) + ? const TextStyle(decoration: TextDecoration.lineThrough) + : null, + ), + subtitle: Text(_overflowSubtitle(apt)), + onTap: () { + Navigator.of(sheetContext).pop(); + onAppointmentTap(apt); + }, + ); + }, + ), + ), + ); + } + + static String _overflowSubtitle(Appointment apt) { + final time = '${_formatHm(apt.startTime)}–${_formatHm(apt.endTime)}'; + final loc = apt.location?.replaceAll('\n', ' · '); + return loc != null && loc.isNotEmpty ? '$time · $loc' : time; + } + + static String _formatHm(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -558,6 +557,8 @@ class _DayColumn extends StatelessWidget { final dayRegions = _expandRegionsForDay(timeRegions, date); final isToday = _isSameDay(date, today); + final laidOut = _assignLanes(dayAppointments); + return GestureDetector( behavior: HitTestBehavior.translucent, onLongPressStart: (details) => _handleLongPress(details, dayAppointments), @@ -566,52 +567,66 @@ class _DayColumn extends StatelessWidget { color: isToday ? theme.colorScheme.primary.withAlpha(14) : null, border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)), ), - child: Stack( - clipBehavior: Clip.none, - children: [ - for (final period in schedule.periods) - Positioned( - top: _y(period.start.hour, period.start.minute), - left: 0, - right: 0, - child: Container( - height: 0.5, - color: theme.dividerColor.withAlpha(60), - ), - ), - for (final region in dayRegions) - Positioned( - top: _yFromDate(region.start), - height: - (_yFromDate(region.end) - _yFromDate(region.start)).clamp(0, double.infinity), - left: 0, - right: 0, - child: TimeRegionTile(region: region.region), - ), - for (final apt in dayAppointments) - Positioned( - top: _yForAppointmentEdge(apt.startTime, isStart: true), - height: (_yForAppointmentEdge(apt.endTime, isStart: false) - - _yForAppointmentEdge(apt.startTime, isStart: true)) - .clamp(0, double.infinity), - left: 1, - right: 1, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => onAppointmentTap(apt), - child: AppointmentTile( - appointment: apt, - crossedOut: isCrossedOut(apt), + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + return Stack( + clipBehavior: Clip.none, + children: [ + for (final period in schedule.periods) + Positioned( + top: layout.topOf(period), + left: 0, + right: 0, + child: Container( + height: 0.5, + color: theme.dividerColor.withAlpha(60), + ), ), - ), - ), - if (isToday) - ValueListenableBuilder( - valueListenable: nowNotifier, - builder: (_, now, child) => - _CurrentTimeMarker(now: now, pxPerHour: pxPerHour, theme: theme), - ), - ], + for (final region in dayRegions) + Positioned( + top: layout.yOfDateTime(region.start), + height: (layout.yOfDateTime(region.end) - + layout.yOfDateTime(region.start)) + .clamp(0, double.infinity), + left: 0, + right: 0, + child: TimeRegionTile(region: region.region), + ), + for (final cell in laidOut) + Positioned( + top: layout.yOfDateTime(cell.startTime), + height: (layout.yOfDateTime(cell.endTime) - + layout.yOfDateTime(cell.startTime)) + .clamp(0, double.infinity), + left: cell.lane * width / cell.laneCount, + width: width / cell.laneCount, + child: switch (cell) { + _LaidOutAppointment(:final appointment) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onAppointmentTap(appointment), + child: AppointmentTile( + appointment: appointment, + crossedOut: isCrossedOut(appointment), + ), + ), + _LaidOutOverflow(:final appointments) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => + _showOverflowSheet(context, appointments), + child: _OverflowTile(count: appointments.length), + ), + }, + ), + if (isToday) + ValueListenableBuilder( + valueListenable: nowNotifier, + builder: (_, now, child) => + _CurrentTimeMarker(now: now, layout: layout, theme: theme), + ), + ], + ); + }, ), ), ); @@ -620,20 +635,27 @@ class _DayColumn extends StatelessWidget { class _CurrentTimeMarker extends StatelessWidget { final DateTime now; - final double pxPerHour; + final _PeriodLayout layout; final ThemeData theme; const _CurrentTimeMarker({ required this.now, - required this.pxPerHour, + required this.layout, required this.theme, }); @override Widget build(BuildContext context) { - final y = (now.hour + now.minute / 60 + now.second / 3600 - kCalendarStartHour) * pxPerHour; - final maxY = (kCalendarEndHour - kCalendarStartHour) * pxPerHour; - if (y < 0 || y > maxY) return const SizedBox.shrink(); + final periods = layout.periods; + if (periods.isEmpty) return const SizedBox.shrink(); + final tMin = now.hour * 60 + now.minute; + final firstStart = + periods.first.start.hour * 60 + periods.first.start.minute; + final lastEnd = + periods.last.end.hour * 60 + periods.last.end.minute; + if (tMin < firstStart || tMin > lastEnd) return const SizedBox.shrink(); + + final y = layout.yOfDateTime(now); return AnimatedPositioned( duration: const Duration(milliseconds: 400), @@ -759,3 +781,278 @@ List> _expandAppointmentsForWeek( } return perDay; } + +/// Maps lesson periods to vertical screen positions. Every non-break period +/// gets the same fixed height (`lessonHeight`), every break gets `breakHeight`. +/// Short transition gaps (Wechselzeiten) between periods are not represented +/// at all — periods are rendered back-to-back, so a 5-minute gap simply +/// disappears visually. +class _PeriodLayout { + final List periods; + final double lessonHeight; + final double breakHeight; + + const _PeriodLayout({ + required this.periods, + required this.lessonHeight, + required this.breakHeight, + }); + + double _h(LessonPeriod p) => p.isBreak ? breakHeight : lessonHeight; + + double get totalHeight => + periods.fold(0, (sum, p) => sum + _h(p)); + + double topOf(LessonPeriod period) { + var y = 0.0; + for (final p in periods) { + if (identical(p, period)) return y; + y += _h(p); + } + return y; + } + + double heightOf(LessonPeriod period) => _h(period); + + /// Vertical offset for a given time of day. Times inside a period are mapped + /// proportionally; times that fall into a transition gap are clipped to the + /// end of the preceding period. Times before the first / after the last + /// period clip to 0 / [totalHeight]. + double yOf(TimeOfDay t) { + final tMin = t.hour * 60 + t.minute; + var y = 0.0; + for (final p in periods) { + final pStart = p.start.hour * 60 + p.start.minute; + final pEnd = p.end.hour * 60 + p.end.minute; + final h = _h(p); + if (tMin < pStart) return y; + if (tMin <= pEnd) { + final span = pEnd - pStart; + final ratio = span > 0 ? (tMin - pStart) / span : 0.0; + return y + ratio * h; + } + y += h; + } + return y; + } + + double yOfDateTime(DateTime t) => + yOf(TimeOfDay(hour: t.hour, minute: t.minute)); + + /// Period at a given y-offset. If y falls into a break, returns the next + /// non-break period. Returns null when y is past the last period. + LessonPeriod? periodAtY(double y) { + var cursor = 0.0; + for (var i = 0; i < periods.length; i++) { + final p = periods[i]; + final h = _h(p); + if (y >= cursor && y < cursor + h) { + if (p.isBreak) { + for (var j = i + 1; j < periods.length; j++) { + if (!periods[j].isBreak) return periods[j]; + } + return null; + } + return p; + } + cursor += h; + } + return null; + } +} + +/// Maximum number of cells shown side by side in a single time slot. When a +/// cluster needs more lanes than this, the first appointment (by start time) +/// keeps lane 0 and the rest are collapsed into a single "+N" overflow cell +/// in lane 1. +const int _kMaxVisibleCells = 2; + +class _OverflowTile extends StatelessWidget { + final int count; + const _OverflowTile({required this.count}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + const radius = BorderRadius.all(Radius.circular(7)); + + return Padding( + padding: const EdgeInsets.all(1), + child: Stack( + children: [ + // Card peeking out at the bottom — visual hint that more cards lie + // underneath the visible one. + Positioned( + top: 4, + left: 2, + right: 2, + bottom: 0, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: radius, + color: scheme.secondaryContainer.withAlpha(120), + ), + ), + ), + // Front card with the "+N" indicator. + Positioned( + top: 0, + left: 0, + right: 0, + bottom: 4, + child: Container( + decoration: BoxDecoration( + borderRadius: radius, + color: scheme.secondaryContainer, + ), + alignment: Alignment.center, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.unfold_more_rounded, + size: 18, + color: scheme.onSecondaryContainer, + ), + Text( + '+$count', + style: theme.textTheme.titleSmall?.copyWith( + color: scheme.onSecondaryContainer, + fontWeight: FontWeight.w700, + height: 1.0, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +/// One cell rendered in the day column — either a regular appointment or an +/// overflow placeholder representing several hidden appointments. +sealed class _LaidOutCell { + int get lane; + int get laneCount; + DateTime get startTime; + DateTime get endTime; +} + +class _LaidOutAppointment extends _LaidOutCell { + final Appointment appointment; + @override + final int lane; + @override + final int laneCount; + _LaidOutAppointment(this.appointment, this.lane, this.laneCount); + + @override + DateTime get startTime => appointment.startTime; + @override + DateTime get endTime => appointment.endTime; +} + +class _LaidOutOverflow extends _LaidOutCell { + final List appointments; + @override + final int lane; + @override + final int laneCount; + @override + final DateTime startTime; + @override + final DateTime endTime; + _LaidOutOverflow(this.appointments, this.lane, this.laneCount, this.startTime, this.endTime); +} + +/// Assigns each appointment a lane index using a greedy sweep, then collapses +/// clusters that exceed [_kMaxVisibleCells] into 1 visible appointment + 1 +/// overflow cell side by side. +/// +/// Greedy sweep: +/// 1. Sort by `startTime` ascending, `endTime` descending on ties. +/// 2. Walk the list, placing each appointment in the lowest-index lane that +/// is free at its `startTime`. When no lane is free, open a new one. +/// 3. A cluster ends as soon as every active lane's end is at or before the +/// next appointment's start. +List<_LaidOutCell> _assignLanes(List appts) { + if (appts.isEmpty) return const <_LaidOutCell>[]; + + final sorted = [...appts]..sort((a, b) { + final c = a.startTime.compareTo(b.startTime); + if (c != 0) return c; + return b.endTime.compareTo(a.endTime); + }); + + // Phase 1: greedy lane assignment, grouped by cluster. + final clusters = >[]; + var current = <({Appointment apt, int lane})>[]; + var laneEnds = []; + + for (final apt in sorted) { + final allFree = + laneEnds.isNotEmpty && laneEnds.every((end) => !end.isAfter(apt.startTime)); + if (allFree) { + clusters.add(current); + current = <({Appointment apt, int lane})>[]; + laneEnds = []; + } + + var laneIdx = -1; + for (var i = 0; i < laneEnds.length; i++) { + if (!laneEnds[i].isAfter(apt.startTime)) { + laneIdx = i; + break; + } + } + if (laneIdx == -1) { + laneIdx = laneEnds.length; + laneEnds.add(apt.endTime); + } else { + laneEnds[laneIdx] = apt.endTime; + } + + current.add((apt: apt, lane: laneIdx)); + } + if (current.isNotEmpty) clusters.add(current); + + // Phase 2: emit cells per cluster, collapsing if too wide. + final result = <_LaidOutCell>[]; + for (final cluster in clusters) { + final laneCount = + cluster.fold(0, (m, e) => e.lane + 1 > m ? e.lane + 1 : m); + + if (laneCount <= _kMaxVisibleCells) { + for (final entry in cluster) { + result.add(_LaidOutAppointment(entry.apt, entry.lane, laneCount)); + } + } else { + // 3+ parallel appointments: keep the earliest, collapse the rest. + final byStart = [...cluster.map((e) => e.apt)] + ..sort((a, b) => a.startTime.compareTo(b.startTime)); + result.add(_LaidOutAppointment(byStart[0], 0, _kMaxVisibleCells)); + + final overflow = byStart.sublist(1); + var earliest = overflow.first.startTime; + var latest = overflow.first.endTime; + for (final a in overflow.skip(1)) { + if (a.startTime.isBefore(earliest)) earliest = a.startTime; + if (a.endTime.isAfter(latest)) latest = a.endTime; + } + result.add(_LaidOutOverflow( + overflow, _kMaxVisibleCells - 1, _kMaxVisibleCells, earliest, latest)); + } + } + return result; +} diff --git a/lib/widget/async_action_button.dart b/lib/widget/async_action_button.dart index 4a5e941..1044e82 100644 --- a/lib/widget/async_action_button.dart +++ b/lib/widget/async_action_button.dart @@ -15,7 +15,9 @@ Future runWithErrorDialog( } catch (e) { if (!context.mounted) return false; final message = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e); - InfoDialog.show(context, message); + final details = errorToTechnicalDetails(e); + final body = details != null && details != message ? '$message\n\n$details' : message; + InfoDialog.show(context, body, copyable: true, title: 'Fehler'); return false; } } diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index 06ce484..4f4e1a2 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -123,7 +123,7 @@ class _FileViewerState extends State { if (saved != null) InfoDialog.show(context, 'Datei gespeichert.'); } on Object catch (e) { if (!context.mounted) return; - InfoDialog.show(context, 'Speichern fehlgeschlagen: $e'); + InfoDialog.show(context, 'Speichern fehlgeschlagen: $e', copyable: true, title: 'Fehler'); } break; } diff --git a/lib/widget/info_dialog.dart b/lib/widget/info_dialog.dart index 001b2ed..59be35c 100644 --- a/lib/widget/info_dialog.dart +++ b/lib/widget/info_dialog.dart @@ -1,10 +1,51 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; class InfoDialog { - static void show(BuildContext context, String info) { - showDialog(context: context, builder: (context) => AlertDialog( - content: Text(info), - contentPadding: const EdgeInsets.all(20), - )); + /// Shows a single-text dialog. When [copyable] is true (default for error + /// details surfaces), the dialog body is selectable and a "Kopieren" action + /// places it on the clipboard with a SnackBar confirmation. + static void show( + BuildContext context, + String info, { + bool copyable = false, + String? title, + }) { + showDialog( + context: context, + builder: (dialogContext) { + final theme = Theme.of(dialogContext); + return AlertDialog( + title: title != null ? Text(title) : null, + content: SingleChildScrollView( + child: copyable + ? SelectableText(info, style: theme.textTheme.bodyMedium) + : Text(info, style: theme.textTheme.bodyMedium), + ), + contentPadding: const EdgeInsets.fromLTRB(20, 20, 20, 12), + actions: [ + if (copyable) + TextButton.icon( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: info)); + if (!dialogContext.mounted) return; + ScaffoldMessenger.of(dialogContext).showSnackBar( + const SnackBar( + content: Text('In Zwischenablage kopiert'), + duration: Duration(seconds: 2), + ), + ); + }, + icon: const Icon(Icons.copy_outlined, size: 18), + label: const Text('Kopieren'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Schließen'), + ), + ], + ); + }, + ); } } diff --git a/pubspec.yaml b/pubspec.yaml index 1798def..92eb3e2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,8 +37,6 @@ dependencies: intl: ^0.20.2 flutter_linkify: ^6.0.0 flutter_local_notifications: ^21.0.0 - flutter_login: ^6.0.0 - flutter_native_splash: ^2.4.4 flutter_split_view: ^0.1.2 flutter_svg: ^2.0.10 freezed_annotation: ^3.1.0 @@ -74,6 +72,7 @@ dependencies: dev_dependencies: flutter_launcher_icons: ^0.14.3 + flutter_native_splash: ^2.4.4 build_runner: ^2.10.5 freezed: ^3.2.4 From 95ef29fb09439bfa4885024a5a3acf3cfe6f1661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 6 May 2026 22:37:41 +0200 Subject: [PATCH 14/23] implemented dynamic module settings and configurable bottom bar, added all-day event support to timetable, and overhauled marianum dates UI with month grouping and search --- lib/app.dart | 116 ++++-- lib/routing/app_routes.dart | 5 + .../loadable_hydrated_bloc.dart | 5 + lib/state/app/modules/app_modules.dart | 38 +- lib/storage/modules_settings.dart | 6 +- lib/storage/modules_settings.g.dart | 4 + lib/storage/timetable_settings.dart | 2 +- .../marianum_dates/marianum_dates_view.dart | 371 +++++++++++++---- .../marianum_dates/search_marianum_dates.dart | 51 +++ lib/view/pages/overhang.dart | 71 +--- .../pages/settings/data/default_settings.dart | 4 +- .../pages/settings/modules_settings_page.dart | 116 ++++++ .../settings/sections/modules_section.dart | 16 + lib/view/pages/settings/settings.dart | 3 + .../custom_event_edit_dialog.dart | 103 +++-- .../data/timetable_appointment_factory.dart | 50 ++- .../details/delete_custom_event.dart | 5 +- lib/view/pages/timetable/timetable.dart | 15 +- .../widgets/custom_workweek_calendar.dart | 386 ++++++++++++++++-- 19 files changed, 1114 insertions(+), 253 deletions(-) create mode 100644 lib/view/pages/marianum_dates/search_marianum_dates.dart create mode 100644 lib/view/pages/settings/modules_settings_page.dart create mode 100644 lib/view/pages/settings/sections/modules_section.dart diff --git a/lib/app.dart b/lib/app.dart index 9f549c9..b33a5e5 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -18,6 +18,7 @@ import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; +import 'storage/settings.dart' as model; import 'utils/debouncer.dart'; import 'view/pages/overhang.dart'; import 'widget/breaker/breaker.dart'; @@ -32,6 +33,15 @@ class App extends StatefulWidget { class _AppState extends State with WidgetsBindingObserver { late Timer _refetchChats; late Timer _updateTimings; + // Tracked via the bottom-nav controller's listener so it always reflects the + // user's actual position, even between rapid setting emits where the + // controller hasn't caught up to a scheduled jump yet. + int _knownTotalTabs = 1; + bool _userOnLastTab = false; + + void _onTabControllerChanged() { + _userOnLastTab = Main.bottomNavigator.index == _knownTotalTabs - 1; + } @override void didChangeAppLifecycleState(AppLifecycleState state) { @@ -49,6 +59,7 @@ class _AppState extends State with WidgetsBindingObserver { void initState() { super.initState(); Main.bottomNavigator = PersistentTabController(initialIndex: 0); + Main.bottomNavigator.addListener(_onTabControllerChanged); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -102,38 +113,89 @@ class _AppState extends State with WidgetsBindingObserver { void dispose() { _refetchChats.cancel(); _updateTimings.cancel(); + Main.bottomNavigator.removeListener(_onTabControllerChanged); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override - Widget build(BuildContext context) => PersistentTabView( - controller: Main.bottomNavigator, - navBarOverlap: const NavBarOverlap.none(), - backgroundColor: Theme.of(context).colorScheme.primary, - handleAndroidBackButtonPress: true, - screenTransitionAnimation: const ScreenTransitionAnimation( - curve: Curves.easeOutQuad, - duration: Duration(milliseconds: 200), - ), - tabs: [ - ...AppModule.getBottomBarModules(context).map((e) => e.toBottomTab(context)), - PersistentTabConfig( - screen: const Breaker(breaker: BreakerArea.more, child: Overhang()), - item: ItemConfig( - activeForegroundColor: Theme.of(context).primaryColor, - inactiveForegroundColor: Theme.of(context).colorScheme.secondary, - icon: const Icon(Icons.apps), - title: 'Mehr', + Widget build(BuildContext context) => BlocBuilder( + builder: (context, _) { + final bottomBarModules = AppModule.getBottomBarModules(context); + final totalTabs = bottomBarModules.length + 1; + final currentIndex = Main.bottomNavigator.index; + + // The bottom-bar layout is identified by the ordered list of module + // names plus the trailing 'more' slot. Whenever this layout changes + // — slot count, reordering, or hiding a module — we recreate the + // entire PersistentTabView via the [layoutKey] below. The package + // caches per-tab navigator state by index in `_navigatorKeys`, and + // its internal `alignLength` only ever appends or trims at the end. + // So when the module sitting at e.g. index 3 changes, the navigator + // at that index still serves the old screen's route stack and the + // user sees stale content. Re-mounting clears those stacks; the + // trade-off (losing in-tab pushed routes on a settings change) is + // acceptable since the user explicitly re-shaped the bar. + final layoutKey = ValueKey('${bottomBarModules.map((m) => m.module.name).join('|')}|more'); + + if (totalTabs != _knownTotalTabs) { + var targetIndex = currentIndex; + if (_userOnLastTab) { + targetIndex = totalTabs - 1; + } else if (currentIndex >= totalTabs) { + targetIndex = totalTabs - 1; + } + // Re-mounting PTV with a new key constructs fresh internals from + // its controller's current index. If the controller still points + // past the new tab list, Style6BottomNavBar (and others) crash on + // out-of-range access during initState. Replace the controller + // atomically with one initialised at the safe target index so the + // new PTV mounts cleanly. + if (targetIndex != currentIndex) { + Main.bottomNavigator.removeListener(_onTabControllerChanged); + Main.bottomNavigator = PersistentTabController(initialIndex: targetIndex); + Main.bottomNavigator.addListener(_onTabControllerChanged); + _userOnLastTab = targetIndex == totalTabs - 1; + } + } + + _knownTotalTabs = totalTabs; + + return PersistentTabView( + key: layoutKey, + controller: Main.bottomNavigator, + navBarOverlap: const NavBarOverlap.none(), + backgroundColor: Theme.of(context).colorScheme.primary, + handleAndroidBackButtonPress: true, + screenTransitionAnimation: const ScreenTransitionAnimation( + curve: Curves.easeOutQuad, + duration: Duration(milliseconds: 200), ), - ), - ], - navBarBuilder: (config) => Style6BottomNavBar( - navBarConfig: config, - navBarDecoration: NavBarDecoration( - border: const Border(top: BorderSide(width: 1, color: Colors.grey)), - color: Theme.of(context).colorScheme.surface, - ), - ), + tabs: [ + ...bottomBarModules.map((e) => e.toBottomTab(context)), + PersistentTabConfig( + screen: const Breaker(breaker: BreakerArea.more, child: Overhang()), + item: ItemConfig( + activeForegroundColor: Theme.of(context).primaryColor, + inactiveForegroundColor: Theme.of(context).colorScheme.secondary, + icon: const Icon(Icons.apps), + title: 'Mehr', + ), + ), + ], + navBarBuilder: (config) => Style6BottomNavBar( + // Style6BottomNavBar builds its internal animation controller list + // in initState and never grows it on didUpdateWidget. Keying by the + // item count forces a fresh State whenever the slot count changes, + // which avoids a RangeError when more tabs slide in. + key: ValueKey(config.items.length), + navBarConfig: config, + navBarDecoration: NavBarDecoration( + border: const Border(top: BorderSide(width: 1, color: Colors.grey)), + color: Theme.of(context).colorScheme.surface, + ), + ), + ); + }, ); } diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 082d5ac..644b5f1 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -15,6 +15,7 @@ import '../view/pages/marianum_message/marianum_message_view.dart'; import '../view/pages/more/feedback/feedback_dialog.dart'; import '../view/pages/more/roomplan/roomplan.dart'; import '../view/pages/more/share/qr_share_view.dart'; +import '../view/pages/settings/modules_settings_page.dart'; import '../view/pages/settings/settings.dart'; import '../view/pages/talk/chat_view.dart'; import '../view/pages/talk/details/message_reactions.dart'; @@ -78,6 +79,10 @@ class AppRoutes { pushScreen(context, withNavBar: false, screen: const Settings()); } + static void openModulesSettings(BuildContext context) { + pushScreen(context, withNavBar: false, screen: const ModulesSettingsPage()); + } + static void openFeedback(BuildContext context) { pushScreen(context, withNavBar: false, screen: const FeedbackDialog()); } 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 74e9f00..ff5aaa3 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 @@ -95,6 +95,11 @@ abstract class LoadableHydratedBloc< gatherData().catchError( (e) { log('Error while fetching ${TState.toString()}: ${e.toString()}'); + // The bloc may have been closed before this async error landed (e.g. + // when its scoping widget tree was disposed mid-fetch). Adding to a + // closed bloc throws "Cannot add new events after calling close", + // so swallow that case quietly. + if (isClosed) return; add(Error(LoadingError( message: errorToUserMessage(e), technicalDetails: errorToTechnicalDetails(e), diff --git a/lib/state/app/modules/app_modules.dart b/lib/state/app/modules/app_modules.dart index 1d960f0..07b64bf 100644 --- a/lib/state/app/modules/app_modules.dart +++ b/lib/state/app/modules/app_modules.dart @@ -113,8 +113,42 @@ class AppModule { return { for (var element in settings.val().modulesSettings.moduleOrder.where((element) => available.containsKey(element))) element : available[element]! }; } - static List getBottomBarModules(BuildContext context) => modules(context).values.toList().getRange(0, 3).toList(); - static List getOverhangModules(BuildContext context) => modules(context).values.skip(3).toList(); + static const int minBottomBarSlots = 3; + static const int maxBottomBarSlots = 5; + + static int resolveBottomBarSlotCount(BuildContext context) { + final settings = context.read().val().modulesSettings; + final available = modules(context).length; + + int desired; + if (settings.autoFillBottomBar) { + final width = MediaQuery.of(context).size.width; + if (width >= 840) { + desired = 5; + } else if (width >= 600) { + desired = 4; + } else { + desired = 3; + } + } else { + desired = settings.fixedBottomBarSlots; + } + + desired = desired.clamp(minBottomBarSlots, maxBottomBarSlots); + return desired.clamp(0, available); + } + + static List getBottomBarModules(BuildContext context) { + final all = modules(context).values.toList(); + final slots = resolveBottomBarSlotCount(context); + return all.take(slots).toList(); + } + + static List getOverhangModules(BuildContext context) { + final all = modules(context).values.toList(); + final slots = resolveBottomBarSlotCount(context); + return all.skip(slots).toList(); + } Widget toListTile(BuildContext context, {Key? key, bool isReorder = false, Function()? onVisibleChange, bool isVisible = true}) => ListTile( key: key, diff --git a/lib/storage/modules_settings.dart b/lib/storage/modules_settings.dart index 14edc66..117f354 100644 --- a/lib/storage/modules_settings.dart +++ b/lib/storage/modules_settings.dart @@ -8,10 +8,14 @@ part 'modules_settings.g.dart'; class ModulesSettings { List moduleOrder; List hiddenModules; + bool autoFillBottomBar; + int fixedBottomBarSlots; ModulesSettings({ required this.moduleOrder, - required this.hiddenModules + required this.hiddenModules, + this.autoFillBottomBar = true, + this.fixedBottomBarSlots = 3, }); factory ModulesSettings.fromJson(Map json) => _$ModulesSettingsFromJson(json); diff --git a/lib/storage/modules_settings.g.dart b/lib/storage/modules_settings.g.dart index fd51b41..e97774b 100644 --- a/lib/storage/modules_settings.g.dart +++ b/lib/storage/modules_settings.g.dart @@ -14,6 +14,8 @@ ModulesSettings _$ModulesSettingsFromJson(Map json) => hiddenModules: (json['hiddenModules'] as List) .map((e) => $enumDecode(_$ModulesEnumMap, e)) .toList(), + autoFillBottomBar: json['autoFillBottomBar'] as bool? ?? true, + fixedBottomBarSlots: (json['fixedBottomBarSlots'] as num?)?.toInt() ?? 3, ); Map _$ModulesSettingsToJson( @@ -23,6 +25,8 @@ Map _$ModulesSettingsToJson( 'hiddenModules': instance.hiddenModules .map((e) => _$ModulesEnumMap[e]!) .toList(), + 'autoFillBottomBar': instance.autoFillBottomBar, + 'fixedBottomBarSlots': instance.fixedBottomBarSlots, }; const _$ModulesEnumMap = { diff --git a/lib/storage/timetable_settings.dart b/lib/storage/timetable_settings.dart index c4db103..75faf6a 100644 --- a/lib/storage/timetable_settings.dart +++ b/lib/storage/timetable_settings.dart @@ -11,7 +11,7 @@ class TimetableSettings { TimetableSettings({ required this.connectDoubleLessons, - required this.timetableNameMode + required this.timetableNameMode, }); factory TimetableSettings.fromJson(Map json) => _$TimetableSettingsFromJson(json); diff --git a/lib/view/pages/marianum_dates/marianum_dates_view.dart b/lib/view/pages/marianum_dates/marianum_dates_view.dart index 74e1a84..1805539 100644 --- a/lib/view/pages/marianum_dates/marianum_dates_view.dart +++ b/lib/view/pages/marianum_dates/marianum_dates_view.dart @@ -10,95 +10,287 @@ import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart import '../../../widget/animated_time.dart'; import '../../../widget/centered_leading.dart'; import '../../../widget/debug/debug_tile.dart'; -import '../../../widget/list_view_util.dart'; +import '../../../widget/placeholder_view.dart'; import '../timetable/custom_events/custom_event_edit_dialog.dart'; +import 'search_marianum_dates.dart'; class MarianumDatesView extends StatelessWidget { const MarianumDatesView({super.key}); - @override - Widget build(BuildContext context) => BlocModule>( - create: (context) => MarianumDatesBloc(), - autoRebuild: true, - child: (context, bloc, state) => Scaffold( - appBar: AppBar( - title: const Text('Marianum Termine'), - actions: [ - PopupMenuButton( - initialValue: bloc.showPastEvents(), - icon: const Icon(Icons.history), - itemBuilder: (context) => [true, false].map((e) => PopupMenuItem( - value: e, - enabled: e != bloc.showPastEvents(), - child: Row( - children: [ - Icon(e ? Icons.history_outlined : Icons.history_toggle_off_outlined, color: Theme.of(context).colorScheme.onSurface), - const SizedBox(width: 15), - Text(e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen'), - ], - ), - )).toList(), - onSelected: (e) => bloc.add(SetPastEventsVisible(e)), - ), - ], - ), - body: LoadableStateConsumer( - child: (state, loading) => ListViewUtil.fromList(bloc.getEvents(), (event) => _MarianumDateTile(event: event)), - ), - ), - ); -} - -class _MarianumDateTile extends StatelessWidget { - final MarianumDate event; - const _MarianumDateTile({required this.event}); - - String _formatSubtitle() { - final start = Jiffy.parseFromDateTime(event.start); - final end = Jiffy.parseFromDateTime(event.end); - - if (event.isAllDay) { - // iCal end is exclusive for multi-day all-day events. The feed sets - // DTSTART == DTEND for single-day all-day events, so only subtract a - // day when end actually advances past start. - final inclusiveEnd = event.end.isAfter(event.start) ? end.subtract(days: 1) : end; - final sameAllDay = start.format(pattern: 'yyyy-MM-dd') == inclusiveEnd.format(pattern: 'yyyy-MM-dd'); - return sameAllDay - ? '${start.format(pattern: 'dd.MM.yyyy')} · Ganztägig' - : '${start.format(pattern: 'dd.MM.yyyy')} – ${inclusiveEnd.format(pattern: 'dd.MM.yyyy')} · Ganztägig'; + /// Groups events by `yyyy-MM` (chronological). Uses the event's start date. + static List<_MonthGroup> _groupByMonth(List events) { + final byMonth = >{}; + for (final e in events) { + final key = '${e.start.year.toString().padLeft(4, '0')}-${e.start.month.toString().padLeft(2, '0')}'; + byMonth.putIfAbsent(key, () => []).add(e); } - - final sameDay = start.format(pattern: 'yyyy-MM-dd') == end.format(pattern: 'yyyy-MM-dd'); - if (sameDay) { - if (event.start == event.end) { - return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')}'; - } - return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')} – ${end.format(pattern: 'HH:mm')}'; - } - return '${start.format(pattern: 'dd.MM.yyyy HH:mm')} – ${end.format(pattern: 'dd.MM.yyyy HH:mm')}'; + final keys = byMonth.keys.toList()..sort(); + return keys.map((key) { + final first = byMonth[key]!.first.start; + final label = Jiffy.parseFromDateTime(first).format(pattern: 'MMMM yyyy').toUpperCase(); + return _MonthGroup(key: key, label: label, events: byMonth[key]!); + }).toList(); } @override - Widget build(BuildContext context) => ListTile( - leading: const CenteredLeading(Icon(Icons.event)), - title: Text(event.title.isEmpty ? '(ohne Titel)' : event.title), - subtitle: Text(_formatSubtitle()), - onTap: () => _showDetails(context), - trailing: IconButton( - icon: const Icon(Icons.add_circle_outline), - tooltip: 'In Stundenplan übernehmen', - onPressed: () => showDialog( - context: context, - builder: (_) => CustomEventEditDialog( - initialTitle: event.title, - initialDescription: event.description, - initialStart: event.start, - initialEnd: event.end, + Widget build(BuildContext context) => BlocModule>( + create: (context) => MarianumDatesBloc(), + autoRebuild: true, + child: (context, bloc, state) => Scaffold( + appBar: AppBar( + title: const Text('Marianum Termine'), + actions: [ + PopupMenuButton( + initialValue: bloc.showPastEvents(), + icon: const Icon(Icons.history), + itemBuilder: (context) => [true, false] + .map((e) => PopupMenuItem( + value: e, + enabled: e != bloc.showPastEvents(), + child: Row( + children: [ + Icon(e ? Icons.history_outlined : Icons.history_toggle_off_outlined, + color: Theme.of(context).colorScheme.onSurface), + const SizedBox(width: 15), + Text(e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen'), + ], + ), + )) + .toList(), + onSelected: (e) => bloc.add(SetPastEventsVisible(e)), + ), + IconButton( + icon: const Icon(Icons.search), + onPressed: () { + final events = bloc.getEvents() ?? const []; + showSearch(context: context, delegate: SearchMarianumDates(events)); + }, + ), + ], + ), + body: LoadableStateConsumer( + child: (state, loading) { + final events = bloc.getEvents() ?? const []; + final groups = _groupByMonth(events); + + if (groups.isEmpty) { + return const PlaceholderView( + icon: Icons.event_busy_outlined, + text: 'Keine Termine', + ); + } + + return CustomScrollView( + slivers: [ + for (final group in groups) + SliverMainAxisGroup( + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: MonthHeaderDelegate(label: group.label), + ), + SliverList.builder( + itemCount: group.events.length, + itemBuilder: (_, i) => MarianumDateRow(event: group.events[i]), + ), + ], + ), + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], + ); + }, + ), + ), + ); +} + +class _MonthGroup { + final String key; + final String label; + final List events; + _MonthGroup({required this.key, required this.label, required this.events}); +} + +class MonthHeaderDelegate extends SliverPersistentHeaderDelegate { + final String label; + MonthHeaderDelegate({required this.label}); + + static const double _height = 38; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + final theme = Theme.of(context); + return Container( + height: _height, + color: theme.colorScheme.surfaceContainer, + padding: const EdgeInsets.symmetric(horizontal: 16), + alignment: Alignment.centerLeft, + child: Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, ), - barrierDismissible: false, ), - ), - ); + ); + } + + @override + double get maxExtent => _height; + + @override + double get minExtent => _height; + + @override + bool shouldRebuild(covariant MonthHeaderDelegate oldDelegate) => oldDelegate.label != label; +} + +/// Composite icon: calendar with a small plus badge in the bottom-right. +/// Material's bundled icon set has no `calendar_add_on`, so we layer +/// `Icons.event_outlined` and `Icons.add` to get the same affordance. +class _CalendarPlusIcon extends StatelessWidget { + final Color color; + const _CalendarPlusIcon({required this.color}); + + @override + Widget build(BuildContext context) => SizedBox( + width: 22, + height: 22, + child: Stack( + clipBehavior: Clip.none, + children: [ + Icon(Icons.event_outlined, size: 22, color: color), + Positioned( + right: -2, + bottom: -2, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(1), + child: Icon(Icons.add_circle, size: 12, color: color), + ), + ), + ], + ), + ); +} + +class MarianumDateRow extends StatelessWidget { + final MarianumDate event; + const MarianumDateRow({required this.event, super.key}); + + String _dayLabel() => event.start.day.toString().padLeft(2, '0'); + + String _monthYearLabel() => + '${event.start.month.toString().padLeft(2, '0')}.${event.start.year}'; + + String _trailingLabel() { + final start = Jiffy.parseFromDateTime(event.start); + final end = Jiffy.parseFromDateTime(event.end); + if (event.isAllDay) return 'Ganztägig'; + final sameDay = start.format(pattern: 'yyyy-MM-dd') == end.format(pattern: 'yyyy-MM-dd'); + if (sameDay) { + if (event.start == event.end) return start.format(pattern: 'HH:mm'); + return '${start.format(pattern: 'HH:mm')}–${end.format(pattern: 'HH:mm')}'; + } + return '${start.format(pattern: 'dd.MM. HH:mm')}–${end.format(pattern: 'dd.MM. HH:mm')}'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return InkWell( + onTap: () => _showDetails(context), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 4, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 44, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _dayLabel(), + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + height: 1.1, + ), + ), + Text( + _monthYearLabel(), + textAlign: TextAlign.center, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.visible, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 10, + height: 1.1, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + event.title.isEmpty ? '(ohne Titel)' : event.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface), + ), + if (event.description != null && event.description!.trim().isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + event.description!.trim(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Text( + _trailingLabel(), + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 4), + IconButton( + icon: _CalendarPlusIcon(color: theme.colorScheme.onSurfaceVariant), + tooltip: 'In Stundenplan übernehmen', + onPressed: () => showDialog( + context: context, + builder: (_) => CustomEventEditDialog( + initialTitle: event.title, + initialDescription: event.description, + initialStart: event.start, + initialEnd: event.end, + ), + barrierDismissible: false, + ), + ), + ], + ), + ), + ); + } void _showDetails(BuildContext context) { showDialog( @@ -108,7 +300,7 @@ class _MarianumDateTile extends StatelessWidget { children: [ ListTile( leading: const CenteredLeading(Icon(Icons.date_range_outlined)), - title: Text(_formatSubtitle()), + title: Text(_formatLongRange()), ), if (event.description != null && event.description!.trim().isNotEmpty) ListTile( @@ -132,4 +324,25 @@ class _MarianumDateTile extends StatelessWidget { ), ); } + + String _formatLongRange() { + final start = Jiffy.parseFromDateTime(event.start); + final end = Jiffy.parseFromDateTime(event.end); + if (event.isAllDay) { + final inclusiveEnd = event.end.isAfter(event.start) ? end.subtract(days: 1) : end; + final sameAllDay = + start.format(pattern: 'yyyy-MM-dd') == inclusiveEnd.format(pattern: 'yyyy-MM-dd'); + return sameAllDay + ? '${start.format(pattern: 'dd.MM.yyyy')} · Ganztägig' + : '${start.format(pattern: 'dd.MM.yyyy')} – ${inclusiveEnd.format(pattern: 'dd.MM.yyyy')} · Ganztägig'; + } + final sameDay = start.format(pattern: 'yyyy-MM-dd') == end.format(pattern: 'yyyy-MM-dd'); + if (sameDay) { + if (event.start == event.end) { + return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')}'; + } + return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')} – ${end.format(pattern: 'HH:mm')}'; + } + return '${start.format(pattern: 'dd.MM.yyyy HH:mm')} – ${end.format(pattern: 'dd.MM.yyyy HH:mm')}'; + } } diff --git a/lib/view/pages/marianum_dates/search_marianum_dates.dart b/lib/view/pages/marianum_dates/search_marianum_dates.dart new file mode 100644 index 0000000..7dadb5a --- /dev/null +++ b/lib/view/pages/marianum_dates/search_marianum_dates.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart'; +import '../../../widget/placeholder_view.dart'; +import 'marianum_dates_view.dart'; + +class SearchMarianumDates extends SearchDelegate { + final List events; + + SearchMarianumDates(this.events); + + List _matches() { + if (query.trim().isEmpty) return events; + final q = query.trim().toLowerCase(); + return events.where((e) { + final title = e.title.toLowerCase(); + final desc = e.description?.toLowerCase() ?? ''; + return title.contains(q) || desc.contains(q); + }).toList(); + } + + @override + List? buildActions(BuildContext context) => [ + if (query.isNotEmpty) + IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)), + ]; + + @override + Widget? buildLeading(BuildContext context) => IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => close(context, null), + ); + + @override + Widget buildResults(BuildContext context) { + final matches = _matches(); + if (matches.isEmpty) { + return const PlaceholderView( + icon: Icons.search_off_outlined, + text: 'Keine Treffer', + ); + } + return ListView.builder( + itemCount: matches.length, + itemBuilder: (_, i) => MarianumDateRow(event: matches[i]), + ); + } + + @override + Widget buildSuggestions(BuildContext context) => buildResults(context); +} diff --git a/lib/view/pages/overhang.dart b/lib/view/pages/overhang.dart index 01b4c50..214d500 100644 --- a/lib/view/pages/overhang.dart +++ b/lib/view/pages/overhang.dart @@ -2,18 +2,14 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:in_app_review/in_app_review.dart'; import '../../extensions/render_not_null.dart'; import '../../routing/app_routes.dart'; import '../../state/app/modules/app_modules.dart'; -import '../../state/app/modules/settings/bloc/settings_cubit.dart'; -import '../../storage/settings.dart' as model; import '../../widget/centered_leading.dart'; import '../../widget/info_dialog.dart'; import 'more/share/select_share_type_dialog.dart'; -import 'settings/data/default_settings.dart'; class Overhang extends StatefulWidget { const Overhang({super.key}); @@ -23,65 +19,16 @@ class Overhang extends StatefulWidget { } class _OverhangState extends State { - bool editMode = false; - @override - Widget build(BuildContext context) => BlocBuilder(builder: (context, _) { - final settings = context.read(); - return Scaffold( - appBar: AppBar( - title: const Text('Mehr'), - actions: [ - if(editMode) IconButton( - onPressed: settings.val().modulesSettings.toJson().toString() != DefaultSettings.get().modulesSettings.toJson().toString() - ? () => settings.val(write: true).modulesSettings = DefaultSettings.get().modulesSettings - : null, - icon: Icon(Icons.undo_outlined) - ), - IconButton(onPressed: () => setState(() => editMode = !editMode), icon: Icon(Icons.edit_note_outlined), color: editMode ? Theme.of(context).primaryColor : null), - IconButton(onPressed: editMode ? null : () => AppRoutes.openSettings(context), icon: const Icon(Icons.settings)), - ], - ), - body: editMode ? _sorting() : _overhang(), - ); - }); - - Widget _sorting() => BlocBuilder(builder: (context, _) { - final settings = context.read(); - void changeVisibility(Modules module) { - var hidden = settings.val(write: true).modulesSettings.hiddenModules; - if (hidden.contains(module)) { - hidden.remove(module); - } else if (hidden.length < 3) { - hidden.add(module); - } - } - - return ReorderableListView( - header: const Center( - heightFactor: 2, - child: Text('Halte und ziehe einen Eintrag, um ihn zu verschieben.\nEs können 3 Bereiche ausgeblendet werden.', textAlign: TextAlign.center) - ), - children: AppModule.modules(context, showFiltered: true) - .map((key, value) => MapEntry(key, value.toListTile( - context, - key: Key(key.name), - isReorder: true, - onVisibleChange: () => changeVisibility(key), - isVisible: !settings.val().modulesSettings.hiddenModules.contains(key) - ))) - .values - .toList(), - onReorder: (oldIndex, newIndex) { - if (newIndex > oldIndex) newIndex -= 1; - - var order = settings.val().modulesSettings.moduleOrder.toList(); - final movedModule = order.removeAt(oldIndex); - order.insert(newIndex, movedModule); - settings.val(write: true).modulesSettings.moduleOrder = order; - } - ); - }); + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('Mehr'), + actions: [ + IconButton(onPressed: () => AppRoutes.openSettings(context), icon: const Icon(Icons.settings)), + ], + ), + body: _overhang(), + ); Widget _overhang() => ListView( children: [ diff --git a/lib/view/pages/settings/data/default_settings.dart b/lib/view/pages/settings/data/default_settings.dart index 63e060a..d835ee5 100644 --- a/lib/view/pages/settings/data/default_settings.dart +++ b/lib/view/pages/settings/data/default_settings.dart @@ -31,10 +31,12 @@ class DefaultSettings { Modules.marianumDates, ], hiddenModules: [], + autoFillBottomBar: true, + fixedBottomBarSlots: 3, ), timetableSettings: TimetableSettings( connectDoubleLessons: true, - timetableNameMode: TimetableNameMode.name + timetableNameMode: TimetableNameMode.name, ), talkSettings: TalkSettings( sortFavoritesToTop: true, diff --git a/lib/view/pages/settings/modules_settings_page.dart b/lib/view/pages/settings/modules_settings_page.dart new file mode 100644 index 0000000..c414d20 --- /dev/null +++ b/lib/view/pages/settings/modules_settings_page.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../state/app/modules/app_modules.dart'; +import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../storage/settings.dart' as model; +import 'data/default_settings.dart'; + +/// Reorderable list with bottom-bar slot configuration on top. +/// +/// Used both inline in the "Mehr" edit mode and as the body of +/// [ModulesSettingsPage] from the main settings. +class ModuleSortBody extends StatelessWidget { + const ModuleSortBody({super.key}); + + @override + Widget build(BuildContext context) => BlocBuilder(builder: (context, _) { + final settings = context.read(); + final modulesSettings = settings.val().modulesSettings; + + void changeVisibility(Modules module) { + var hidden = settings.val(write: true).modulesSettings.hiddenModules; + if (hidden.contains(module)) { + hidden.remove(module); + } else if (hidden.length < 3) { + hidden.add(module); + } + } + + return ReorderableListView( + header: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Text( + 'Halte und ziehe einen Eintrag, um ihn zu verschieben.\nEs können 3 Bereiche ausgeblendet werden.', + textAlign: TextAlign.center, + ), + ), + SwitchListTile( + title: const Text('Modulleiste automatisch füllen'), + subtitle: const Text('Auf größeren Bildschirmen werden mehr Module direkt angezeigt'), + value: modulesSettings.autoFillBottomBar, + onChanged: (value) => settings.val(write: true).modulesSettings.autoFillBottomBar = value, + ), + if (!modulesSettings.autoFillBottomBar) + ListTile( + title: const Text('Anzahl Slots in der Modulleiste'), + subtitle: Text('${modulesSettings.fixedBottomBarSlots} Module (zzgl. „Mehr")'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.remove_circle_outline), + onPressed: modulesSettings.fixedBottomBarSlots > AppModule.minBottomBarSlots + ? () => settings.val(write: true).modulesSettings.fixedBottomBarSlots -= 1 + : null, + ), + Text('${modulesSettings.fixedBottomBarSlots}'), + IconButton( + icon: const Icon(Icons.add_circle_outline), + onPressed: modulesSettings.fixedBottomBarSlots < AppModule.maxBottomBarSlots + ? () => settings.val(write: true).modulesSettings.fixedBottomBarSlots += 1 + : null, + ), + ], + ), + ), + const Divider(), + ], + ), + children: AppModule.modules(context, showFiltered: true) + .map((key, value) => MapEntry(key, value.toListTile( + context, + key: Key(key.name), + isReorder: true, + onVisibleChange: () => changeVisibility(key), + isVisible: !settings.val().modulesSettings.hiddenModules.contains(key), + ))) + .values + .toList(), + onReorder: (oldIndex, newIndex) { + if (newIndex > oldIndex) newIndex -= 1; + + var order = settings.val().modulesSettings.moduleOrder.toList(); + final movedModule = order.removeAt(oldIndex); + order.insert(newIndex, movedModule); + settings.val(write: true).modulesSettings.moduleOrder = order; + }, + ); + }); +} + +class ModulesSettingsPage extends StatelessWidget { + const ModulesSettingsPage({super.key}); + + @override + Widget build(BuildContext context) => BlocBuilder(builder: (context, _) { + final settings = context.read(); + final isModified = settings.val().modulesSettings.toJson().toString() != DefaultSettings.get().modulesSettings.toJson().toString(); + return Scaffold( + appBar: AppBar( + title: const Text('Module'), + actions: [ + IconButton( + tooltip: 'Auf Standard zurücksetzen', + onPressed: isModified ? () => settings.val(write: true).modulesSettings = DefaultSettings.get().modulesSettings : null, + icon: const Icon(Icons.undo_outlined), + ), + ], + ), + body: const ModuleSortBody(), + ); + }); +} diff --git a/lib/view/pages/settings/sections/modules_section.dart b/lib/view/pages/settings/sections/modules_section.dart new file mode 100644 index 0000000..b3fe499 --- /dev/null +++ b/lib/view/pages/settings/sections/modules_section.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +import '../../../../routing/app_routes.dart'; + +class ModulesSection extends StatelessWidget { + const ModulesSection({super.key}); + + @override + Widget build(BuildContext context) => ListTile( + leading: const Icon(Icons.apps_outlined), + title: const Text('Module'), + subtitle: const Text('Reihenfolge, Sichtbarkeit und Modulleiste anpassen'), + trailing: const Icon(Icons.arrow_right), + onTap: () => AppRoutes.openModulesSettings(context), + ); +} diff --git a/lib/view/pages/settings/settings.dart b/lib/view/pages/settings/settings.dart index ebb45ea..040d6eb 100644 --- a/lib/view/pages/settings/settings.dart +++ b/lib/view/pages/settings/settings.dart @@ -4,6 +4,7 @@ import 'sections/about_section.dart'; import 'sections/account_section.dart'; import 'sections/appearance_section.dart'; import 'sections/files_section.dart'; +import 'sections/modules_section.dart'; import 'sections/talk_section.dart'; import 'sections/timetable_section.dart'; @@ -19,6 +20,8 @@ class Settings extends StatelessWidget { Divider(), AppearanceSection(), Divider(), + ModulesSection(), + Divider(), TimetableSection(), Divider(), TalkSection(), 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 a85b652..db1d06b 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 @@ -9,8 +9,8 @@ import 'package:time_range_picker/time_range_picker.dart'; import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../extensions/date_time.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../../widget/async_action_button.dart'; import '../../../../widget/focus_behaviour.dart'; -import '../../../../widget/info_dialog.dart'; import 'custom_event_colors.dart'; class CustomEventEditDialog extends StatefulWidget { @@ -34,15 +34,18 @@ class CustomEventEditDialog extends StatefulWidget { } class _CustomEventEditDialogState extends State { - // Visible window of the timetable / time picker (matches `_pickTimeRange`'s - // `disabledTime`). Pre-filled times from outside this window are clamped in. + // Selectable window for non-all-day events. Times outside this range are + // clamped in. For events outside school hours, use the all-day toggle. static const TimeOfDay _windowStart = TimeOfDay(hour: 8, minute: 0); static const TimeOfDay _windowEnd = TimeOfDay(hour: 16, minute: 30); + static const TimeOfDay _defaultStart = _windowStart; + static const TimeOfDay _defaultEnd = TimeOfDay(hour: 9, minute: 30); static const int _minDurationMinutes = 15; late DateTime _date = widget.existingEvent?.startDate ?? widget.initialStart ?? DateTime.now(); late TimeOfDay _startTime; late TimeOfDay _endTime; + late bool _isAllDay; late final TextEditingController _name = TextEditingController( text: widget.existingEvent?.title ?? widget.initialTitle, ); @@ -61,12 +64,22 @@ class _CustomEventEditDialogState extends State { void initState() { super.initState(); if (_isEditing) { - _startTime = widget.existingEvent!.startDate.toTimeOfDay(); - _endTime = widget.existingEvent!.endDate.toTimeOfDay(); + final s = widget.existingEvent!.startDate; + final e = widget.existingEvent!.endDate; + _isAllDay = isAllDayConvention(s, e); + if (_isAllDay) { + _startTime = _defaultStart; + _endTime = _defaultEnd; + } else { + final clamped = _clampToVisibleWindow(s.toTimeOfDay(), e.toTimeOfDay()); + _startTime = clamped.$1; + _endTime = clamped.$2; + } return; } - final rawStart = widget.initialStart?.toTimeOfDay() ?? _windowStart; - final rawEnd = widget.initialEnd?.toTimeOfDay() ?? const TimeOfDay(hour: 9, minute: 30); + _isAllDay = false; + final rawStart = widget.initialStart?.toTimeOfDay() ?? _defaultStart; + final rawEnd = widget.initialEnd?.toTimeOfDay() ?? _defaultEnd; final clamped = _clampToVisibleWindow(rawStart, rawEnd); _startTime = clamped.$1; _endTime = clamped.$2; @@ -88,17 +101,38 @@ class _CustomEventEditDialogState extends State { return (fromMin(start), fromMin(end)); } - bool _validate() => _name.text.isNotEmpty; + /// 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; - void _save() { - if (!_validate()) return; + Future _save() async { + if (_name.text.trim().isEmpty) { + throw Exception('Bitte einen Terminnamen eingeben.'); + } + + // 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 startDate = _isAllDay ? midnight : _date.withTime(_startTime); + final endDate = _isAllDay ? midnight : _date.withTime(_endTime); final edited = CustomTimetableEvent( id: widget.existingEvent?.id ?? '', title: _name.text, description: _description.text, - startDate: _date.withTime(_startTime), - endDate: _date.withTime(_endTime), + startDate: startDate, + endDate: endDate, color: _color.name, rrule: _rrule, createdAt: DateTime.now(), @@ -106,17 +140,11 @@ class _CustomEventEditDialogState extends State { ); final bloc = context.read(); - final future = _isEditing - ? bloc.updateCustomEvent(widget.existingEvent!.id, edited) - : bloc.addCustomEvent(edited); - - future.then((_) { - if (!mounted) return; - Navigator.of(context).pop(); - }).catchError((Object error) { - if (!mounted) return; - InfoDialog.show(context, error.toString(), copyable: true, title: 'Fehler'); - }); + if (_isEditing) { + await bloc.updateCustomEvent(widget.existingEvent!.id, edited); + } else { + await bloc.addCustomEvent(edited); + } } Future _pickDate() async { @@ -138,8 +166,8 @@ class _CustomEventEditDialogState extends State { start: _startTime, end: _endTime, disabledTime: TimeRange( - startTime: const TimeOfDay(hour: 16, minute: 30), - endTime: const TimeOfDay(hour: 8, minute: 0), + startTime: _windowEnd, + endTime: _windowStart, ), disabledColor: Colors.grey, paintingStyle: PaintingStyle.fill, @@ -147,7 +175,7 @@ class _CustomEventEditDialogState extends State { fromText: 'Beginnend', toText: 'Endend', strokeColor: Theme.of(context).colorScheme.secondary, - minDuration: const Duration(minutes: 15), + minDuration: Duration(minutes: _minDurationMinutes), selectedColor: Theme.of(context).primaryColor, ticks: 24, ); @@ -191,12 +219,19 @@ class _CustomEventEditDialogState extends State { subtitle: const Text('Datum'), onTap: _pickDate, ), - ListTile( - leading: const Icon(Icons.access_time_outlined), - title: Text('${_startTime.format(context)} - ${_endTime.format(context)}'), - subtitle: const Text('Zeitraum'), - onTap: _pickTimeRange, + SwitchListTile( + secondary: const Icon(Icons.today_outlined), + title: const Text('Ganztägig'), + value: _isAllDay, + onChanged: (v) => setState(() => _isAllDay = v), ), + if (!_isAllDay) + ListTile( + leading: const Icon(Icons.access_time_outlined), + title: Text('${_startTime.format(context)} - ${_endTime.format(context)}'), + subtitle: const Text('Zeitraum'), + onTap: _pickTimeRange, + ), const Divider(), ListTile( leading: const Icon(Icons.color_lens_outlined), @@ -246,8 +281,10 @@ class _CustomEventEditDialogState extends State { ), ), actions: [ - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')), - TextButton(onPressed: _save, child: Text(_isEditing ? 'Speichern' : 'Erstellen')), + AsyncDialogAction( + confirmLabel: _isEditing ? 'Speichern' : 'Erstellen', + onConfirm: _save, + ), ], ); } diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index 985dfd7..258c359 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -68,27 +68,51 @@ class TimetableAppointmentFactory { } } - Appointment _customEventToAppointment(CustomTimetableEvent event) => Appointment( - id: CustomAppointment(event), - startTime: event.startDate, - endTime: event.endDate, - location: _collapseWhitespace(event.description), - subject: _collapseWhitespace(event.title) ?? event.title, - recurrenceRule: event.rrule, - color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name), - startTimeZone: '', - endTimeZone: '', - ); + Appointment _customEventToAppointment(CustomTimetableEvent event) { + final allDay = isCustomEventAllDay(event); + return Appointment( + id: CustomAppointment(event), + startTime: event.startDate, + endTime: allDay + ? DateTime(event.startDate.year, event.startDate.month, event.startDate.day, 23, 59) + : event.endDate, + isAllDay: allDay, + location: _collapseWhitespace(event.description), + subject: _collapseWhitespace(event.title) ?? event.title, + recurrenceRule: event.rrule, + color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name), + startTimeZone: '', + endTimeZone: '', + ); + } + + /// All-day convention: a `CustomTimetableEvent` is treated as all-day when + /// its `startDate` and `endDate` both land on midnight of the same day. + /// Keeps the backend schema unchanged — the editor stores all-day events as + /// `start == end == midnight(date)`. + static bool isCustomEventAllDay(CustomTimetableEvent event) { + final s = event.startDate; + final e = event.endDate; + return s.year == e.year && + s.month == e.month && + s.day == e.day && + s.hour == 0 && + s.minute == 0 && + s.second == 0 && + e.hour == 0 && + e.minute == 0 && + e.second == 0; + } String _subjectName(GetTimetableResponseObject lesson) { final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id); - if (subject == null) return 'Unbekannt'; + if (subject == null) return 'Event'; final name = switch (settings.timetableNameMode) { TimetableNameMode.name => subject.name, TimetableNameMode.longName => subject.longName, TimetableNameMode.alternateName => subject.alternateName, }; - return _collapseWhitespace(name) ?? 'Unbekannt'; + return _collapseWhitespace(name) ?? 'Event'; } String _locationLabel(GetTimetableResponseObject lesson) { diff --git a/lib/view/pages/timetable/details/delete_custom_event.dart b/lib/view/pages/timetable/details/delete_custom_event.dart index 7361a70..1ada219 100644 --- a/lib/view/pages/timetable/details/delete_custom_event.dart +++ b/lib/view/pages/timetable/details/delete_custom_event.dart @@ -14,8 +14,9 @@ Completer showDeleteCustomEventDialog(BuildContext context, CustomTimetabl title: 'Termin löschen', content: 'Der ${event.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.', confirmButton: 'Löschen', - onConfirm: () { - bloc.removeCustomEvent(event.id).then(completer.complete).onError(completer.completeError); + onConfirmAsync: () async { + await bloc.removeCustomEvent(event.id); + completer.complete(); }, ).asDialog(context); return completer; diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index cd119e6..ffced37 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -78,14 +78,27 @@ class _TimetableState extends State { return false; } + bool _isOnInitialWeek(TimetableState state) { + final target = _initialDisplayDate(); + final targetMonday = target.subtract(Duration(days: target.weekday - 1)); + final mondayOnly = DateTime(targetMonday.year, targetMonday.month, targetMonday.day); + return state.startDate == mondayOnly; + } + @override Widget build(BuildContext context) { final bloc = context.read(); + final loadableState = context.watch().state; + final innerState = loadableState.data; + final atToday = innerState != null && _isOnInitialWeek(innerState); return Scaffold( appBar: AppBar( title: const Text('Stunden & Vertretungsplan'), actions: [ - IconButton(icon: const Icon(Icons.home_outlined), onPressed: _jumpToToday), + IconButton( + icon: const Icon(Icons.home_outlined), + onPressed: atToday ? null : _jumpToToday, + ), PopupMenuButton<_CalendarAction>( icon: const Icon(Icons.edit_calendar_outlined), onSelected: _onAction, diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 08a1384..53c70ef 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -43,7 +43,7 @@ class CustomWorkWeekCalendar extends StatefulWidget { } class CustomWorkWeekCalendarState extends State { - static const double _rulerWidth = 50; + static const double _rulerWidth = 36; late PageController _pageController; late int _currentWeekIndex; @@ -128,6 +128,28 @@ class CustomWorkWeekCalendarState extends State { ), ), ), + ClipRect( + child: AnimatedSize( + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 280), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: _OutsideHoursStrip( + key: ValueKey(visibleWeekStart), + weekStart: visibleWeekStart, + appointments: widget.appointments, + rulerWidth: _rulerWidth, + onAppointmentTap: widget.onAppointmentTap, + isCrossedOut: widget.isCrossedOut, + ), + ), + ), + ), Container(height: 0.5, color: theme.dividerColor.withAlpha(110)), Expanded( child: LayoutBuilder( @@ -189,6 +211,271 @@ class CustomWorkWeekCalendarState extends State { } } +class _OutsideHoursStrip extends StatelessWidget { + static const int _maxVisibleChips = 2; + static const double _chipHeight = 22; + static const double _chipSpacing = 3; + static const double _verticalPadding = 3; + + final DateTime weekStart; + final List appointments; + final double rulerWidth; + final void Function(Appointment) onAppointmentTap; + final bool Function(Appointment) isCrossedOut; + + const _OutsideHoursStrip({ + super.key, + required this.weekStart, + required this.appointments, + required this.rulerWidth, + required this.onAppointmentTap, + required this.isCrossedOut, + }); + + @override + Widget build(BuildContext context) { + final outside = _partitionAppointmentsForWeek(appointments, weekStart).outside; + if (outside.every((day) => day.isEmpty)) return const SizedBox.shrink(); + + final theme = Theme.of(context); + final maxChipsPerDay = outside + .map((day) => day.length > _maxVisibleChips ? _maxVisibleChips : day.length) + .fold(0, (m, c) => c > m ? c : m); + final stripHeight = _verticalPadding * 2 + + maxChipsPerDay * _chipHeight + + (maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * _chipSpacing : 0); + + return Container( + color: theme.colorScheme.surfaceContainerLowest, + padding: const EdgeInsets.symmetric(vertical: _verticalPadding), + child: SizedBox( + height: stripHeight - _verticalPadding * 2, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: rulerWidth), + for (var d = 0; d < 5; d++) + Expanded( + child: _OutsideDayColumn( + appointments: outside[d], + maxVisible: _maxVisibleChips, + chipHeight: _chipHeight, + chipSpacing: _chipSpacing, + onAppointmentTap: onAppointmentTap, + isCrossedOut: isCrossedOut, + ), + ), + ], + ), + ), + ); + } +} + +class _OutsideDayColumn extends StatelessWidget { + final List appointments; + final int maxVisible; + final double chipHeight; + final double chipSpacing; + final void Function(Appointment) onAppointmentTap; + final bool Function(Appointment) isCrossedOut; + + const _OutsideDayColumn({ + required this.appointments, + required this.maxVisible, + required this.chipHeight, + required this.chipSpacing, + required this.onAppointmentTap, + required this.isCrossedOut, + }); + + void _showOverflow(BuildContext context, List hidden) { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetCtx) => SafeArea( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: hidden.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (_, i) { + final apt = hidden[i]; + return ListTile( + leading: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: apt.color, + borderRadius: BorderRadius.circular(3), + ), + ), + title: Text( + apt.subject, + style: isCrossedOut(apt) + ? const TextStyle(decoration: TextDecoration.lineThrough) + : null, + ), + subtitle: Text(_subtitleFor(apt)), + onTap: () { + Navigator.of(sheetCtx).pop(); + onAppointmentTap(apt); + }, + ); + }, + ), + ), + ); + } + + static String _subtitleFor(Appointment a) { + if (_isAllDayLike(a)) return 'Ganztägig'; + return '${_hm(a.startTime)}–${_hm(a.endTime)}'; + } + + static String _hm(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + + @override + Widget build(BuildContext context) { + if (appointments.isEmpty) return const SizedBox.shrink(); + final sorted = [...appointments] + ..sort((a, b) { + final aLike = _isAllDayLike(a); + final bLike = _isAllDayLike(b); + if (aLike && !bLike) return -1; + if (!aLike && bLike) return 1; + return a.startTime.compareTo(b.startTime); + }); + final visible = sorted.length <= maxVisible + ? sorted + : sorted.take(maxVisible - 1).toList(); + final overflow = + sorted.length <= maxVisible ? const [] : sorted.skip(maxVisible - 1).toList(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < visible.length; i++) ...[ + if (i > 0) SizedBox(height: chipSpacing), + SizedBox( + height: chipHeight, + child: _OutsideChip( + appointment: visible[i], + onTap: () => onAppointmentTap(visible[i]), + ), + ), + ], + if (overflow.isNotEmpty) ...[ + SizedBox(height: chipSpacing), + SizedBox( + height: chipHeight, + child: _OutsideOverflowChip( + count: overflow.length, + onTap: () => _showOverflow(context, overflow), + ), + ), + ], + ], + ), + ); + } +} + +class _OutsideChip extends StatelessWidget { + final Appointment appointment; + final VoidCallback onTap; + + const _OutsideChip({required this.appointment, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final allDay = _isAllDayLike(appointment); + final timeLabel = allDay + ? null + : '${appointment.startTime.hour.toString().padLeft(2, '0')}:${appointment.startTime.minute.toString().padLeft(2, '0')}'; + + return Material( + color: appointment.color.withAlpha(60), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(7)), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Text( + appointment.subject, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + if (timeLabel != null) ...[ + const SizedBox(width: 4), + Flexible( + child: Text( + timeLabel, + maxLines: 1, + overflow: TextOverflow.fade, + softWrap: false, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 10, + ), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +class _OutsideOverflowChip extends StatelessWidget { + final int count; + final VoidCallback onTap; + + const _OutsideOverflowChip({required this.count, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: theme.colorScheme.secondaryContainer, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Center( + child: Text( + '+$count weitere', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } +} + class _DayHeaderStrip extends StatelessWidget { final DateTime weekStart; final DateTime today; @@ -301,7 +588,7 @@ class _WeekGrid extends StatelessWidget { @override Widget build(BuildContext context) { - final perDay = _expandAppointmentsForWeek(appointments, weekStart); + final partitioned = _partitionAppointmentsForWeek(appointments, weekStart); return Row( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -316,7 +603,7 @@ class _WeekGrid extends StatelessWidget { child: _DayColumn( date: weekStart.add(Duration(days: d)), schedule: schedule, - appointments: perDay[d], + appointments: partitioned.inside[d], timeRegions: timeRegions, layout: layout, today: today, @@ -389,9 +676,9 @@ class _PeriodLabel extends StatelessWidget { } final timeStyle = theme.textTheme.labelSmall?.copyWith( - color: secondaryTextColor, + color: secondaryTextColor.withAlpha(140), height: 1.0, - fontSize: 10, + fontSize: 9, ); const tightTextHeight = TextHeightBehavior( applyHeightToFirstAscent: false, @@ -422,7 +709,7 @@ class _PeriodLabel extends StatelessWidget { ), ), Text( - '${period.name}.', + period.name, style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onSurface, fontWeight: FontWeight.w500, @@ -728,25 +1015,34 @@ List<_BoundRegion> _expandRegionsForDay(List regions, DateTime day) bool _isSameDay(DateTime a, DateTime b) => a.year == b.year && a.month == b.month && a.day == b.day; -/// Expands the given list of appointments across the visible 5-day work week, -/// resolving any RRULE-based recurrences into per-day synthetic instances. -/// Returns a list of length 5 (Monday..Friday); each entry holds the -/// appointments occurring on that day, with `startTime` and `endTime` shifted -/// to the actual occurrence date (preserving time-of-day and duration). The -/// original `id`, `subject`, `color`, `location` and `notes` are kept so taps -/// still resolve to the correct underlying event. -List> _expandAppointmentsForWeek( - List appointments, DateTime weekStart) { - final perDay = List>.generate(5, (_) => []); +/// Expands the given list of appointments across the visible 5-day work week +/// (resolving RRULE recurrences) and splits each day's events into two +/// buckets: those that fit within the school-hours grid (`inside`) and those +/// that don't (`outside` — all-day events and events that start before +/// [kCalendarStartHour] or end after [kCalendarEndHour]). The outside bucket +/// is rendered as chips above the grid. +({List> inside, List> outside}) + _partitionAppointmentsForWeek( + List appointments, DateTime weekStart) { + final inside = List>.generate(5, (_) => []); + final outside = List>.generate(5, (_) => []); final weekEnd = weekStart.add(const Duration(days: 5)); final weekStartUtc = weekStart.toUtc(); final weekEndUtc = weekEnd.toUtc(); + void place(int idx, Appointment a) { + if (_isOutsideSchoolHours(a)) { + outside[idx].add(a); + } else { + inside[idx].add(a); + } + } + for (final a in appointments) { final rule = a.recurrenceRule; if (rule == null || rule.isEmpty) { - final idx = a.startTime.difference(weekStart).inDays; - if (idx >= 0 && idx < 5) perDay[idx].add(a); + final idx = _dayIndex(a.startTime, weekStart); + if (idx >= 0 && idx < 5) place(idx, a); continue; } try { @@ -763,25 +1059,53 @@ List> _expandAppointmentsForWeek( if (idx < 0 || idx >= 5) continue; final newStart = DateTime(occLocal.year, occLocal.month, occLocal.day, a.startTime.hour, a.startTime.minute); - perDay[idx].add(Appointment( - id: a.id, - startTime: newStart, - endTime: newStart.add(duration), - subject: a.subject, - color: a.color, - location: a.location, - notes: a.notes, - )); + place( + idx, + Appointment( + id: a.id, + startTime: newStart, + endTime: newStart.add(duration), + subject: a.subject, + color: a.color, + location: a.location, + notes: a.notes, + isAllDay: a.isAllDay, + ), + ); } } catch (_) { - // Malformed RRULE → behave as non-recurring (anchor day only). - final idx = a.startTime.difference(weekStart).inDays; - if (idx >= 0 && idx < 5) perDay[idx].add(a); + final idx = _dayIndex(a.startTime, weekStart); + if (idx >= 0 && idx < 5) place(idx, a); } } - return perDay; + return (inside: inside, outside: outside); } +int _dayIndex(DateTime t, DateTime weekStart) => + DateTime(t.year, t.month, t.day).difference(weekStart).inDays; + +/// True when the appointment doesn't fit into the school-hours grid: +/// all-day, fully before the grid start, fully after the grid end, engulfing +/// the grid entirely, or lasting 10+ hours (treated as a de-facto all-day +/// event the source system happens to represent with explicit times). +bool _isOutsideSchoolHours(Appointment a) { + if (_isAllDayLike(a)) return true; + final schoolStart = (kCalendarStartHour * 60).round(); + final schoolEnd = (kCalendarEndHour * 60).round(); + final startMin = a.startTime.hour * 60 + a.startTime.minute; + final endMin = a.endTime.hour * 60 + a.endTime.minute; + if (endMin <= schoolStart) return true; + if (startMin >= schoolEnd) return true; + if (startMin <= schoolStart && endMin >= schoolEnd) return true; + return false; +} + +/// Either explicitly marked as all-day, or so long it's effectively a full +/// day from the user's perspective. We compare in minutes (not hours) because +/// `Duration.inHours` truncates: a 9h 30min event would otherwise count as 9. +bool _isAllDayLike(Appointment a) => + a.isAllDay || a.endTime.difference(a.startTime).inMinutes >= 8 * 60; + /// Maps lesson periods to vertical screen positions. Every non-break period /// gets the same fixed height (`lessonHeight`), every break gets `breakHeight`. /// Short transition gaps (Wechselzeiten) between periods are not represented From b8cac73e74b0ddf7c87c2d6c0a750884c333934d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 6 May 2026 22:53:24 +0200 Subject: [PATCH 15/23] updated timetable UI with event status and enhanced appointment tile rendering --- .../pages/timetable/data/lesson_color.dart | 3 + .../pages/timetable/data/lesson_status.dart | 10 +- .../data/timetable_appointment_factory.dart | 19 ++- .../timetable/widgets/appointment_tile.dart | 139 ++++++++++++++++-- .../widgets/custom_workweek_calendar.dart | 15 +- 5 files changed, 163 insertions(+), 23 deletions(-) diff --git a/lib/view/pages/timetable/data/lesson_color.dart b/lib/view/pages/timetable/data/lesson_color.dart index cb4ad80..03cda8f 100644 --- a/lib/view/pages/timetable/data/lesson_color.dart +++ b/lib/view/pages/timetable/data/lesson_color.dart @@ -8,12 +8,15 @@ class LessonColor { static const Color cancelled = Color(0xff000000); static const Color irregular = Color(0xff8F19B3); static const Color teacherChanged = Color(0xFF29639B); + static const Color event = Color(0xff2E7D32); static const Color parseFallback = Color(0xff404040); static Color forStatus(LessonStatus status) { switch (status) { case LessonStatus.cancelled: return cancelled; + case LessonStatus.event: + return event; case LessonStatus.irregular: return irregular; case LessonStatus.teacherChanged: diff --git a/lib/view/pages/timetable/data/lesson_status.dart b/lib/view/pages/timetable/data/lesson_status.dart index 39eeb37..90e24d0 100644 --- a/lib/view/pages/timetable/data/lesson_status.dart +++ b/lib/view/pages/timetable/data/lesson_status.dart @@ -2,6 +2,7 @@ import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.da enum LessonStatus { cancelled, + event, irregular, teacherChanged, past, @@ -10,8 +11,15 @@ enum LessonStatus { } class LessonStatusClassifier { - static LessonStatus classify(GetTimetableResponseObject lesson, DateTime startTime, DateTime endTime, DateTime now) { + static LessonStatus classify( + GetTimetableResponseObject lesson, + DateTime startTime, + DateTime endTime, + DateTime now, { + bool isEvent = false, + }) { if (lesson.code == 'cancelled') return LessonStatus.cancelled; + if (isEvent) return LessonStatus.event; if (lesson.code == 'irregular' || (lesson.te.isNotEmpty && lesson.te.first.id == 0)) return LessonStatus.irregular; if (lesson.te.any((t) => t.orgname != null)) return LessonStatus.teacherChanged; if (endTime.isBefore(now)) return LessonStatus.past; diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index 258c359..ba1d1b6 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -42,13 +42,20 @@ class TimetableAppointmentFactory { try { final startTime = WebuntisTime.parse(lesson.date, lesson.startTime); final endTime = WebuntisTime.parse(lesson.date, lesson.endTime); - final status = LessonStatusClassifier.classify(lesson, startTime, endTime, now); + final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id); + final status = LessonStatusClassifier.classify( + lesson, + startTime, + endTime, + now, + isEvent: subject == null, + ); return Appointment( id: WebuntisAppointment(lesson), startTime: startTime, endTime: endTime, - subject: _subjectName(lesson), + subject: _subjectName(lesson, subject), location: _locationLabel(lesson), notes: lesson.activityType, color: LessonColor.forStatus(status), @@ -77,7 +84,10 @@ class TimetableAppointmentFactory { ? DateTime(event.startDate.year, event.startDate.month, event.startDate.day, 23, 59) : event.endDate, isAllDay: allDay, - location: _collapseWhitespace(event.description), + // Preserve user-entered newlines in descriptions; the tile soft-wraps to + // fill the available height. For lessons we still collapse whitespace + // so room/teacher stay on one line each. + location: event.description.trim().isEmpty ? null : event.description.trim(), subject: _collapseWhitespace(event.title) ?? event.title, recurrenceRule: event.rrule, color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name), @@ -104,8 +114,7 @@ class TimetableAppointmentFactory { e.second == 0; } - String _subjectName(GetTimetableResponseObject lesson) { - final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id); + String _subjectName(GetTimetableResponseObject lesson, GetSubjectsResponseObject? subject) { if (subject == null) return 'Event'; final name = switch (settings.timetableNameMode) { TimetableNameMode.name => subject.name, diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart index 180a6ac..bb24afb 100644 --- a/lib/view/pages/timetable/widgets/appointment_tile.dart +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -1,10 +1,15 @@ import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; +import '../data/arbitrary_appointment.dart'; import 'cross_painter.dart'; class AppointmentTile extends StatelessWidget { static const _radius = BorderRadius.all(Radius.circular(7)); + static const _titleFontSize = 15.0; + static const _titleMinFontSize = 11.0; + static const _bodyFontSize = 10.0; + static const _bodyLineHeight = 1.15; final Appointment appointment; final bool crossedOut; @@ -15,12 +20,8 @@ class AppointmentTile extends StatelessWidget { Widget build(BuildContext context) { final isPast = appointment.endTime.isBefore(DateTime.now()); final color = appointment.color.withAlpha(isPast ? 160 : 255); - - final locationLines = (appointment.location ?? '') - .split('\n') - .where((p) => p.isNotEmpty) - .take(2) - .toList(growable: false); + final isCustom = appointment.id is CustomAppointment; + final description = appointment.location ?? ''; return Padding( padding: const EdgeInsets.all(1), @@ -37,15 +38,33 @@ class AppointmentTile extends StatelessWidget { ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, + mainAxisSize: MainAxisSize.max, children: [ - _ScaledLine( + _AdaptiveTitle( text: appointment.subject, - fontSize: 15, + fontSize: _titleFontSize, + minFontSize: _titleMinFontSize, fontWeight: FontWeight.w500, ), - for (final line in locationLines) - _ScaledLine(text: line, fontSize: 10), + if (isCustom) ...[ + if (description.isNotEmpty) + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 1), + child: _WrappingBody( + text: description, + fontSize: _bodyFontSize, + lineHeight: _bodyLineHeight, + ), + ), + ), + ] else ...[ + for (final line in description + .split('\n') + .where((p) => p.isNotEmpty) + .take(2)) + _ScaledLine(text: line, fontSize: _bodyFontSize), + ], ], ), ), @@ -69,18 +88,111 @@ class AppointmentTile extends StatelessWidget { } } +/// Renders the appointment title. Scales down to fit the available width via +/// [FittedBox], but never below [minFontSize] — when even the minimum size +/// overflows, the text is rendered at [minFontSize] with an ellipsis. +class _AdaptiveTitle extends StatelessWidget { + final String text; + final double fontSize; + final double minFontSize; + final FontWeight? fontWeight; + + const _AdaptiveTitle({ + required this.text, + required this.fontSize, + required this.minFontSize, + this.fontWeight, + }); + + @override + Widget build(BuildContext context) { + final baseStyle = TextStyle( + color: Colors.white, + fontSize: fontSize, + fontWeight: fontWeight, + height: 1.1, + ); + final textScaler = MediaQuery.textScalerOf(context); + return LayoutBuilder( + builder: (context, constraints) { + // Probe at the minimum size: if even that overflows, we have to ellipsize. + final probe = TextPainter( + text: TextSpan(text: text, style: baseStyle.copyWith(fontSize: minFontSize)), + textDirection: TextDirection.ltr, + maxLines: 1, + textScaler: textScaler, + )..layout(); + if (probe.width > constraints.maxWidth) { + return Text( + text, + style: baseStyle.copyWith(fontSize: minFontSize), + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + ); + } + return FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + text, + style: baseStyle, + maxLines: 1, + softWrap: false, + ), + ); + }, + ); + } +} + +/// Body text for custom events. Wraps to fill the available height and clips +/// trailing content with an ellipsis if there is more than fits. +class _WrappingBody extends StatelessWidget { + final String text; + final double fontSize; + final double lineHeight; + + const _WrappingBody({ + required this.text, + required this.fontSize, + required this.lineHeight, + }); + + @override + Widget build(BuildContext context) { + final style = TextStyle( + color: Colors.white, + fontSize: fontSize, + height: lineHeight, + ); + final textScaler = MediaQuery.textScalerOf(context); + return LayoutBuilder( + builder: (context, constraints) { + final lineBox = textScaler.scale(fontSize) * lineHeight; + final maxLines = (constraints.maxHeight / lineBox).floor().clamp(1, 99); + return Text( + text, + style: style, + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + softWrap: true, + ); + }, + ); + } +} + /// One row of appointment text. The FittedBox scales **only this line** down /// when the text is wider than the tile, so a long teacher name does not /// shrink the room number above it. class _ScaledLine extends StatelessWidget { final String text; final double fontSize; - final FontWeight? fontWeight; const _ScaledLine({ required this.text, required this.fontSize, - this.fontWeight, }); @override @@ -92,7 +204,6 @@ class _ScaledLine extends StatelessWidget { style: TextStyle( color: Colors.white, fontSize: fontSize, - fontWeight: fontWeight, height: 1.1, ), maxLines: 1, diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 53c70ef..9c776ef 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -399,8 +399,17 @@ class _OutsideChip extends StatelessWidget { ? null : '${appointment.startTime.hour.toString().padLeft(2, '0')}:${appointment.startTime.minute.toString().padLeft(2, '0')}'; + // Past chips fade further, future/ongoing ones get a more saturated tint + // so the strip no longer reads as one uniform grey block. + final isPast = appointment.endTime.isBefore(DateTime.now()); + final backgroundAlpha = isPast ? 38 : 120; + final subjectColor = isPast + ? theme.colorScheme.onSurfaceVariant + : theme.colorScheme.onSurface; + final subjectWeight = isPast ? FontWeight.w400 : FontWeight.w600; + return Material( - color: appointment.color.withAlpha(60), + color: appointment.color.withAlpha(backgroundAlpha), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(7)), ), @@ -419,8 +428,8 @@ class _OutsideChip extends StatelessWidget { overflow: TextOverflow.ellipsis, softWrap: false, style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurface, - fontWeight: FontWeight.w500, + color: subjectColor, + fontWeight: subjectWeight, ), ), ), From e8f0c4383c7da27746650341018bf9443feddb3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 6 May 2026 23:09:44 +0200 Subject: [PATCH 16/23] added camera support and enabled gallery selection on ios --- ios/Runner/Info.plist | 2 ++ .../pages/talk/widgets/chat_textfield.dart | 31 ++++++++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 006a921..ccbfe9c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -26,6 +26,8 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSCameraUsageDescription + Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt. NSPhotoLibraryUsageDescription Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt. UIApplicationSupportsIndirectInputEvents diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index a821cf1..f3bc333 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -181,18 +181,25 @@ class _ChatTextfieldState extends State { Navigator.of(dialogCtx).pop(); }, ), - Visibility( - visible: !Platform.isIOS, - child: ListTile( - leading: const Icon(Icons.image), - title: const Text('Aus Gallerie auswählen'), - onTap: () { - FilePick.multipleGalleryPick().then((value) { - if (value != null) mediaUpload(value.map((e) => e.path).toList()); - }); - Navigator.of(dialogCtx).pop(); - }, - ), + ListTile( + leading: const Icon(Icons.image), + title: const Text('Aus Galerie auswählen'), + onTap: () { + FilePick.multipleGalleryPick().then((value) { + if (value != null) mediaUpload(value.map((e) => e.path).toList()); + }); + Navigator.of(dialogCtx).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.camera_alt_outlined), + title: const Text('Foto aufnehmen'), + onTap: () { + FilePick.cameraPick().then((image) { + if (image != null) mediaUpload([image.path]); + }); + Navigator.of(dialogCtx).pop(); + }, ), ])); }, From 517e515ac118da713a206f4fc1169045fac829bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Thu, 7 May 2026 09:11:09 +0200 Subject: [PATCH 17/23] centered login view and updated layout constraints --- lib/view/login/login.dart | 348 +++++++++++++++++++++----------------- 1 file changed, 196 insertions(+), 152 deletions(-) diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index 87aed0d..92753c6 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -46,7 +46,8 @@ class _LoginState extends State { super.dispose(); } - String? _required(String? value) => (value ?? '').trim().isEmpty ? 'Eingabe erforderlich' : null; + String? _required(String? value) => + (value ?? '').trim().isEmpty ? 'Eingabe erforderlich' : null; Future _submit() async { if (_loading) return; @@ -97,10 +98,7 @@ class _LoginState extends State { icon: Icon(Icons.error_outline, color: theme.colorScheme.error), title: const Text('Fehlerdetails'), content: SingleChildScrollView( - child: SelectableText( - details, - style: theme.textTheme.bodySmall, - ), + child: SelectableText(details, style: theme.textTheme.bodySmall), ), actions: [ TextButton.icon( @@ -133,11 +131,15 @@ class _LoginState extends State { return Scaffold( backgroundColor: _marianumRed, body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24), + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Center( child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + maxWidth: 420, + ), child: IntrinsicHeight( child: Column( children: [ @@ -170,168 +172,209 @@ class _LoginState extends State { ), ), const SizedBox(height: 28), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Card( - elevation: 8, - shadowColor: Colors.black.withValues(alpha: 0.35), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - color: theme.colorScheme.surface, - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 20), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Anmelden', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + Card( + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.35), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + color: theme.colorScheme.surface, + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Anmelden', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, ), - const SizedBox(height: 6), - Text( - 'Melde dich mit deinen Marianum-Zugangsdaten an.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), + ), + const SizedBox(height: 6), + Text( + 'Melde dich mit deinen Marianum-Zugangsdaten an.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - const SizedBox(height: 20), - TextFormField( - controller: _usernameController, - enabled: !_loading, - validator: _required, - autocorrect: false, - textInputAction: TextInputAction.next, - onFieldSubmitted: (_) => _passwordFocus.requestFocus(), - decoration: InputDecoration( - labelText: 'Nutzername', - prefixIcon: const Icon(Icons.person_outline), - filled: true, - fillColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: theme.colorScheme.primary, width: 1.5), + ), + const SizedBox(height: 20), + TextFormField( + controller: _usernameController, + enabled: !_loading, + validator: _required, + autocorrect: false, + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => + _passwordFocus.requestFocus(), + decoration: InputDecoration( + labelText: 'Nutzername', + prefixIcon: const Icon( + Icons.person_outline, + ), + filled: true, + fillColor: theme + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.4), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: theme.colorScheme.primary, + width: 1.5, ), ), ), - const SizedBox(height: 12), - TextFormField( - controller: _passwordController, - focusNode: _passwordFocus, - enabled: !_loading, - validator: _required, - obscureText: true, - obscuringCharacter: '•', - autocorrect: false, - enableSuggestions: false, - keyboardType: TextInputType.visiblePassword, - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => _submit(), - decoration: InputDecoration( - labelText: 'Passwort', - prefixIcon: const Icon(Icons.lock_outline), - filled: true, - fillColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: theme.colorScheme.primary, width: 1.5), + ), + const SizedBox(height: 12), + TextFormField( + controller: _passwordController, + focusNode: _passwordFocus, + enabled: !_loading, + validator: _required, + obscureText: true, + obscuringCharacter: '•', + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.visiblePassword, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + decoration: InputDecoration( + labelText: 'Passwort', + prefixIcon: const Icon(Icons.lock_outline), + filled: true, + fillColor: theme + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.4), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: theme.colorScheme.primary, + width: 1.5, ), ), ), - AnimatedSize( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOut, - child: _errorMessage == null - ? const SizedBox(height: 0, width: double.infinity) - : Padding( - padding: const EdgeInsets.only(top: 14), - child: Material( - color: theme.colorScheme.errorContainer.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(12), - child: InkWell( - onTap: _errorDetails != null ? _showErrorDetails : null, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - child: Row( - children: [ - Icon(Icons.error_outline, - size: 20, - color: theme.colorScheme.onErrorContainer), - const SizedBox(width: 10), - Expanded( - child: Text( - _errorMessage!, - style: TextStyle( - color: theme.colorScheme.onErrorContainer, - fontSize: 13, - height: 1.3, - ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + child: _errorMessage == null + ? const SizedBox( + height: 0, + width: double.infinity, + ) + : Padding( + padding: const EdgeInsets.only( + top: 14, + ), + child: Material( + color: theme + .colorScheme + .errorContainer + .withValues(alpha: 0.6), + borderRadius: BorderRadius.circular( + 12, + ), + child: InkWell( + onTap: _errorDetails != null + ? _showErrorDetails + : null, + borderRadius: + BorderRadius.circular(12), + child: Padding( + padding: + const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + size: 20, + color: theme + .colorScheme + .onErrorContainer, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle( + color: theme + .colorScheme + .onErrorContainer, + fontSize: 13, + height: 1.3, ), ), - if (_errorDetails != null) ...[ - const SizedBox(width: 8), - Icon(Icons.chevron_right, - size: 20, - color: theme.colorScheme.onErrorContainer - .withValues(alpha: 0.7)), - ], + ), + if (_errorDetails != + null) ...[ + const SizedBox(width: 8), + Icon( + Icons.chevron_right, + size: 20, + color: theme + .colorScheme + .onErrorContainer + .withValues( + alpha: 0.7, + ), + ), ], - ), + ], ), ), ), ), - ), - const SizedBox(height: 20), - SizedBox( - height: 50, - child: FilledButton( - onPressed: _loading ? null : _submit, - style: FilledButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - textStyle: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, ), + ), + const SizedBox(height: 20), + SizedBox( + height: 50, + child: FilledButton( + onPressed: _loading ? null : _submit, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, ), - child: _loading - ? const SizedBox( - height: 22, - width: 22, - child: CircularProgressIndicator( - strokeWidth: 2.5, - color: Colors.white, - ), - ) - : const Text('Anmelden'), ), + child: _loading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.white, + ), + ) + : const Text('Anmelden'), ), - ], - ), + ), + ], ), ), ), @@ -368,6 +411,7 @@ class _LoginState extends State { ), ), ), + ), ), ); } From 710e88d74466123302ce5a2f2be4991d73f82c16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Thu, 7 May 2026 09:46:30 +0200 Subject: [PATCH 18/23] refactored chat data fetching to support separate cache and network callbacks --- .../talk/chat/get_chat_cache.dart | 4 +-- .../app/modules/chat/bloc/chat_bloc.dart | 27 +++++++++++++------ .../data_provider/chat_data_provider.dart | 20 +++++--------- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/lib/api/marianumcloud/talk/chat/get_chat_cache.dart b/lib/api/marianumcloud/talk/chat/get_chat_cache.dart index 608da9a..b15a886 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat_cache.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_cache.dart @@ -5,7 +5,8 @@ import 'get_chat_response.dart'; class GetChatCache extends SimpleCache { GetChatCache({ - required void Function(GetChatResponse) onUpdate, + super.onCacheData, + super.onNetworkData, super.onError, required String chatToken, }) : super( @@ -19,7 +20,6 @@ class GetChatCache extends SimpleCache { ), ).run(), fromJson: GetChatResponse.fromJson, - onUpdate: onUpdate, ) { start('nc-chat-$chatToken'); } diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index ee1823f..f02b80f 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -1,5 +1,4 @@ import '../../../../../api/errors/error_mapper.dart'; -import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../infrastructure/loadable_state/loading_error.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; @@ -57,23 +56,35 @@ class ChatBloc extends LoadableHydratedBloc s.copyWith(chatResponse: data))); + }, + onNetworkData: (data) { + if (!stillCurrent()) return; + add(DataGathered((s) => s.copyWith(chatResponse: data))); + }, onError: (e) => capturedError = e, ); } catch (e) { capturedError = e; } - if (_lastTokenSet.isAfter(requestStart)) return; - if ((innerState?.currentToken ?? '') != token) return; + if (!stillCurrent()) return; - if (response != null) { - add(DataGathered((s) => s.copyWith(chatResponse: response))); - } if (capturedError != null) { add(Error(LoadingError( message: errorToUserMessage(capturedError), diff --git a/lib/state/app/modules/chat/data_provider/chat_data_provider.dart b/lib/state/app/modules/chat/data_provider/chat_data_provider.dart index 8271b61..9be4252 100644 --- a/lib/state/app/modules/chat/data_provider/chat_data_provider.dart +++ b/lib/state/app/modules/chat/data_provider/chat_data_provider.dart @@ -2,26 +2,18 @@ import '../../../../../api/marianumcloud/talk/chat/get_chat_cache.dart'; import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; class ChatDataProvider { - Future getChat({ + Future getChat({ required String token, - void Function(GetChatResponse data)? onUpdate, + void Function(GetChatResponse data)? onCacheData, + void Function(GetChatResponse data)? onNetworkData, void Function(Object)? onError, }) async { - GetChatResponse? latest; - Object? capturedError; final cache = GetChatCache( chatToken: token, - onUpdate: (data) { - latest = data; - onUpdate?.call(data); - }, - onError: (e) { - capturedError = e; - onError?.call(e); - }, + onCacheData: onCacheData, + onNetworkData: onNetworkData, + onError: (e) => onError?.call(e), ); await cache.ready; - if (latest != null) return latest!; - throw capturedError ?? Exception('No data and no error from getChat'); } } From c32e64fe74b1e60df921340df3bf7c0c2880f650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Thu, 7 May 2026 09:51:13 +0200 Subject: [PATCH 19/23] improved yOfDateTime precision and period-based calculation in workweek calendar --- .../widgets/custom_workweek_calendar.dart | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 9c776ef..8bfdbd5 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -1169,8 +1169,23 @@ class _PeriodLayout { return y; } - double yOfDateTime(DateTime t) => - yOf(TimeOfDay(hour: t.hour, minute: t.minute)); + double yOfDateTime(DateTime t) { + final tMin = t.hour * 60 + t.minute + t.second / 60.0; + var y = 0.0; + for (final p in periods) { + final pStart = p.start.hour * 60 + p.start.minute; + final pEnd = p.end.hour * 60 + p.end.minute; + final h = _h(p); + if (tMin < pStart) return y; + if (tMin <= pEnd) { + final span = pEnd - pStart; + final ratio = span > 0 ? (tMin - pStart) / span : 0.0; + return y + ratio * h; + } + y += h; + } + return y; + } /// Period at a given y-offset. If y falls into a break, returns the next /// non-break period. Returns null when y is past the last period. From 3b1b0d0c197a0ef2c64ebbd2da59925c0c360f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Thu, 7 May 2026 13:27:40 +0200 Subject: [PATCH 20/23] fixed lesson merging mutation, improved overlap detection, and implemented priority-based lane assignment with tablet support --- .../data/timetable_appointment_factory.dart | 32 +++++++--- .../widgets/custom_workweek_calendar.dart | 61 +++++++++++++------ 2 files changed, 67 insertions(+), 26 deletions(-) diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index ba1d1b6..cdcb733 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -148,7 +148,13 @@ class TimetableAppointmentFactory { return cleaned.isEmpty ? null : cleaned; } - // Pure: returns a new list, does not mutate input. + // Pure: returns a new list of fresh objects, does not mutate input. + // (The previous version replaced `previous.endTime` in place, which + // mutated the original lesson object passed in via [input]. Across + // rebuilds those mutated lessons were observed again by the next merge + // pass — extending lessons further or, after the overlap-gap guard was + // added to [_canMerge], even causing the second half of a double lesson + // to be emitted alongside the already-merged block.) static List _mergeAdjacentLessons( List input, { Duration maxGap = const Duration(minutes: 5), @@ -158,19 +164,22 @@ class TimetableAppointmentFactory { final sorted = [...input]..sort((a, b) => WebuntisTime.parse(a.date, a.startTime).compareTo(WebuntisTime.parse(b.date, b.startTime))); - final merged = [sorted.first]; - for (var i = 1; i < sorted.length; i++) { - final previous = merged.last; - final current = sorted[i]; - if (_canMerge(previous, current, maxGap)) { - previous.endTime = current.endTime; + final merged = []; + for (final current in sorted) { + if (merged.isNotEmpty && _canMerge(merged.last, current, maxGap)) { + // `merged.last` is always a copy we created below, so mutating its + // endTime is safe and keeps the next iteration's gap check correct. + merged.last.endTime = current.endTime; } else { - merged.add(current); + merged.add(_copyLesson(current)); } } return merged; } + static GetTimetableResponseObject _copyLesson(GetTimetableResponseObject l) => + GetTimetableResponseObject.fromJson(l.toJson()); + static bool _canMerge(GetTimetableResponseObject a, GetTimetableResponseObject b, Duration maxGap) { final aSubject = a.su.firstOrNull?.id; final bSubject = b.su.firstOrNull?.id; @@ -179,7 +188,12 @@ class TimetableAppointmentFactory { if (a.te.firstOrNull?.id != b.te.firstOrNull?.id) return false; if (a.code != b.code) return false; + // Merge only sequential lessons (b starts at or after a ends, within the + // tolerance). Without the lower bound, identical-metadata lessons that + // overlap in time would silently collapse into one — and because the + // merge sets `previous.endTime = current.endTime`, an overlapping merge + // can even truncate the earlier lesson. final gap = WebuntisTime.parse(b.date, b.startTime).difference(WebuntisTime.parse(a.date, a.endTime)); - return gap <= maxGap; + return !gap.isNegative && gap <= maxGap; } } diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 8bfdbd5..8a6ba56 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -7,6 +7,7 @@ import 'package:intl/intl.dart'; import 'package:rrule/rrule.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; +import '../data/arbitrary_appointment.dart'; import '../data/calendar_layout.dart'; import '../data/lesson_period_schedule.dart'; import 'appointment_tile.dart'; @@ -853,7 +854,8 @@ class _DayColumn extends StatelessWidget { final dayRegions = _expandRegionsForDay(timeRegions, date); final isToday = _isSameDay(date, today); - final laidOut = _assignLanes(dayAppointments); + final isTablet = MediaQuery.of(context).size.shortestSide >= 600; + final laidOut = _assignLanes(dayAppointments, maxLanes: isTablet ? 3 : 2); return GestureDetector( behavior: HitTestBehavior.translucent, @@ -1209,11 +1211,6 @@ class _PeriodLayout { } } -/// Maximum number of cells shown side by side in a single time slot. When a -/// cluster needs more lanes than this, the first appointment (by start time) -/// keeps lane 0 and the rest are collapsed into a single "+N" overflow cell -/// in lane 1. -const int _kMaxVisibleCells = 2; class _OverflowTile extends StatelessWidget { final int count; @@ -1324,22 +1321,40 @@ class _LaidOutOverflow extends _LaidOutCell { _LaidOutOverflow(this.appointments, this.lane, this.laneCount, this.startTime, this.endTime); } +/// Horizontal ordering rank for parallel appointments. Lower = further left. +/// User-owned custom events sit on the leftmost lane, cancelled lessons after +/// them, every other lesson last. Only used as a tiebreaker — the greedy lane +/// assignment still has to honor actual time-overlap constraints, so events +/// that start later can't jump left of events that started earlier and are +/// still occupying that lane. +int _appointmentPriority(Appointment a) { + final id = a.id; + if (id is CustomAppointment) return 0; + if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 1; + return 2; +} + /// Assigns each appointment a lane index using a greedy sweep, then collapses -/// clusters that exceed [_kMaxVisibleCells] into 1 visible appointment + 1 -/// overflow cell side by side. +/// clusters that exceed [maxLanes] into `(maxLanes - 1)` visible appointments +/// + one trailing overflow cell. /// /// Greedy sweep: -/// 1. Sort by `startTime` ascending, `endTime` descending on ties. +/// 1. Sort by `startTime` ascending, then [_appointmentPriority] (custom → +/// cancelled → other) so parallel events land in the requested left-to- +/// right order, then `endTime` descending as a final tiebreaker. /// 2. Walk the list, placing each appointment in the lowest-index lane that /// is free at its `startTime`. When no lane is free, open a new one. /// 3. A cluster ends as soon as every active lane's end is at or before the /// next appointment's start. -List<_LaidOutCell> _assignLanes(List appts) { +List<_LaidOutCell> _assignLanes(List appts, {required int maxLanes}) { + assert(maxLanes >= 2, 'maxLanes must reserve at least one slot for overflow'); if (appts.isEmpty) return const <_LaidOutCell>[]; final sorted = [...appts]..sort((a, b) { final c = a.startTime.compareTo(b.startTime); if (c != 0) return c; + final p = _appointmentPriority(a).compareTo(_appointmentPriority(b)); + if (p != 0) return p; return b.endTime.compareTo(a.endTime); }); @@ -1381,17 +1396,29 @@ List<_LaidOutCell> _assignLanes(List appts) { final laneCount = cluster.fold(0, (m, e) => e.lane + 1 > m ? e.lane + 1 : m); - if (laneCount <= _kMaxVisibleCells) { + if (laneCount <= maxLanes) { for (final entry in cluster) { result.add(_LaidOutAppointment(entry.apt, entry.lane, laneCount)); } } else { - // 3+ parallel appointments: keep the earliest, collapse the rest. - final byStart = [...cluster.map((e) => e.apt)] - ..sort((a, b) => a.startTime.compareTo(b.startTime)); - result.add(_LaidOutAppointment(byStart[0], 0, _kMaxVisibleCells)); + // Too many parallel appointments: keep the highest-priority + // (maxLanes - 1) and collapse the rest into a single overflow cell in + // the trailing lane. Sorting by priority first means custom and + // cancelled lessons stay visible when the cluster has to be trimmed, + // matching the requested left-to-right order in the visible lanes. + final visibleCount = maxLanes - 1; + final byPriority = [...cluster.map((e) => e.apt)] + ..sort((a, b) { + final p = _appointmentPriority(a).compareTo(_appointmentPriority(b)); + if (p != 0) return p; + return a.startTime.compareTo(b.startTime); + }); - final overflow = byStart.sublist(1); + for (var i = 0; i < visibleCount; i++) { + result.add(_LaidOutAppointment(byPriority[i], i, maxLanes)); + } + + final overflow = byPriority.sublist(visibleCount); var earliest = overflow.first.startTime; var latest = overflow.first.endTime; for (final a in overflow.skip(1)) { @@ -1399,7 +1426,7 @@ List<_LaidOutCell> _assignLanes(List appts) { if (a.endTime.isAfter(latest)) latest = a.endTime; } result.add(_LaidOutOverflow( - overflow, _kMaxVisibleCells - 1, _kMaxVisibleCells, earliest, latest)); + overflow, maxLanes - 1, maxLanes, earliest, latest)); } } return result; From c62a14645a73015b4668cca300d30758417210c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Fri, 8 May 2026 19:05:16 +0200 Subject: [PATCH 21/23] refactored broad range of the application, split files, modularized calendar and file views, centralized bottom sheets and clipboard handling, and implemented unit test coverage --- CLAUDE.md | 77 + .../talk/chat/get_chat_response.dart | 4 +- .../list_files/list_files_response.dart | 2 +- .../webuntis/services/lesson_resolver.dart | 73 + lib/extensions/date_time.dart | 21 + lib/extensions/text.dart | 8 + lib/routing/app_routes.dart | 2 +- .../bloc/loadable_state_bloc.dart | 4 +- lib/storage/file_settings.dart | 2 +- lib/utils/clipboard_helper.dart | 19 + lib/view/login/login.dart | 411 +----- lib/view/login/login_controller.dart | 55 + lib/view/login/widgets/login_branding.dart | 75 + lib/view/login/widgets/login_card.dart | 157 +++ .../login/widgets/login_error_banner.dart | 64 + lib/view/pages/files/data/sort_options.dart | 40 + lib/view/pages/files/files.dart | 312 +---- .../pages/files/widgets/add_file_menu.dart | 83 ++ .../pages/files/widgets/clipboard_banner.dart | 152 ++ .../files/widgets/file_details_sheet.dart | 68 +- .../pages/files/widgets/file_element.dart | 7 +- .../files/widgets/files_sort_actions.dart | 66 + .../marianum_dates/data/event_formatter.dart | 38 + .../marianum_dates/marianum_dates_view.dart | 246 +--- .../marianum_dates/search_marianum_dates.dart | 2 +- .../widgets/event_details_sheet.dart | 46 + .../widgets/event_list_tile.dart | 141 ++ .../widgets/month_section_header.dart | 36 + .../marianum_message_view.dart | 15 +- .../pages/settings/data/default_settings.dart | 2 +- .../pages/settings/sections/talk_section.dart | 26 +- lib/view/pages/talk/widgets/chat_bubble.dart | 411 +++--- .../pages/talk/widgets/chat_bubble_poll.dart | 44 + .../talk/widgets/chat_bubble_reactions.dart | 73 + .../widgets/chat_message_options_dialog.dart | 4 +- .../pages/talk/widgets/chat_textfield.dart | 64 +- lib/view/pages/talk/widgets/chat_tile.dart | 4 +- .../custom_events/custom_events_view.dart | 4 +- .../pages/timetable/data/calendar_logic.dart | 356 +++++ .../timetable/details/custom_event_sheet.dart | 10 +- .../details/webuntis_lesson_sheet.dart | 106 +- .../widgets/calendar/day_header.dart | 84 ++ .../widgets/calendar/outside_chips.dart | 271 ++++ .../timetable/widgets/calendar/week_grid.dart | 489 +++++++ .../widgets/custom_workweek_calendar.dart | 1238 +---------------- lib/widget/async_action_button.dart | 551 +------- .../async_actions/async_action_button.dart | 58 + .../async_action_controller.dart | 63 + .../async_actions/async_dialog_action.dart | 90 ++ lib/widget/async_actions/async_fab.dart | 48 + .../async_actions/async_icon_button.dart | 46 + lib/widget/async_actions/async_list_tile.dart | 85 ++ lib/widget/async_actions/async_mixin.dart | 109 ++ .../async_actions/async_text_button.dart | 49 + lib/widget/debug/json_viewer.dart | 29 +- .../details_bottom_sheet.dart} | 16 +- lib/widget/info_dialog.dart | 14 +- pubspec.yaml | 4 + test/api/errors/error_mapper_test.dart | 105 ++ .../rich_object_string_processor_test.dart | 76 + test/api/webuntis/lesson_resolver_test.dart | 104 ++ test/extensions/date_time_test.dart | 61 + test/utils/debouncer_test.dart | 112 ++ test/utils/file_clipboard_test.dart | 97 ++ test/view/files/sort_options_test.dart | 108 ++ .../marianum_dates/event_formatter_test.dart | 109 ++ test/view/timetable/calendar_logic_test.dart | 346 +++++ test/widget/async_action_controller_test.dart | 82 ++ 68 files changed, 4633 insertions(+), 3141 deletions(-) create mode 100644 CLAUDE.md create mode 100644 lib/api/webuntis/services/lesson_resolver.dart create mode 100644 lib/utils/clipboard_helper.dart create mode 100644 lib/view/login/login_controller.dart create mode 100644 lib/view/login/widgets/login_branding.dart create mode 100644 lib/view/login/widgets/login_card.dart create mode 100644 lib/view/login/widgets/login_error_banner.dart create mode 100644 lib/view/pages/files/data/sort_options.dart create mode 100644 lib/view/pages/files/widgets/add_file_menu.dart create mode 100644 lib/view/pages/files/widgets/clipboard_banner.dart create mode 100644 lib/view/pages/files/widgets/files_sort_actions.dart create mode 100644 lib/view/pages/marianum_dates/data/event_formatter.dart create mode 100644 lib/view/pages/marianum_dates/widgets/event_details_sheet.dart create mode 100644 lib/view/pages/marianum_dates/widgets/event_list_tile.dart create mode 100644 lib/view/pages/marianum_dates/widgets/month_section_header.dart create mode 100644 lib/view/pages/talk/widgets/chat_bubble_poll.dart create mode 100644 lib/view/pages/talk/widgets/chat_bubble_reactions.dart create mode 100644 lib/view/pages/timetable/data/calendar_logic.dart create mode 100644 lib/view/pages/timetable/widgets/calendar/day_header.dart create mode 100644 lib/view/pages/timetable/widgets/calendar/outside_chips.dart create mode 100644 lib/view/pages/timetable/widgets/calendar/week_grid.dart create mode 100644 lib/widget/async_actions/async_action_button.dart create mode 100644 lib/widget/async_actions/async_action_controller.dart create mode 100644 lib/widget/async_actions/async_dialog_action.dart create mode 100644 lib/widget/async_actions/async_fab.dart create mode 100644 lib/widget/async_actions/async_icon_button.dart create mode 100644 lib/widget/async_actions/async_list_tile.dart create mode 100644 lib/widget/async_actions/async_mixin.dart create mode 100644 lib/widget/async_actions/async_text_button.dart rename lib/{view/pages/timetable/details/bottom_sheet.dart => widget/details_bottom_sheet.dart} (63%) create mode 100644 test/api/errors/error_mapper_test.dart create mode 100644 test/api/talk/rich_object_string_processor_test.dart create mode 100644 test/api/webuntis/lesson_resolver_test.dart create mode 100644 test/extensions/date_time_test.dart create mode 100644 test/utils/debouncer_test.dart create mode 100644 test/utils/file_clipboard_test.dart create mode 100644 test/view/files/sort_options_test.dart create mode 100644 test/view/marianum_dates/event_formatter_test.dart create mode 100644 test/view/timetable/calendar_logic_test.dart create mode 100644 test/widget/async_action_controller_test.dart diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5202b16 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,77 @@ +# MarianumMobile Client + +Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Files, Custom MHSL-Backend (Breaker, Custom Events, Push). + +## Stack + +- **Flutter** (Dart >= 3.8) +- **State:** `flutter_bloc` + `hydrated_bloc` (persistente BLoCs pro Modul) +- **Navigation:** `persistent_bottom_nav_bar_v2` mit zentraler `AppRoutes`-Klasse als Single Entry Point +- **HTTP:** `dio`, lokales Caching via `localstore` (Generic `RequestCache`) +- **Calendar:** `syncfusion_flutter_calendar` +- **Datum/Zeit:** `jiffy` – wird **nur** über die Extensions in `lib/extensions/date_time.dart` verwendet +- **Code-Gen:** `freezed`, `json_serializable` + +## Ordnerstruktur + +``` +lib/ +├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/) +├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...) +├── state/app/infrastructure LoadableState, DataLoader, geteilte BLoC-Bausteine +├── view/ Screens +│ ├── login/ Login-Flow +│ └── pages/ ein Verzeichnis pro Modul (timetable, files, talk, ...) +├── widget/ Geteilte UI-Komponenten (Dialoge, Buttons, Sheets) +├── extensions/ DateTime-, Text-, TimeOfDay-Extensions +├── routing/ AppRoutes (Single Navigation Entry) +├── theming/ Light/Dark Theme +├── storage/ Freezed Settings-Modelle (HydratedBloc-persistent) +├── notification/ Firebase + flutter_local_notifications +└── utils/ Helper (clipboard_helper, debouncer, download_manager, ...) +``` + +## Konventionen + +**Navigation:** Ausschließlich über `AppRoutes.openX(context, ...)`. Direkte `Navigator.push(...)` für volle Pages sind nicht erlaubt – `Navigator.pop` für Sheets/Dialogs bleibt am Call-Site. + +**Dialoge:** +- Info/Fehler: `InfoDialog.show(context, body, copyable: true, title: '...')` aus `lib/widget/info_dialog.dart`. +- Bestätigung: `ConfirmDialog(...).asDialog(context)` aus `lib/widget/confirm_dialog.dart`. Async-Bestätigung nutzt `onConfirmAsync` (zeigt Spinner und Inline-Fehler über `AsyncDialogAction`). +- **Kein** inline `AlertDialog`/`SimpleDialog` mehr. + +**Bottom-Sheets:** Detail-Sheets gehen über `showDetailsBottomSheet(context, header: ..., children: (ctx) => [...])` aus `lib/widget/details_bottom_sheet.dart`. Header ist optional. + +**Async-Actions:** Statt manuelles Spinner+Try/Catch die `AsyncActionButton`-Familie aus `lib/widget/async_action_button.dart` (`AsyncActionButton`, `AsyncTextButton`, `AsyncIconButton`, `AsyncFab`, `AsyncListTile`, `AsyncDialogAction`, `runWithErrorDialog`). Fehler-Mapping läuft über `errorBuilder` oder zentral über `errorToUserMessage` aus `lib/api/errors/error_mapper.dart`. + +**Clipboard:** Über `copyToClipboard(context, text)` aus `lib/utils/clipboard_helper.dart`. Zeigt automatisch SnackBar. + +**Datum/Zeit-Formatierung:** Über die Extensions in `lib/extensions/date_time.dart`: +`dt.formatHm()`, `dt.formatDate()`, `dt.formatDateTime()`, `dt.formatDateShort()`, `dt.formatRelative()`, `start.timeRangeTo(end)`. **Kein** direktes `Jiffy.parseFromDateTime(...).format(pattern: '...')` im View-Code. + +**Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc. + +## Build / Run + +```bash +flutter pub get +dart run build_runner build --delete-conflicting-outputs # nach Änderungen an Freezed/JSON-Modellen +flutter run # Debug auf angeschlossenem Device +flutter analyze # statische Analyse, muss 0 Issues melden +flutter test # Tests (siehe test/) +``` + +## Backend-Integrationen + +| Backend | Pfad | Zweck | +|---------------------------|-----------------------|----------------------------------------| +| Webuntis | `lib/api/webuntis/` | Stundenplan, Klassen, Räume, Lehrer | +| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung | +| Custom MHSL-Server | `lib/api/mhsl/` | Breaker, Custom Events, Notify, Noten | +| Holiday-Calendar | `lib/api/holidays/` | Ferien | + +`nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`). + +## Tests + +`test/` deckt aktuell nur Kern-Funktionen ab (DateTime-Extensions, AsyncActionController, LessonResolver). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu. diff --git a/lib/api/marianumcloud/talk/chat/get_chat_response.dart b/lib/api/marianumcloud/talk/chat/get_chat_response.dart index 6470b19..3c6416d 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat_response.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_response.dart @@ -1,6 +1,6 @@ -import 'package:jiffy/jiffy.dart'; import 'package:json_annotation/json_annotation.dart'; +import '../../../../extensions/date_time.dart'; import '../../../api_response.dart'; import '../room/get_room_response.dart'; @@ -63,7 +63,7 @@ class GetChatResponseObject { static GetChatResponseObject getDateDummy(int timestamp) { var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); - return getTextDummy(Jiffy.parseFromDateTime(elementDate).format(pattern: 'dd.MM.yyyy')); + return getTextDummy(elementDate.formatDate()); } static GetChatResponseObject getTextDummy(String text) => GetChatResponseObject( diff --git a/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart index 1983583..8614f3e 100644 --- a/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart @@ -1,7 +1,7 @@ import 'package:jiffy/jiffy.dart'; import 'package:json_annotation/json_annotation.dart'; -import '../../../../../view/pages/files/files.dart'; +import '../../../../../view/pages/files/data/sort_options.dart'; import '../../../../api_response.dart'; import 'cacheable_file.dart'; diff --git a/lib/api/webuntis/services/lesson_resolver.dart b/lib/api/webuntis/services/lesson_resolver.dart new file mode 100644 index 0000000..77128e5 --- /dev/null +++ b/lib/api/webuntis/services/lesson_resolver.dart @@ -0,0 +1,73 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +import '../../../state/app/modules/timetable/bloc/timetable_state.dart'; +import '../queries/get_rooms/get_rooms_response.dart'; +import '../queries/get_subjects/get_subjects_response.dart'; + +/// Resolves Webuntis IDs (subject, room) against the cached `TimetableState`. +/// When a record is missing the resolver returns a placeholder fallback +/// instead of `null` so call sites stay branch-free. +class LessonResolver { + static GetSubjectsResponseObject resolveSubject(TimetableState state, int? id) { + final fallback = GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true); + if (id == null) return fallback; + return state.subjects?.result.firstWhereOrNull((s) => s.id == id) ?? fallback; + } + + static GetRoomsResponseObject resolveRoom(TimetableState state, int? id) { + final fallback = GetRoomsResponseObject(0, '?', 'Unbekannt', true, ''); + if (id == null) return fallback; + return state.rooms?.result.firstWhereOrNull((r) => r.id == id) ?? fallback; + } +} + +/// Pure formatting/labelling helpers for Webuntis lessons (status code → +/// icon/label, "Name (Longname) · Extra" lines, subject prefix). No widgets, +/// safe to unit-test. +class LessonFormatter { + static IconData iconForCode(String? code) { + switch (code) { + case 'cancelled': + return Icons.event_busy_outlined; + case 'irregular': + return Icons.swap_horiz; + default: + return Icons.school_outlined; + } + } + + static String statusLabel(String? code) { + switch (code) { + case null: + case '': + return 'Regulär'; + case 'cancelled': + return 'Entfällt'; + case 'irregular': + return 'Geändert'; + default: + return code; + } + } + + static String codePrefix(String? code) { + if (code == 'cancelled') return 'Entfällt: '; + if (code == 'irregular') return 'Änderung: '; + return code ?? ''; + } + + /// Builds a single display line from the typical Webuntis triple of name, + /// optional longname (rendered in parentheses if it differs from `name`), + /// and optional extra info (joined with `·`). + static String formatLine(String name, {String? longname, String? extra}) { + final parts = [ + if (name.isNotEmpty) name else '?', + ]; + final ln = (longname ?? '').trim(); + if (ln.isNotEmpty && ln != name) parts.add('($ln)'); + final ex = (extra ?? '').trim(); + if (ex.isNotEmpty) parts.add('· $ex'); + return parts.join(' '); + } +} diff --git a/lib/extensions/date_time.dart b/lib/extensions/date_time.dart index 7852873..7dc0d52 100644 --- a/lib/extensions/date_time.dart +++ b/lib/extensions/date_time.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; extension IsSameDay on DateTime { bool isSameDay(DateTime other) => year == other.year && month == other.month && day == other.day; @@ -18,3 +19,23 @@ extension IsSameDay on DateTime { bool isSameOrAfter(DateTime other) => isSameDateTime(other) || isAfter(other); } + +/// Formatting helpers backed by Jiffy. Centralises the patterns that previously +/// were repeated as `Jiffy.parseFromDateTime(dt).format(pattern: '...')`. +extension DateTimeFormatting on DateTime { + String formatHm() => Jiffy.parseFromDateTime(this).format(pattern: 'HH:mm'); + + String formatDate() => Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM.yyyy'); + + String formatDateTime() => Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM.yyyy HH:mm'); + + String formatDateShort() => Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM.'); + + String formatDateShortHm() => Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM. HH:mm'); + + String formatMonthYear() => Jiffy.parseFromDateTime(this).format(pattern: 'MMMM yyyy'); + + String formatRelative() => Jiffy.parseFromDateTime(this).fromNow(); + + String timeRangeTo(DateTime end) => '${formatHm()} - ${end.formatHm()}'; +} diff --git a/lib/extensions/text.dart b/lib/extensions/text.dart index 0cf90a2..735a860 100644 --- a/lib/extensions/text.dart +++ b/lib/extensions/text.dart @@ -10,3 +10,11 @@ extension TextExt on Text { return textPainter.size; } } + +/// Returns the first non-empty (after trim) entry, or '' if none match. +String firstNonEmpty(List values) { + for (final v in values) { + if (v != null && v.trim().isNotEmpty) return v; + } + return ''; +} diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 644b5f1..bbe31c7 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -29,7 +29,7 @@ import '../widget/user_avatar.dart'; /// /// Every full-page push in modules should go through one of these methods. /// Dialogs (`showDialog`), bottom sheets (`showStickyFlexibleBottomSheet`, -/// `showAppointmentBottomSheet`), and `Navigator.pop` for closing those +/// `showDetailsBottomSheet`), and `Navigator.pop` for closing those /// remain unchanged and live at the call sites. class AppRoutes { AppRoutes._(); diff --git a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart index 5d3bd43..daaca67 100644 --- a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart +++ b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart @@ -3,8 +3,8 @@ import 'dart:async'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:jiffy/jiffy.dart'; +import '../../../../../extensions/date_time.dart'; import 'loadable_state_event.dart'; import 'loadable_state_state.dart'; @@ -73,7 +73,7 @@ class LoadableStateBloc extends Bloc String connectionText({int? lastUpdated}) => connectivityStatusKnown() ? isConnected() ? 'Verbindung fehlgeschlagen' - : 'Offline${lastUpdated == null ? '' : ' - Stand von ${Jiffy.parseFromMillisecondsSinceEpoch(lastUpdated).fromNow()}'}' + : 'Offline${lastUpdated == null ? '' : ' - Stand von ${DateTime.fromMillisecondsSinceEpoch(lastUpdated).formatRelative()}'}' : 'Unbekannte Fehlerursache'; @override diff --git a/lib/storage/file_settings.dart b/lib/storage/file_settings.dart index c493f7a..3b76ffa 100644 --- a/lib/storage/file_settings.dart +++ b/lib/storage/file_settings.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../view/pages/files/files.dart'; +import '../view/pages/files/data/sort_options.dart'; part 'file_settings.g.dart'; diff --git a/lib/utils/clipboard_helper.dart b/lib/utils/clipboard_helper.dart new file mode 100644 index 0000000..df08022 --- /dev/null +++ b/lib/utils/clipboard_helper.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Copies [text] to the system clipboard and shows a SnackBar confirmation. +/// Safe to await: respects context lifecycle via the provided [context]. +Future copyToClipboard( + BuildContext context, + String text, { + String successMessage = 'In Zwischenablage kopiert', +}) async { + await Clipboard.setData(ClipboardData(text: text)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(successMessage), + duration: const Duration(seconds: 2), + ), + ); +} diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index 92753c6..286f7e4 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -1,17 +1,12 @@ -import 'dart:developer'; - import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../api/errors/auth_exception.dart'; -import '../../api/errors/error_mapper.dart'; -import '../../api/marianumcloud/talk/room/get_room.dart'; -import '../../api/marianumcloud/talk/room/get_room_params.dart'; -import '../../model/account_data.dart'; import '../../state/app/modules/account/bloc/account_bloc.dart'; import '../../state/app/modules/account/bloc/account_state.dart'; import '../../theming/light_app_theme.dart'; +import 'login_controller.dart'; +import 'widgets/login_branding.dart'; +import 'widgets/login_card.dart'; class Login extends StatefulWidget { const Login({super.key}); @@ -23,14 +18,7 @@ class Login extends StatefulWidget { class _LoginState extends State { static const _marianumRed = LightAppTheme.marianumRed; - final _formKey = GlobalKey(); - final _usernameController = TextEditingController(); - final _passwordController = TextEditingController(); - final _passwordFocus = FocusNode(); - - bool _loading = false; - String? _errorMessage; - String? _errorDetails; + final LoginController _controller = LoginController(); @override void didChangeDependencies() { @@ -40,379 +28,44 @@ class _LoginState extends State { @override void dispose() { - _usernameController.dispose(); - _passwordController.dispose(); - _passwordFocus.dispose(); + _controller.dispose(); super.dispose(); } - String? _required(String? value) => - (value ?? '').trim().isEmpty ? 'Eingabe erforderlich' : null; - - Future _submit() async { - if (_loading) return; - if (!(_formKey.currentState?.validate() ?? false)) return; - - setState(() { - _loading = true; - _errorMessage = null; - _errorDetails = null; - }); - - final username = _usernameController.text.trim().toLowerCase(); - final password = _passwordController.text; - - try { - await AccountData().removeData(); - await AccountData().setData(username, password); - await GetRoom(GetRoomParams(includeStatus: false)).run(); - if (!mounted) return; - context.read().setStatus(AccountStatus.loggedIn); - } catch (e) { - log(e.toString()); - await AccountData().removeData(); - if (!mounted) return; - // 401 from the probe means the credentials were wrong; everything else - // (no network, server down, TLS errors, …) gets the generic mapped - // message so the user knows it isn't their typo. - final isWrongCredentials = e is AuthException && e.statusCode == 401; - setState(() { - _errorMessage = isWrongCredentials - ? 'Benutzername oder Passwort falsch.' - : errorToUserMessage(e); - _errorDetails = errorToTechnicalDetails(e); - }); - } finally { - if (mounted) setState(() => _loading = false); - } - } - - void _showErrorDetails() { - final details = _errorDetails; - if (details == null) return; - showDialog( - context: context, - builder: (dialogContext) { - final theme = Theme.of(dialogContext); - return AlertDialog( - icon: Icon(Icons.error_outline, color: theme.colorScheme.error), - title: const Text('Fehlerdetails'), - content: SingleChildScrollView( - child: SelectableText(details, style: theme.textTheme.bodySmall), - ), - actions: [ - TextButton.icon( - onPressed: () async { - await Clipboard.setData(ClipboardData(text: details)); - if (!dialogContext.mounted) return; - ScaffoldMessenger.of(dialogContext).showSnackBar( - const SnackBar( - content: Text('In Zwischenablage kopiert'), - duration: Duration(seconds: 2), - ), - ); - }, - icon: const Icon(Icons.copy_outlined, size: 18), - label: const Text('Kopieren'), - ), - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('Schließen'), - ), - ], - ); - }, - ); + void _onLoginSuccess() { + context.read().setStatus(AccountStatus.loggedIn); } @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Scaffold( - backgroundColor: _marianumRed, - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Center( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - maxWidth: 420, - ), - child: IntrinsicHeight( - child: Column( - children: [ - const SizedBox(height: 40), - Image.asset( - 'assets/logo/icon.png', - height: 110, - fit: BoxFit.contain, - gaplessPlayback: true, - ), - const SizedBox(height: 20), - const Text( - 'Marianum Fulda', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white, - fontSize: 26, - fontWeight: FontWeight.w600, - letterSpacing: 0.3, - ), - ), - const SizedBox(height: 6), - Text( - 'Stundenplan, Talk & Dateien an einem Ort.', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.85), - fontSize: 14, - height: 1.3, - ), - ), - const SizedBox(height: 28), - Card( - elevation: 8, - shadowColor: Colors.black.withValues(alpha: 0.35), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - color: theme.colorScheme.surface, - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 20), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Anmelden', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 6), - Text( - 'Melde dich mit deinen Marianum-Zugangsdaten an.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 20), - TextFormField( - controller: _usernameController, - enabled: !_loading, - validator: _required, - autocorrect: false, - textInputAction: TextInputAction.next, - onFieldSubmitted: (_) => - _passwordFocus.requestFocus(), - decoration: InputDecoration( - labelText: 'Nutzername', - prefixIcon: const Icon( - Icons.person_outline, - ), - filled: true, - fillColor: theme - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.4), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: theme.colorScheme.primary, - width: 1.5, - ), - ), - ), - ), - const SizedBox(height: 12), - TextFormField( - controller: _passwordController, - focusNode: _passwordFocus, - enabled: !_loading, - validator: _required, - obscureText: true, - obscuringCharacter: '•', - autocorrect: false, - enableSuggestions: false, - keyboardType: TextInputType.visiblePassword, - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => _submit(), - decoration: InputDecoration( - labelText: 'Passwort', - prefixIcon: const Icon(Icons.lock_outline), - filled: true, - fillColor: theme - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.4), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: theme.colorScheme.primary, - width: 1.5, - ), - ), - ), - ), - AnimatedSize( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOut, - child: _errorMessage == null - ? const SizedBox( - height: 0, - width: double.infinity, - ) - : Padding( - padding: const EdgeInsets.only( - top: 14, - ), - child: Material( - color: theme - .colorScheme - .errorContainer - .withValues(alpha: 0.6), - borderRadius: BorderRadius.circular( - 12, - ), - child: InkWell( - onTap: _errorDetails != null - ? _showErrorDetails - : null, - borderRadius: - BorderRadius.circular(12), - child: Padding( - padding: - const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - child: Row( - children: [ - Icon( - Icons.error_outline, - size: 20, - color: theme - .colorScheme - .onErrorContainer, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - _errorMessage!, - style: TextStyle( - color: theme - .colorScheme - .onErrorContainer, - fontSize: 13, - height: 1.3, - ), - ), - ), - if (_errorDetails != - null) ...[ - const SizedBox(width: 8), - Icon( - Icons.chevron_right, - size: 20, - color: theme - .colorScheme - .onErrorContainer - .withValues( - alpha: 0.7, - ), - ), - ], - ], - ), - ), - ), - ), - ), - ), - const SizedBox(height: 20), - SizedBox( - height: 50, - child: FilledButton( - onPressed: _loading ? null : _submit, - style: FilledButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - textStyle: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - ), - ), - child: _loading - ? const SizedBox( - height: 22, - width: 22, - child: CircularProgressIndicator( - strokeWidth: 2.5, - color: Colors.white, - ), - ) - : const Text('Anmelden'), - ), - ), - ], - ), - ), - ), - ), - const SizedBox(height: 18), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - 'Inoffizieller Nextcloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.75), - fontSize: 11, - height: 1.4, - ), - ), - ), - const Spacer(), - Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8), - child: Text( - 'Marianum Fulda. Die persönliche Schule.', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.7), - fontSize: 12, - fontStyle: FontStyle.italic, - ), - ), - ), - ], + Widget build(BuildContext context) => Scaffold( + backgroundColor: _marianumRed, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + maxWidth: 420, + ), + child: IntrinsicHeight( + child: Column( + children: [ + const LoginHeader(), + const SizedBox(height: 28), + LoginCard(controller: _controller, onSuccess: _onLoginSuccess), + const SizedBox(height: 18), + const LoginDisclaimer(), + const Spacer(), + const LoginFooter(), + ], + ), ), ), ), ), ), ), - ), - ); - } + ); } diff --git a/lib/view/login/login_controller.dart b/lib/view/login/login_controller.dart new file mode 100644 index 0000000..2d7126f --- /dev/null +++ b/lib/view/login/login_controller.dart @@ -0,0 +1,55 @@ +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; + +import '../../api/errors/auth_exception.dart'; +import '../../api/errors/error_mapper.dart'; +import '../../api/marianumcloud/talk/room/get_room.dart'; +import '../../api/marianumcloud/talk/room/get_room_params.dart'; +import '../../model/account_data.dart'; + +/// Owns the login flow's transient state (loading, last error) so it can be +/// driven from a thin Stateful view and unit-tested without a widget tree. +class LoginController extends ChangeNotifier { + bool _loading = false; + String? _errorMessage; + String? _errorDetails; + + bool get loading => _loading; + String? get errorMessage => _errorMessage; + String? get errorDetails => _errorDetails; + + /// Returns `true` when the credential probe succeeded. The view should + /// then transition the AccountBloc to `loggedIn`. + Future submit(String username, String password) async { + if (_loading) return false; + _loading = true; + _errorMessage = null; + _errorDetails = null; + notifyListeners(); + + final user = username.trim().toLowerCase(); + try { + await AccountData().removeData(); + await AccountData().setData(user, password); + await GetRoom(GetRoomParams(includeStatus: false)).run(); + _loading = false; + notifyListeners(); + return true; + } catch (e) { + log(e.toString()); + await AccountData().removeData(); + // 401 from the probe means the credentials were wrong; everything else + // (no network, server down, TLS errors, …) gets the generic mapped + // message so the user knows it isn't their typo. + final isWrongCredentials = e is AuthException && e.statusCode == 401; + _errorMessage = isWrongCredentials + ? 'Benutzername oder Passwort falsch.' + : errorToUserMessage(e); + _errorDetails = errorToTechnicalDetails(e); + _loading = false; + notifyListeners(); + return false; + } + } +} diff --git a/lib/view/login/widgets/login_branding.dart b/lib/view/login/widgets/login_branding.dart new file mode 100644 index 0000000..07475ce --- /dev/null +++ b/lib/view/login/widgets/login_branding.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +class LoginHeader extends StatelessWidget { + const LoginHeader({super.key}); + + @override + Widget build(BuildContext context) => Column( + children: [ + const SizedBox(height: 40), + Image.asset( + 'assets/logo/icon.png', + height: 110, + fit: BoxFit.contain, + gaplessPlayback: true, + ), + const SizedBox(height: 20), + const Text( + 'Marianum Fulda', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + const SizedBox(height: 6), + Text( + 'Stundenplan, Talk & Dateien an einem Ort.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.85), + fontSize: 14, + height: 1.3, + ), + ), + ], + ); +} + +class LoginDisclaimer extends StatelessWidget { + const LoginDisclaimer({super.key}); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + 'Inoffizieller Nextcloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.75), + fontSize: 11, + height: 1.4, + ), + ), + ); +} + +class LoginFooter extends StatelessWidget { + const LoginFooter({super.key}); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: 16, bottom: 8), + child: Text( + 'Marianum Fulda. Die persönliche Schule.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 12, + fontStyle: FontStyle.italic, + ), + ), + ); +} diff --git a/lib/view/login/widgets/login_card.dart b/lib/view/login/widgets/login_card.dart new file mode 100644 index 0000000..61d1129 --- /dev/null +++ b/lib/view/login/widgets/login_card.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; + +import '../login_controller.dart'; +import 'login_error_banner.dart'; + +/// White Card hosting the login form (heading, two text fields, error +/// banner, submit button). Submitting calls [controller.submit] and signals +/// success via [onSuccess]. +class LoginCard extends StatefulWidget { + final LoginController controller; + final VoidCallback onSuccess; + + const LoginCard({required this.controller, required this.onSuccess, super.key}); + + @override + State createState() => _LoginCardState(); +} + +class _LoginCardState extends State { + final _formKey = GlobalKey(); + final _usernameController = TextEditingController(); + 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; + + Future _submit() async { + if (widget.controller.loading) return; + if (!(_formKey.currentState?.validate() ?? false)) return; + final ok = await widget.controller.submit( + _usernameController.text, + _passwordController.text, + ); + if (ok && mounted) widget.onSuccess(); + } + + InputDecoration _decoration(ThemeData theme, String label, IconData icon) => + InputDecoration( + labelText: label, + prefixIcon: Icon(icon), + filled: true, + fillColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.primary, width: 1.5), + ), + ); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final loading = widget.controller.loading; + return Card( + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.35), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + color: theme.colorScheme.surface, + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Anmelden', + style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + Text( + 'Melde dich mit deinen Marianum-Zugangsdaten an.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + TextFormField( + controller: _usernameController, + enabled: !loading, + validator: _required, + autocorrect: false, + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => _passwordFocus.requestFocus(), + decoration: _decoration(theme, 'Nutzername', Icons.person_outline), + ), + const SizedBox(height: 12), + TextFormField( + controller: _passwordController, + focusNode: _passwordFocus, + enabled: !loading, + validator: _required, + obscureText: true, + obscuringCharacter: '•', + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.visiblePassword, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + decoration: _decoration(theme, 'Passwort', Icons.lock_outline), + ), + LoginErrorBanner( + message: widget.controller.errorMessage, + details: widget.controller.errorDetails, + ), + const SizedBox(height: 20), + SizedBox( + height: 50, + child: FilledButton( + onPressed: loading ? null : _submit, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + ), + child: loading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white), + ) + : const Text('Anmelden'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/view/login/widgets/login_error_banner.dart b/lib/view/login/widgets/login_error_banner.dart new file mode 100644 index 0000000..df6395f --- /dev/null +++ b/lib/view/login/widgets/login_error_banner.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +import '../../../widget/info_dialog.dart'; + +/// Tappable error banner shown beneath the login form. Animates in/out via +/// AnimatedSize. When [details] is non-null, tapping opens an InfoDialog +/// with the technical error text. +class LoginErrorBanner extends StatelessWidget { + final String? message; + final String? details; + + const LoginErrorBanner({required this.message, required this.details, super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + child: message == null + ? const SizedBox(height: 0, width: double.infinity) + : Padding( + padding: const EdgeInsets.only(top: 14), + child: Material( + color: theme.colorScheme.errorContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: details != null + ? () => InfoDialog.show(context, details!, copyable: true, title: 'Fehlerdetails') + : null, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(Icons.error_outline, size: 20, color: theme.colorScheme.onErrorContainer), + const SizedBox(width: 10), + Expanded( + child: Text( + message!, + style: TextStyle( + color: theme.colorScheme.onErrorContainer, + fontSize: 13, + height: 1.3, + ), + ), + ), + if (details != null) ...[ + const SizedBox(width: 8), + Icon( + Icons.chevron_right, + size: 20, + color: theme.colorScheme.onErrorContainer.withValues(alpha: 0.7), + ), + ], + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/view/pages/files/data/sort_options.dart b/lib/view/pages/files/data/sort_options.dart new file mode 100644 index 0000000..941e7d1 --- /dev/null +++ b/lib/view/pages/files/data/sort_options.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; + +enum SortOption { name, date, size } + +class BetterSortOption { + final String displayName; + final int Function(CacheableFile, CacheableFile) compare; + final IconData icon; + + BetterSortOption({required this.displayName, required this.icon, required this.compare}); +} + +class SortOptions { + static final Map options = { + SortOption.name: BetterSortOption( + displayName: 'Name', + icon: Icons.sort_by_alpha_outlined, + compare: (a, b) => a.name.compareTo(b.name), + ), + SortOption.date: BetterSortOption( + displayName: 'Datum', + icon: Icons.history_outlined, + compare: (a, b) => a.modifiedAt!.compareTo(b.modifiedAt!), + ), + SortOption.size: BetterSortOption( + displayName: 'Größe', + icon: Icons.sd_card_outlined, + compare: (a, b) { + if (a.isDirectory || b.isDirectory) return a.isDirectory ? 1 : 0; + if (a.size == null) return 0; + if (b.size == null) return 1; + return a.size!.compareTo(b.size!); + }, + ), + }; + + static BetterSortOption getOption(SortOption option) => options[option]!; +} diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index d673417..73bed9d 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -2,12 +2,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:nextcloud/nextcloud.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import '../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; -import '../../../api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; -import '../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; @@ -15,49 +11,13 @@ 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 '../../../utils/file_clipboard.dart'; -import '../../../widget/async_action_button.dart'; -import '../../../widget/file_pick.dart'; import '../../../widget/placeholder_view.dart'; +import 'data/sort_options.dart'; import 'files_upload_dialog.dart'; +import 'widgets/add_file_menu.dart'; +import 'widgets/clipboard_banner.dart'; import 'widgets/file_element.dart'; - -class BetterSortOption { - String displayName; - int Function(CacheableFile, CacheableFile) compare; - IconData icon; - - BetterSortOption({required this.displayName, required this.icon, required this.compare}); -} - -enum SortOption { name, date, size } - -class SortOptions { - static Map options = { - SortOption.name: BetterSortOption( - displayName: 'Name', - icon: Icons.sort_by_alpha_outlined, - compare: (a, b) => a.name.compareTo(b.name), - ), - SortOption.date: BetterSortOption( - displayName: 'Datum', - icon: Icons.history_outlined, - compare: (a, b) => a.modifiedAt!.compareTo(b.modifiedAt!), - ), - SortOption.size: BetterSortOption( - displayName: 'Größe', - icon: Icons.sd_card_outlined, - compare: (a, b) { - if (a.isDirectory || b.isDirectory) return a.isDirectory ? 1 : 0; - if (a.size == null) return 0; - if (b.size == null) return 1; - return a.size!.compareTo(b.size!); - }, - ), - }; - - static BetterSortOption getOption(SortOption option) => options[option]!; -} +import 'widgets/files_sort_actions.dart'; class Files extends StatelessWidget { final List path; @@ -89,6 +49,10 @@ class _FilesViewState extends State<_FilesView> { // 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(); @@ -110,7 +74,7 @@ class _FilesViewState extends State<_FilesView> { super.dispose(); } - Future mediaUpload(List? paths) async { + Future _mediaUpload(List? paths) async { if (paths == null) return; final bloc = context.read(); unawaited(pushScreen( @@ -131,47 +95,16 @@ class _FilesViewState extends State<_FilesView> { appBar: AppBar( title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'), actions: [ - PopupMenuButton( - icon: Icon(currentSortDirection ? Icons.text_rotate_up : Icons.text_rotation_down), - itemBuilder: (context) => [true, false] - .map((e) => PopupMenuItem( - value: e, - enabled: e != currentSortDirection, - child: Row( - children: [ - Icon( - e ? Icons.text_rotate_up : Icons.text_rotation_down, - color: Theme.of(context).colorScheme.onSurface, - ), - const SizedBox(width: 15), - Text(e ? 'Aufsteigend' : 'Absteigend'), - ], - ), - )) - .toList(), - onSelected: (e) { + FilesSortActions( + currentSort: currentSort, + ascending: currentSortDirection, + onDirectionChanged: (e) { setState(() { currentSortDirection = e; settings.val(write: true).fileSettings.ascending = e; }); }, - ), - PopupMenuButton( - icon: const Icon(Icons.sort), - itemBuilder: (context) => SortOptions.options.keys - .map((key) => PopupMenuItem( - value: key, - enabled: key != currentSort, - child: Row( - children: [ - Icon(SortOptions.getOption(key).icon, color: Theme.of(context).colorScheme.onSurface), - const SizedBox(width: 15), - Text(SortOptions.getOption(key).displayName), - ], - ), - )) - .toList(), - onSelected: (e) { + onSortChanged: (e) { setState(() { currentSort = e; settings.val(write: true).fileSettings.sortBy = e; @@ -183,12 +116,12 @@ class _FilesViewState extends State<_FilesView> { floatingActionButton: FloatingActionButton( heroTag: 'uploadFile', backgroundColor: Theme.of(context).primaryColor, - onPressed: () => _showAddDialog(context, bloc), + onPressed: () => showAddFileSheet(context, bloc: bloc, onPickedFiles: _mediaUpload), child: const Icon(Icons.add), ), body: Column( children: [ - _ClipboardBanner(currentFolder: _currentFolderPath, onPasteDone: bloc.refresh), + ClipboardBanner(currentFolder: _currentFolderPath, onPasteDone: bloc.refresh), Expanded( child: LoadableStateConsumer( isReady: (state) => state.listing != null, @@ -214,217 +147,4 @@ class _FilesViewState extends State<_FilesView> { ), ); } - - // 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('/')}/'; - - void _showAddDialog(BuildContext context, FilesBloc bloc) { - showDialog( - context: context, - builder: (dialogCtx) => SimpleDialog(children: [ - ListTile( - leading: const Icon(Icons.create_new_folder_outlined), - title: const Text('Ordner erstellen'), - onTap: () { - Navigator.of(dialogCtx).pop(); - _showCreateFolderDialog(context, bloc); - }, - ), - ListTile( - leading: const Icon(Icons.upload_file), - title: const Text('Aus Dateien hochladen'), - onTap: () { - FilePick.documentPick().then(mediaUpload); - Navigator.of(dialogCtx).pop(); - }, - ), - ListTile( - leading: const Icon(Icons.add_a_photo_outlined), - title: const Text('Aus Galerie hochladen'), - onTap: () { - FilePick.multipleGalleryPick().then((value) { - if (value != null) mediaUpload(value.map((e) => e.path).toList()); - }); - Navigator.of(dialogCtx).pop(); - }, - ), - ListTile( - leading: const Icon(Icons.camera_alt_outlined), - title: const Text('Foto aufnehmen'), - onTap: () { - FilePick.cameraPick().then((image) { - if (image != null) mediaUpload([image.path]); - }); - Navigator.of(dialogCtx).pop(); - }, - ), - ]), - ); - } - - void _showCreateFolderDialog(BuildContext context, FilesBloc bloc) { - final inputController = TextEditingController(); - showDialog( - context: context, - builder: (dialogCtx) => AlertDialog( - title: const Text('Neuer Ordner'), - content: TextField( - controller: inputController, - decoration: const InputDecoration(labelText: 'Name'), - autofocus: true, - ), - actions: [ - AsyncDialogAction( - confirmLabel: 'Ordner erstellen', - onConfirm: () async { - if (inputController.text.trim().isEmpty) { - throw Exception('Bitte einen Namen eingeben.'); - } - await bloc.createFolder(inputController.text.trim()); - }, - ), - ], - ), - ); - } -} - -class _ClipboardBanner extends StatefulWidget { - const _ClipboardBanner({required this.currentFolder, required this.onPasteDone}); - final String currentFolder; - final void Function() onPasteDone; - - @override - State<_ClipboardBanner> createState() => _ClipboardBannerState(); -} - -class _ClipboardBannerState extends State<_ClipboardBanner> { - 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 - // - every entry already lives in the current folder (paste would be a no-op) - bool get _canPaste { - final cb = FileClipboard.instance; - if (cb.isEmpty) return false; - final dst = _normalised(widget.currentFolder); - var atLeastOneActionable = false; - for (final f in cb.files) { - if (f.isDirectory) { - final src = _normalised(f.path); - if (dst == src || dst.startsWith(src)) return false; - } - final destination = _joinPath(widget.currentFolder, f.name, isDirectory: f.isDirectory); - if (destination != f.path) atLeastOneActionable = true; - } - 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; - setState(() => _busy = true); - final operation = cb.operation; - final errors = []; - final invalidatedSourceFolders = {}; - try { - final webdav = await WebdavApi.webdav; - for (final file in cb.files) { - final destination = _joinPath(widget.currentFolder, file.name, isDirectory: file.isDirectory); - if (destination == file.path) continue; - try { - if (operation == FileClipboardOperation.cut) { - await webdav.move(PathUri.parse(file.path), PathUri.parse(destination)); - invalidatedSourceFolders.add(_parentCacheKey(file.path)); - } else { - await webdav.copy(PathUri.parse(file.path), PathUri.parse(destination)); - } - } on Object catch (e) { - errors.add('${file.name}: $e'); - } - } - // 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); - } - if (operation == FileClipboardOperation.cut) cb.clear(); - widget.onPasteDone(); - } finally { - if (mounted) setState(() => _busy = false); - } - if (errors.isNotEmpty && mounted) { - await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Einfügen teilweise fehlgeschlagen'), - content: SingleChildScrollView(child: Text(errors.join('\n\n'))), - actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('OK'))], - ), - ); - } - } - - @override - Widget build(BuildContext context) => ListenableBuilder( - listenable: FileClipboard.instance, - builder: (context, _) { - final cb = FileClipboard.instance; - if (cb.isEmpty) return const SizedBox.shrink(); - final cut = cb.operation == FileClipboardOperation.cut; - final count = cb.files.length; - final label = count == 1 ? '"${cb.files.first.name}"' : '$count Elemente'; - return Material( - color: Theme.of(context).colorScheme.secondaryContainer, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - children: [ - Icon(cut ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 20), - const SizedBox(width: 12), - Expanded( - child: Text( - cut ? '$label verschieben' : '$label kopieren', - overflow: TextOverflow.ellipsis, - ), - ), - TextButton( - onPressed: _busy || !_canPaste ? null : _paste, - child: _busy - ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Hier einfügen'), - ), - IconButton( - tooltip: 'Verwerfen', - icon: const Icon(Icons.close, size: 20), - onPressed: _busy ? null : cb.clear, - ), - ], - ), - ), - ); - }, - ); } diff --git a/lib/view/pages/files/widgets/add_file_menu.dart b/lib/view/pages/files/widgets/add_file_menu.dart new file mode 100644 index 0000000..508fe7d --- /dev/null +++ b/lib/view/pages/files/widgets/add_file_menu.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import '../../../../state/app/modules/files/bloc/files_bloc.dart'; +import '../../../../widget/async_action_button.dart'; +import '../../../../widget/details_bottom_sheet.dart'; +import '../../../../widget/file_pick.dart'; + +/// Opens the "Element hinzufügen" sheet (create folder, upload, take photo, …). +/// [onPickedFiles] receives selected/captured file paths (gallery, file picker +/// or camera) and is responsible for kicking off the upload flow. +void showAddFileSheet( + BuildContext context, { + required FilesBloc bloc, + required Future Function(List? paths) onPickedFiles, +}) { + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + ListTile( + leading: const Icon(Icons.create_new_folder_outlined), + title: const Text('Ordner erstellen'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _showCreateFolderDialog(context, bloc); + }, + ), + ListTile( + leading: const Icon(Icons.upload_file), + title: const Text('Aus Dateien hochladen'), + onTap: () { + FilePick.documentPick().then(onPickedFiles); + Navigator.of(sheetCtx).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.add_a_photo_outlined), + title: const Text('Aus Galerie hochladen'), + onTap: () { + FilePick.multipleGalleryPick().then((value) { + if (value != null) onPickedFiles(value.map((e) => e.path).toList()); + }); + Navigator.of(sheetCtx).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.camera_alt_outlined), + title: const Text('Foto aufnehmen'), + onTap: () { + FilePick.cameraPick().then((image) { + if (image != null) onPickedFiles([image.path]); + }); + Navigator.of(sheetCtx).pop(); + }, + ), + ], + ); +} + +void _showCreateFolderDialog(BuildContext context, FilesBloc bloc) { + final inputController = TextEditingController(); + showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: const Text('Neuer Ordner'), + content: TextField( + controller: inputController, + decoration: const InputDecoration(labelText: 'Name'), + autofocus: true, + ), + actions: [ + AsyncDialogAction( + confirmLabel: 'Ordner erstellen', + onConfirm: () async { + if (inputController.text.trim().isEmpty) { + throw Exception('Bitte einen Namen eingeben.'); + } + await bloc.createFolder(inputController.text.trim()); + }, + ), + ], + ), + ); +} diff --git a/lib/view/pages/files/widgets/clipboard_banner.dart b/lib/view/pages/files/widgets/clipboard_banner.dart new file mode 100644 index 0000000..ac73360 --- /dev/null +++ b/lib/view/pages/files/widgets/clipboard_banner.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:nextcloud/nextcloud.dart'; + +import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; +import '../../../../api/marianumcloud/webdav/webdav_api.dart'; +import '../../../../utils/file_clipboard.dart'; +import '../../../../widget/info_dialog.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 +/// einfügen" button. +class ClipboardBanner extends StatefulWidget { + final String currentFolder; + final VoidCallback onPasteDone; + + const ClipboardBanner({ + required this.currentFolder, + required this.onPasteDone, + super.key, + }); + + @override + State createState() => _ClipboardBannerState(); +} + +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 + // - every entry already lives in the current folder (paste would be a no-op) + bool get _canPaste { + final cb = FileClipboard.instance; + if (cb.isEmpty) return false; + final dst = _normalised(widget.currentFolder); + var atLeastOneActionable = false; + for (final f in cb.files) { + if (f.isDirectory) { + final src = _normalised(f.path); + if (dst == src || dst.startsWith(src)) return false; + } + final destination = _joinPath(widget.currentFolder, f.name, isDirectory: f.isDirectory); + if (destination != f.path) atLeastOneActionable = true; + } + 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; + setState(() => _busy = true); + final operation = cb.operation; + final errors = []; + final invalidatedSourceFolders = {}; + try { + final webdav = await WebdavApi.webdav; + for (final file in cb.files) { + final destination = _joinPath(widget.currentFolder, file.name, isDirectory: file.isDirectory); + if (destination == file.path) continue; + try { + if (operation == FileClipboardOperation.cut) { + await webdav.move(PathUri.parse(file.path), PathUri.parse(destination)); + invalidatedSourceFolders.add(_parentCacheKey(file.path)); + } else { + await webdav.copy(PathUri.parse(file.path), PathUri.parse(destination)); + } + } on Object catch (e) { + errors.add('${file.name}: $e'); + } + } + // 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); + } + if (operation == FileClipboardOperation.cut) cb.clear(); + widget.onPasteDone(); + } finally { + if (mounted) setState(() => _busy = false); + } + if (errors.isNotEmpty && mounted) { + InfoDialog.show( + context, + errors.join('\n\n'), + copyable: true, + title: 'Einfügen teilweise fehlgeschlagen', + ); + } + } + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: FileClipboard.instance, + builder: (context, _) { + final cb = FileClipboard.instance; + if (cb.isEmpty) return const SizedBox.shrink(); + final cut = cb.operation == FileClipboardOperation.cut; + final count = cb.files.length; + final label = count == 1 ? '"${cb.files.first.name}"' : '$count Elemente'; + return Material( + color: Theme.of(context).colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Icon(cut ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + cut ? '$label verschieben' : '$label kopieren', + overflow: TextOverflow.ellipsis, + ), + ), + TextButton( + onPressed: _busy || !_canPaste ? null : _paste, + child: _busy + ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Hier einfügen'), + ), + IconButton( + tooltip: 'Verwerfen', + icon: const Icon(Icons.close, size: 20), + onPressed: _busy ? null : cb.clear, + ), + ], + ), + ), + ); + }, + ); +} diff --git a/lib/view/pages/files/widgets/file_details_sheet.dart b/lib/view/pages/files/widgets/file_details_sheet.dart index 418b50e..2e59564 100644 --- a/lib/view/pages/files/widgets/file_details_sheet.dart +++ b/lib/view/pages/files/widgets/file_details_sheet.dart @@ -1,48 +1,33 @@ import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:jiffy/jiffy.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import '../../../../extensions/date_time.dart'; +import '../../../../utils/clipboard_helper.dart'; +import '../../../../widget/details_bottom_sheet.dart'; /// Shows a modal bottom sheet with technical metadata about a single file or /// folder: full path, MIME type, size, timestamps, ETag. -Future showFileDetailsSheet(BuildContext context, CacheableFile file) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - builder: (context) => SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: Icon(file.isDirectory ? Icons.folder : Icons.description_outlined, size: 32), - title: Text(file.name, style: const TextStyle(fontWeight: FontWeight.bold)), - subtitle: Text(file.isDirectory ? 'Ordner' : (file.mimeType ?? '–')), - ), - const Divider(), - _DetailRow(label: 'Pfad', value: file.path, copyable: true), - if (!file.isDirectory) _DetailRow(label: 'Größe', value: filesize(file.size)), - if (file.modifiedAt != null) - _DetailRow( - label: 'Geändert', - value: '${Jiffy.parseFromDateTime(file.modifiedAt!).format(pattern: 'dd.MM.yyyy HH:mm')} ' - '(${Jiffy.parseFromDateTime(file.modifiedAt!).fromNow()})', - ), - if (file.createdAt != null) - _DetailRow( - label: 'Erstellt', - value: Jiffy.parseFromDateTime(file.createdAt!).format(pattern: 'dd.MM.yyyy HH:mm'), - ), - if (file.eTag != null) _DetailRow(label: 'ETag', value: file.eTag!, copyable: true), - ], - ), - ), +void showFileDetailsSheet(BuildContext context, CacheableFile file) { + showDetailsBottomSheet( + context, + header: ListTile( + leading: Icon(file.isDirectory ? Icons.folder : Icons.description_outlined, size: 32), + title: Text(file.name, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(file.isDirectory ? 'Ordner' : (file.mimeType ?? '–')), ), + children: (_) => [ + _DetailRow(label: 'Pfad', value: file.path, copyable: true), + if (!file.isDirectory) _DetailRow(label: 'Größe', value: filesize(file.size)), + if (file.modifiedAt != null) + _DetailRow( + label: 'Geändert', + value: '${file.modifiedAt!.formatDateTime()} (${file.modifiedAt!.formatRelative()})', + ), + if (file.createdAt != null) + _DetailRow(label: 'Erstellt', value: file.createdAt!.formatDateTime()), + if (file.eTag != null) _DetailRow(label: 'ETag', value: file.eTag!, copyable: true), + ], ); } @@ -54,7 +39,7 @@ class _DetailRow extends StatelessWidget { @override Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -67,12 +52,7 @@ class _DetailRow extends StatelessWidget { IconButton( tooltip: 'Kopieren', icon: const Icon(Icons.copy, size: 18), - onPressed: () { - Clipboard.setData(ClipboardData(text: value)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('In Zwischenablage kopiert')), - ); - }, + onPressed: () => copyToClipboard(context, value), ), ], ), diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart index 3718c14..d70d856 100644 --- a/lib/view/pages/files/widgets/file_element.dart +++ b/lib/view/pages/files/widgets/file_element.dart @@ -1,10 +1,10 @@ import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; import 'package:nextcloud/nextcloud.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; import '../../../../api/marianumcloud/webdav/webdav_api.dart'; +import '../../../../extensions/date_time.dart'; import '../../../../model/endpoint_data.dart'; import '../../../../routing/app_routes.dart'; import '../../../../utils/download_manager.dart'; @@ -135,9 +135,10 @@ class _FileElementState extends State { ], ); } + final modified = widget.file.modifiedAt ?? DateTime.now(); return widget.file.isDirectory - ? Text('geändert ${Jiffy.parseFromDateTime(widget.file.modifiedAt ?? DateTime.now()).fromNow()}') - : Text('${filesize(widget.file.size)}, ${Jiffy.parseFromDateTime(widget.file.modifiedAt ?? DateTime.now()).fromNow()}'); + ? Text('geändert ${modified.formatRelative()}') + : Text('${filesize(widget.file.size)}, ${modified.formatRelative()}'); } void _onTap() { diff --git a/lib/view/pages/files/widgets/files_sort_actions.dart b/lib/view/pages/files/widgets/files_sort_actions.dart new file mode 100644 index 0000000..58abb23 --- /dev/null +++ b/lib/view/pages/files/widgets/files_sort_actions.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import '../data/sort_options.dart'; + +/// AppBar action buttons for sort direction (asc/desc) and sort field +/// (name/date/size). Pure UI – owners pass current values + selection +/// callbacks. +class FilesSortActions extends StatelessWidget { + final SortOption currentSort; + final bool ascending; + final ValueChanged onDirectionChanged; + final ValueChanged onSortChanged; + + const FilesSortActions({ + required this.currentSort, + required this.ascending, + required this.onDirectionChanged, + required this.onSortChanged, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + PopupMenuButton( + icon: Icon(ascending ? Icons.text_rotate_up : Icons.text_rotation_down), + itemBuilder: (context) => [true, false] + .map((e) => PopupMenuItem( + value: e, + enabled: e != ascending, + child: Row( + children: [ + Icon(e ? Icons.text_rotate_up : Icons.text_rotation_down, + color: theme.colorScheme.onSurface), + const SizedBox(width: 15), + Text(e ? 'Aufsteigend' : 'Absteigend'), + ], + ), + )) + .toList(), + onSelected: onDirectionChanged, + ), + PopupMenuButton( + icon: const Icon(Icons.sort), + itemBuilder: (context) => SortOptions.options.keys + .map((key) => PopupMenuItem( + value: key, + enabled: key != currentSort, + child: Row( + children: [ + Icon(SortOptions.getOption(key).icon, color: theme.colorScheme.onSurface), + const SizedBox(width: 15), + Text(SortOptions.getOption(key).displayName), + ], + ), + )) + .toList(), + onSelected: onSortChanged, + ), + ], + ); + } +} diff --git a/lib/view/pages/marianum_dates/data/event_formatter.dart b/lib/view/pages/marianum_dates/data/event_formatter.dart new file mode 100644 index 0000000..c5f31d9 --- /dev/null +++ b/lib/view/pages/marianum_dates/data/event_formatter.dart @@ -0,0 +1,38 @@ +import '../../../../extensions/date_time.dart'; +import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart'; + +/// Pure formatting helpers for `MarianumDate` events. Held outside the view +/// so the view can stay focused on layout and these helpers remain +/// unit-testable. +class EventFormatter { + /// Compact trailing label shown in the list row: "HH:mm–HH:mm" for same-day, + /// "dd.MM. HH:mm–dd.MM. HH:mm" otherwise, or "Ganztägig" for all-day events. + static String trailingLabel(MarianumDate event) { + if (event.isAllDay) return 'Ganztägig'; + if (event.start.isSameDay(event.end)) { + if (event.start == event.end) return event.start.formatHm(); + return '${event.start.formatHm()}–${event.end.formatHm()}'; + } + return '${event.start.formatDateShortHm()}–${event.end.formatDateShortHm()}'; + } + + /// Verbose date+time line shown in the details sheet. Drops the trailing + /// time when the event is all-day, and de-duplicates same-day endpoints. + static String longRange(MarianumDate event) { + if (event.isAllDay) { + final inclusiveEnd = event.end.isAfter(event.start) + ? event.end.subtract(const Duration(days: 1)) + : event.end; + return event.start.isSameDay(inclusiveEnd) + ? '${event.start.formatDate()} · Ganztägig' + : '${event.start.formatDate()} – ${inclusiveEnd.formatDate()} · Ganztägig'; + } + if (event.start.isSameDay(event.end)) { + if (event.start == event.end) { + return '${event.start.formatDate()} · ${event.start.formatHm()}'; + } + return '${event.start.formatDate()} · ${event.start.formatHm()} – ${event.end.formatHm()}'; + } + return '${event.start.formatDateTime()} – ${event.end.formatDateTime()}'; + } +} diff --git a/lib/view/pages/marianum_dates/marianum_dates_view.dart b/lib/view/pages/marianum_dates/marianum_dates_view.dart index 1805539..3574031 100644 --- a/lib/view/pages/marianum_dates/marianum_dates_view.dart +++ b/lib/view/pages/marianum_dates/marianum_dates_view.dart @@ -1,18 +1,16 @@ import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; +import '../../../extensions/date_time.dart'; import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart'; import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_event.dart'; import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart'; -import '../../../widget/animated_time.dart'; -import '../../../widget/centered_leading.dart'; -import '../../../widget/debug/debug_tile.dart'; import '../../../widget/placeholder_view.dart'; -import '../timetable/custom_events/custom_event_edit_dialog.dart'; import 'search_marianum_dates.dart'; +import 'widgets/event_list_tile.dart'; +import 'widgets/month_section_header.dart'; class MarianumDatesView extends StatelessWidget { const MarianumDatesView({super.key}); @@ -27,7 +25,7 @@ class MarianumDatesView extends StatelessWidget { final keys = byMonth.keys.toList()..sort(); return keys.map((key) { final first = byMonth[key]!.first.start; - final label = Jiffy.parseFromDateTime(first).format(pattern: 'MMMM yyyy').toUpperCase(); + final label = first.formatMonthYear().toUpperCase(); return _MonthGroup(key: key, label: label, events: byMonth[key]!); }).toList(); } @@ -110,239 +108,3 @@ class _MonthGroup { final List events; _MonthGroup({required this.key, required this.label, required this.events}); } - -class MonthHeaderDelegate extends SliverPersistentHeaderDelegate { - final String label; - MonthHeaderDelegate({required this.label}); - - static const double _height = 38; - - @override - Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { - final theme = Theme.of(context); - return Container( - height: _height, - color: theme.colorScheme.surfaceContainer, - padding: const EdgeInsets.symmetric(horizontal: 16), - alignment: Alignment.centerLeft, - child: Text( - label, - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w700, - letterSpacing: 1.2, - ), - ), - ); - } - - @override - double get maxExtent => _height; - - @override - double get minExtent => _height; - - @override - bool shouldRebuild(covariant MonthHeaderDelegate oldDelegate) => oldDelegate.label != label; -} - -/// Composite icon: calendar with a small plus badge in the bottom-right. -/// Material's bundled icon set has no `calendar_add_on`, so we layer -/// `Icons.event_outlined` and `Icons.add` to get the same affordance. -class _CalendarPlusIcon extends StatelessWidget { - final Color color; - const _CalendarPlusIcon({required this.color}); - - @override - Widget build(BuildContext context) => SizedBox( - width: 22, - height: 22, - child: Stack( - clipBehavior: Clip.none, - children: [ - Icon(Icons.event_outlined, size: 22, color: color), - Positioned( - right: -2, - bottom: -2, - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - shape: BoxShape.circle, - ), - padding: const EdgeInsets.all(1), - child: Icon(Icons.add_circle, size: 12, color: color), - ), - ), - ], - ), - ); -} - -class MarianumDateRow extends StatelessWidget { - final MarianumDate event; - const MarianumDateRow({required this.event, super.key}); - - String _dayLabel() => event.start.day.toString().padLeft(2, '0'); - - String _monthYearLabel() => - '${event.start.month.toString().padLeft(2, '0')}.${event.start.year}'; - - String _trailingLabel() { - final start = Jiffy.parseFromDateTime(event.start); - final end = Jiffy.parseFromDateTime(event.end); - if (event.isAllDay) return 'Ganztägig'; - final sameDay = start.format(pattern: 'yyyy-MM-dd') == end.format(pattern: 'yyyy-MM-dd'); - if (sameDay) { - if (event.start == event.end) return start.format(pattern: 'HH:mm'); - return '${start.format(pattern: 'HH:mm')}–${end.format(pattern: 'HH:mm')}'; - } - return '${start.format(pattern: 'dd.MM. HH:mm')}–${end.format(pattern: 'dd.MM. HH:mm')}'; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return InkWell( - onTap: () => _showDetails(context), - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 10, 4, 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: 44, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _dayLabel(), - textAlign: TextAlign.center, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: theme.colorScheme.onSurface, - height: 1.1, - ), - ), - Text( - _monthYearLabel(), - textAlign: TextAlign.center, - maxLines: 1, - softWrap: false, - overflow: TextOverflow.visible, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontSize: 10, - height: 1.1, - ), - ), - ], - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - event.title.isEmpty ? '(ohne Titel)' : event.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface), - ), - if (event.description != null && event.description!.trim().isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - event.description!.trim(), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ], - ), - ), - const SizedBox(width: 8), - Text( - _trailingLabel(), - style: theme.textTheme.labelMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 4), - IconButton( - icon: _CalendarPlusIcon(color: theme.colorScheme.onSurfaceVariant), - tooltip: 'In Stundenplan übernehmen', - onPressed: () => showDialog( - context: context, - builder: (_) => CustomEventEditDialog( - initialTitle: event.title, - initialDescription: event.description, - initialStart: event.start, - initialEnd: event.end, - ), - barrierDismissible: false, - ), - ), - ], - ), - ), - ); - } - - void _showDetails(BuildContext context) { - showDialog( - context: context, - builder: (context) => SimpleDialog( - title: Text(event.title.isEmpty ? '(ohne Titel)' : event.title), - children: [ - ListTile( - leading: const CenteredLeading(Icon(Icons.date_range_outlined)), - title: Text(_formatLongRange()), - ), - if (event.description != null && event.description!.trim().isNotEmpty) - ListTile( - leading: const CenteredLeading(Icon(Icons.notes_outlined)), - title: Text(event.description!.trim()), - ), - Visibility( - visible: !event.start.difference(DateTime.now()).isNegative, - replacement: ListTile( - leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)), - title: Text(Jiffy.parseFromDateTime(event.start).fromNow()), - ), - child: ListTile( - leading: const CenteredLeading(Icon(Icons.timer_outlined)), - title: AnimatedTime(callback: () => event.start.difference(DateTime.now())), - subtitle: Text(Jiffy.parseFromDateTime(event.start).fromNow()), - ), - ), - DebugTile(context).jsonData(event.toJson()), - ], - ), - ); - } - - String _formatLongRange() { - final start = Jiffy.parseFromDateTime(event.start); - final end = Jiffy.parseFromDateTime(event.end); - if (event.isAllDay) { - final inclusiveEnd = event.end.isAfter(event.start) ? end.subtract(days: 1) : end; - final sameAllDay = - start.format(pattern: 'yyyy-MM-dd') == inclusiveEnd.format(pattern: 'yyyy-MM-dd'); - return sameAllDay - ? '${start.format(pattern: 'dd.MM.yyyy')} · Ganztägig' - : '${start.format(pattern: 'dd.MM.yyyy')} – ${inclusiveEnd.format(pattern: 'dd.MM.yyyy')} · Ganztägig'; - } - final sameDay = start.format(pattern: 'yyyy-MM-dd') == end.format(pattern: 'yyyy-MM-dd'); - if (sameDay) { - if (event.start == event.end) { - return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')}'; - } - return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')} – ${end.format(pattern: 'HH:mm')}'; - } - return '${start.format(pattern: 'dd.MM.yyyy HH:mm')} – ${end.format(pattern: 'dd.MM.yyyy HH:mm')}'; - } -} diff --git a/lib/view/pages/marianum_dates/search_marianum_dates.dart b/lib/view/pages/marianum_dates/search_marianum_dates.dart index 7dadb5a..8a76c98 100644 --- a/lib/view/pages/marianum_dates/search_marianum_dates.dart +++ b/lib/view/pages/marianum_dates/search_marianum_dates.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart'; import '../../../widget/placeholder_view.dart'; -import 'marianum_dates_view.dart'; +import 'widgets/event_list_tile.dart'; class SearchMarianumDates extends SearchDelegate { final List events; diff --git a/lib/view/pages/marianum_dates/widgets/event_details_sheet.dart b/lib/view/pages/marianum_dates/widgets/event_details_sheet.dart new file mode 100644 index 0000000..cf14e48 --- /dev/null +++ b/lib/view/pages/marianum_dates/widgets/event_details_sheet.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import '../../../../extensions/date_time.dart'; +import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart'; +import '../../../../widget/animated_time.dart'; +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/debug/debug_tile.dart'; +import '../../../../widget/details_bottom_sheet.dart'; +import '../data/event_formatter.dart'; + +void showEventDetailsSheet(BuildContext context, MarianumDate event) { + final isUpcoming = !event.start.difference(DateTime.now()).isNegative; + showDetailsBottomSheet( + context, + header: ListTile( + leading: const Icon(Icons.event_outlined, size: 32), + title: Text( + event.title.isEmpty ? '(ohne Titel)' : event.title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + children: (sheetContext) => [ + ListTile( + leading: const CenteredLeading(Icon(Icons.date_range_outlined)), + title: Text(EventFormatter.longRange(event)), + ), + if (event.description != null && event.description!.trim().isNotEmpty) + ListTile( + leading: const CenteredLeading(Icon(Icons.notes_outlined)), + title: Text(event.description!.trim()), + ), + if (isUpcoming) + ListTile( + leading: const CenteredLeading(Icon(Icons.timer_outlined)), + title: AnimatedTime(callback: () => event.start.difference(DateTime.now())), + subtitle: Text(event.start.formatRelative()), + ) + else + ListTile( + leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)), + title: Text(event.start.formatRelative()), + ), + DebugTile(sheetContext).jsonData(event.toJson()), + ], + ); +} diff --git a/lib/view/pages/marianum_dates/widgets/event_list_tile.dart b/lib/view/pages/marianum_dates/widgets/event_list_tile.dart new file mode 100644 index 0000000..ba4b2f2 --- /dev/null +++ b/lib/view/pages/marianum_dates/widgets/event_list_tile.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; + +import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart'; +import '../../timetable/custom_events/custom_event_edit_dialog.dart'; +import '../data/event_formatter.dart'; +import 'event_details_sheet.dart'; + +class MarianumDateRow extends StatelessWidget { + final MarianumDate event; + const MarianumDateRow({required this.event, super.key}); + + String _dayLabel() => event.start.day.toString().padLeft(2, '0'); + + String _monthYearLabel() => + '${event.start.month.toString().padLeft(2, '0')}.${event.start.year}'; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return InkWell( + onTap: () => showEventDetailsSheet(context, event), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 4, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 44, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _dayLabel(), + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + height: 1.1, + ), + ), + Text( + _monthYearLabel(), + textAlign: TextAlign.center, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.visible, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 10, + height: 1.1, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + event.title.isEmpty ? '(ohne Titel)' : event.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface), + ), + if (event.description != null && event.description!.trim().isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + event.description!.trim(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Text( + EventFormatter.trailingLabel(event), + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 4), + IconButton( + icon: _CalendarPlusIcon(color: theme.colorScheme.onSurfaceVariant), + tooltip: 'In Stundenplan übernehmen', + onPressed: () => showDialog( + context: context, + builder: (_) => CustomEventEditDialog( + initialTitle: event.title, + initialDescription: event.description, + initialStart: event.start, + initialEnd: event.end, + ), + barrierDismissible: false, + ), + ), + ], + ), + ), + ); + } +} + +/// Composite icon: calendar with a small plus badge in the bottom-right. +/// Material's bundled icon set has no `calendar_add_on`, so we layer +/// `Icons.event_outlined` and `Icons.add` to get the same affordance. +class _CalendarPlusIcon extends StatelessWidget { + final Color color; + const _CalendarPlusIcon({required this.color}); + + @override + Widget build(BuildContext context) => SizedBox( + width: 22, + height: 22, + child: Stack( + clipBehavior: Clip.none, + children: [ + Icon(Icons.event_outlined, size: 22, color: color), + Positioned( + right: -2, + bottom: -2, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(1), + child: Icon(Icons.add_circle, size: 12, color: color), + ), + ), + ], + ), + ); +} diff --git a/lib/view/pages/marianum_dates/widgets/month_section_header.dart b/lib/view/pages/marianum_dates/widgets/month_section_header.dart new file mode 100644 index 0000000..944b44c --- /dev/null +++ b/lib/view/pages/marianum_dates/widgets/month_section_header.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +class MonthHeaderDelegate extends SliverPersistentHeaderDelegate { + final String label; + MonthHeaderDelegate({required this.label}); + + static const double _height = 38; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + final theme = Theme.of(context); + return Container( + height: _height, + color: theme.colorScheme.surfaceContainer, + padding: const EdgeInsets.symmetric(horizontal: 16), + alignment: Alignment.centerLeft, + child: Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + ); + } + + @override + double get maxExtent => _height; + + @override + double get minExtent => _height; + + @override + bool shouldRebuild(covariant MonthHeaderDelegate oldDelegate) => oldDelegate.label != label; +} diff --git a/lib/view/pages/marianum_message/marianum_message_view.dart b/lib/view/pages/marianum_message/marianum_message_view.dart index cb518d8..6968e8c 100644 --- a/lib/view/pages/marianum_message/marianum_message_view.dart +++ b/lib/view/pages/marianum_message/marianum_message_view.dart @@ -4,6 +4,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; import '../../../widget/confirm_dialog.dart'; +import '../../../widget/info_dialog.dart'; class MessageView extends StatefulWidget { final String basePath; @@ -26,15 +27,11 @@ class _MessageViewState extends State { enableHyperlinkNavigation: true, onDocumentLoadFailed: (PdfDocumentLoadFailedDetails e) { Navigator.of(context).pop(); - showDialog(context: context, builder: (context) => AlertDialog( - title: const Text('Fehler beim öffnen'), - content: Text("Dokument '${widget.message.name}' konnte nicht geladen werden:\n${e.description}"), - actions: [ - TextButton(onPressed: () { - Navigator.of(context).pop(); - }, child: const Text('Ok')) - ], - )); + InfoDialog.show( + context, + "Dokument '${widget.message.name}' konnte nicht geladen werden:\n${e.description}", + title: 'Fehler beim öffnen', + ); }, onHyperlinkClicked: (PdfHyperlinkClickedDetails e) { showDialog( diff --git a/lib/view/pages/settings/data/default_settings.dart b/lib/view/pages/settings/data/default_settings.dart index d835ee5..d893b77 100644 --- a/lib/view/pages/settings/data/default_settings.dart +++ b/lib/view/pages/settings/data/default_settings.dart @@ -13,7 +13,7 @@ import '../../../../storage/settings.dart'; import '../../../../storage/talk_settings.dart'; import '../../../../storage/timetable_settings.dart'; import '../../../../view/pages/timetable/data/timetable_name_mode.dart'; -import '../../files/files.dart'; +import '../../files/data/sort_options.dart'; class DefaultSettings { static Settings get() => Settings( diff --git a/lib/view/pages/settings/sections/talk_section.dart b/lib/view/pages/settings/sections/talk_section.dart index 1596222..0b808fb 100644 --- a/lib/view/pages/settings/sections/talk_section.dart +++ b/lib/view/pages/settings/sections/talk_section.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../notification/notify_updater.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../widget/centered_leading.dart'; +import '../../../../widget/info_dialog.dart'; class TalkSection extends StatelessWidget { const TalkSection({super.key}); @@ -51,22 +52,13 @@ class TalkSection extends StatelessWidget { ); } - void _showInfoDialog(BuildContext context) => showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Info über Push'), - content: const SingleChildScrollView( - child: Text( - "Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n" - 'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n' - 'Der extene Server verwendet die Zugangsdaten um sich maschinell in Nextcloud Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n' - 'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n' - 'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!', - ), - ), - actions: [ - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Zurück')), - ], - ), + void _showInfoDialog(BuildContext context) => InfoDialog.show( + context, + "Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n" + 'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n' + 'Der extene Server verwendet die Zugangsdaten um sich maschinell in Nextcloud Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n' + 'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n' + 'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!', + title: 'Info über Push', ); } diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index 1ea1dab..f226c8e 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -1,26 +1,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:jiffy/jiffy.dart'; import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; -import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message.dart'; -import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart'; -import '../../../../api/marianumcloud/talk/get_poll/get_poll_state.dart'; -import '../../../../api/marianumcloud/talk/react_message/react_message.dart'; -import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../extensions/date_time.dart'; import '../../../../extensions/text.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../utils/download_manager.dart'; -import '../../../../widget/async_action_button.dart'; -import '../../../../widget/loading_spinner.dart'; +import '../../../../widget/confirm_dialog.dart'; +import '../../../../widget/info_dialog.dart'; import '../data/chat_bubble_styles.dart'; import '../data/chat_message.dart'; import 'answer_reference.dart'; import 'bubble.dart'; +import 'chat_bubble_poll.dart'; +import 'chat_bubble_reactions.dart'; import 'chat_message_options_dialog.dart'; -import 'poll_options_list.dart'; class ChatBubble extends StatefulWidget { final BuildContext context; @@ -54,8 +50,8 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM late ChatMessage message; DownloadJob? _job; - late Offset _position = const Offset(0, 0); - late Offset _dragStartPosition = Offset.zero; + Offset _position = Offset.zero; + Offset _dragStartPosition = Offset.zero; @override void initState() { @@ -99,7 +95,7 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM DownloadManager.instance.clear(job.remotePath); _detachJob(); setState(() {}); - showDialog(context: context, builder: (context) => AlertDialog(content: Text(message))); + InfoDialog.show(context, message, title: 'Download fehlgeschlagen'); } else if (status is DownloadCancelled) { DownloadManager.instance.clear(job.remotePath); _detachJob(); @@ -122,66 +118,69 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM } void _confirmCancel() { - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Download abbrechen?'), - content: const Text('Möchtest du den Download abbrechen?'), - actions: [ - TextButton(onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Nein')), - TextButton( - onPressed: () { - Navigator.of(dialogContext).pop(); - _job?.cancel(); - }, - child: const Text('Ja, Abbrechen'), - ), - ], - ), - ); + ConfirmDialog( + title: 'Download abbrechen?', + content: 'Möchtest du den Download abbrechen?', + confirmButton: 'Ja, Abbrechen', + cancelButton: 'Nein', + onConfirm: () => _job?.cancel(), + ).asDialog(context); } - BubbleStyle getStyle() { - var styles = ChatBubbleStyles(context); - if(widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment) { - if(widget.isSender) { - return styles.getSelfStyle(false); - } else { - return styles.getRemoteStyle(false); - } - } else { + BubbleStyle _getStyle() { + final styles = ChatBubbleStyles(context); + if (widget.bubbleData.messageType != GetRoomResponseObjectMessageType.comment) { return styles.getSystemStyle(); } + return widget.isSender ? styles.getSelfStyle(false) : styles.getRemoteStyle(false); } - void showOptionsDialog() { - showChatMessageOptionsDialog( - context, - chatData: widget.chatData, - bubbleData: widget.bubbleData, - isSender: widget.isSender, - onRefetch: widget.refetch, - ); - } + void _showOptionsDialog() => showChatMessageOptionsDialog( + context, + chatData: widget.chatData, + bubbleData: widget.bubbleData, + isSender: widget.isSender, + onRefetch: widget.refetch, + ); + void _onTap() { + final obj = message.originalData?['object']; + if (obj?.type == RichObjectStringObjectType.talkPoll) { + showChatBubblePollDialog( + context, + chatToken: widget.chatData.token, + messageToken: widget.bubbleData.token, + pollId: int.parse(obj!.id), + pollName: obj.name, + ); + return; + } + if (message.file == null) return; + if (_job?.status.value is DownloadInProgress) { + _confirmCancel(); + } else { + _startFileDownload(); + } + } @override Widget build(BuildContext context) { message = ChatMessage(originalMessage: widget.bubbleData.message, originalData: widget.bubbleData.messageParameters); - var showActorDisplayName = widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment && widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne; - var showBubbleTime = widget.bubbleData.messageType != GetRoomResponseObjectMessageType.system - && widget.bubbleData.messageType != GetRoomResponseObjectMessageType.deletedComment; + final showActorDisplayName = widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment + && widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne; + final showBubbleTime = widget.bubbleData.messageType != GetRoomResponseObjectMessageType.system + && widget.bubbleData.messageType != GetRoomResponseObjectMessageType.deletedComment; - var parent = widget.bubbleData.parent; - var actorText = Text( + final parent = widget.bubbleData.parent; + final actorText = Text( widget.bubbleData.actorDisplayName, textAlign: TextAlign.start, overflow: TextOverflow.ellipsis, style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold), ); - var timeText = Text( - Jiffy.parseFromMillisecondsSinceEpoch(widget.bubbleData.timestamp * 1000).format(pattern: 'HH:mm'), + final timeText = Text( + DateTime.fromMillisecondsSinceEpoch(widget.bubbleData.timestamp * 1000).formatHm(), textAlign: TextAlign.end, style: TextStyle(color: widget.timeIconColor, fontSize: widget.timeIconSize), ); @@ -191,191 +190,161 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM mainAxisAlignment: MainAxisAlignment.end, textDirection: TextDirection.ltr, crossAxisAlignment: CrossAxisAlignment.end, - children: [ GestureDetector( - onHorizontalDragStart: (details) { - _dragStartPosition = _position; - }, + onHorizontalDragStart: (_) => _dragStartPosition = _position, onHorizontalDragUpdate: (details) { - if(!widget.bubbleData.isReplyable) return; - var dx = details.delta.dx - _dragStartPosition.dx; + 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); + _position = (_position.dx + dx).abs() > 60 + ? Offset(_position.dx, 0) + : Offset(_position.dx + dx, 0); }); }, - onHorizontalDragEnd: (DragEndDetails details) { - var isAction = _position.dx.abs() > 50; - setState(() { - _position = const Offset(0, 0); - }); - if(widget.bubbleData.isReplyable && isAction) { + onHorizontalDragEnd: (_) { + final isAction = _position.dx.abs() > 50; + setState(() => _position = Offset.zero); + if (widget.bubbleData.isReplyable && isAction) { context.read().setReferenceMessageId(widget.bubbleData.id); } }, - onLongPress: showOptionsDialog, - onDoubleTap: showOptionsDialog, - onTap: () { - if(message.originalData?['object']?.type == RichObjectStringObjectType.talkPoll) { - var pollId = int.parse(message.originalData!['object']!.id); - var pollState = GetPollState(token: widget.bubbleData.token, pollId: pollId).run(); - showDialog(context: context, builder: (context) => AlertDialog( - title: Text(message.originalData!['object']!.name, overflow: TextOverflow.ellipsis), - content: FutureBuilder( - future: pollState, - builder: (context, snapshot) { - if(snapshot.connectionState == ConnectionState.waiting) return const Column(mainAxisSize: MainAxisSize.min, children: [LoadingSpinner()]); - - var pollData = snapshot.data!.data; - return SingleChildScrollView( - child: PollOptionsList( - pollData: pollData, - chatToken: widget.chatData.token, - ), - ); - } - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Zurück') - ), - ], - )); - } - - if (message.file == null) return; - if (_job?.status.value is DownloadInProgress) { - _confirmCancel(); - } else { - _startFileDownload(); - } - }, + onLongPress: _showOptionsDialog, + onDoubleTap: _showOptionsDialog, + onTap: _onTap, child: Transform.translate( offset: _position, child: Bubble( - style: getStyle(), - child: Column( - children: [ - Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.9, - minWidth: showActorDisplayName - ? actorText.size.width - : timeText.size.width + (widget.isSender ? widget.spacing + widget.timeIconSize : 0) + 3, - ), - child: Stack( - children: [ - Visibility( - visible: showActorDisplayName, - child: Positioned( - top: 0, - left: 0, - child: actorText - ), - ), - Padding( - padding: EdgeInsets.only(bottom: showBubbleTime ? 18 : 0, top: showActorDisplayName ? 18 : 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if(parent != null && widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment) ...[ - AnswerReference( - context: context, - referenceMessage: parent, - selfId: widget.selfId, - ), - const SizedBox(height: 5), - ], - message.getWidget(), - ], - ), - ), - Visibility( - visible: showBubbleTime, - child: Positioned( - bottom: 0, - right: 0, - child: Row( - children: [ - timeText, - if(widget.isSender) ...[ - SizedBox(width: widget.spacing), - Icon( - widget.isRead ? Icons.done_all_outlined: Icons.done_outlined, - size: widget.timeIconSize, - color: widget.timeIconColor - ) - ] - ], - ) - ), - ), - if (_job?.status.value is DownloadInProgress) - Positioned( - bottom: 0, - right: 0, - left: 0, - child: LinearProgressIndicator( - value: () { - final s = _job!.status.value as DownloadInProgress; - return s.percent <= 0 ? null : s.percent / 100; - }(), - ), - ), - ], - ), - ), - ], + style: _getStyle(), + child: _BubbleContent( + actorText: actorText, + timeText: timeText, + messageWidget: message.getWidget(), + parent: parent, + bubbleData: widget.bubbleData, + isSender: widget.isSender, + isRead: widget.isRead, + selfId: widget.selfId, + spacing: widget.spacing, + timeIconSize: widget.timeIconSize, + timeIconColor: widget.timeIconColor, + showActorDisplayName: showActorDisplayName, + showBubbleTime: showBubbleTime, + downloadJob: _job, ), ), ), ), - Visibility( - visible: widget.bubbleData.reactions != null, - child: Transform.translate( - offset: const Offset(0, -10), - child: Container( - width: MediaQuery.of(context).size.width, - margin: const EdgeInsets.only(left: 15, right: 15), - child: Wrap( - alignment: widget.isSender ? WrapAlignment.end : WrapAlignment.start, - crossAxisAlignment: WrapCrossAlignment.start, - children: widget.bubbleData.reactions?.entries.map((e) { - var hasSelfReacted = widget.bubbleData.reactionsSelf?.contains(e.key) ?? false; - return Container( - margin: const EdgeInsets.only(right: 2.5, left: 2.5), - child: ActionChip( - label: Text('${e.key} ${e.value}'), - visualDensity: const VisualDensity(vertical: VisualDensity.minimumDensity, horizontal: VisualDensity.minimumDensity), - padding: EdgeInsets.zero, - backgroundColor: hasSelfReacted ? Theme.of(context).primaryColor : null, - onPressed: () { - runWithErrorDialog(context, () async { - if (hasSelfReacted) { - await DeleteReactMessage( - chatToken: widget.chatData.token, - messageId: widget.bubbleData.id, - params: DeleteReactMessageParams(e.key), - ).run(); - } else { - await ReactMessage( - chatToken: widget.chatData.token, - messageId: widget.bubbleData.id, - params: ReactMessageParams(e.key), - ).run(); - } - widget.refetch(renew: true); - }); - }, - ), - ); - }).toList() ?? [], - ), - ), - ), + ChatBubbleReactions( + bubbleData: widget.bubbleData, + chatData: widget.chatData, + isSender: widget.isSender, + onChanged: widget.refetch, ), ], ); } } + +/// Stack inside the bubble: actor name (top-left, optional), message body +/// (centre), timestamp + read marker (bottom-right, optional), and a +/// download progress bar overlaid at the bottom while a job is running. +class _BubbleContent extends StatelessWidget { + final Text actorText; + final Text timeText; + final Widget messageWidget; + final GetChatResponseObject? parent; + final GetChatResponseObject bubbleData; + 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; + + const _BubbleContent({ + required this.actorText, + required this.timeText, + required this.messageWidget, + required this.parent, + required this.bubbleData, + 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, + }); + + @override + Widget build(BuildContext context) => Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.9, + minWidth: showActorDisplayName + ? actorText.size.width + : timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3, + ), + child: Stack( + children: [ + if (showActorDisplayName) + Positioned(top: 0, left: 0, child: actorText), + Padding( + padding: EdgeInsets.only( + bottom: showBubbleTime ? 18 : 0, + top: showActorDisplayName ? 18 : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (parent != null && bubbleData.messageType == GetRoomResponseObjectMessageType.comment) ...[ + AnswerReference( + context: context, + referenceMessage: parent!, + selfId: selfId, + ), + const SizedBox(height: 5), + ], + messageWidget, + ], + ), + ), + if (showBubbleTime) + Positioned( + bottom: 0, + right: 0, + child: Row( + children: [ + timeText, + if (isSender) ...[ + SizedBox(width: spacing), + Icon( + isRead ? Icons.done_all_outlined : Icons.done_outlined, + size: timeIconSize, + color: timeIconColor, + ), + ], + ], + ), + ), + if (downloadJob?.status.value is DownloadInProgress) + Positioned( + bottom: 0, + right: 0, + left: 0, + child: LinearProgressIndicator( + value: () { + final s = downloadJob!.status.value as DownloadInProgress; + return s.percent <= 0 ? null : s.percent / 100; + }(), + ), + ), + ], + ), + ); +} diff --git a/lib/view/pages/talk/widgets/chat_bubble_poll.dart b/lib/view/pages/talk/widgets/chat_bubble_poll.dart new file mode 100644 index 0000000..96c25ed --- /dev/null +++ b/lib/view/pages/talk/widgets/chat_bubble_poll.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import '../../../../api/marianumcloud/talk/get_poll/get_poll_state.dart'; +import '../../../../widget/loading_spinner.dart'; +import 'poll_options_list.dart'; + +/// Opens the poll dialog that lets a user vote on a Talk poll attached to +/// a message. Loads the poll state lazily and renders the option list. +void showChatBubblePollDialog( + BuildContext context, { + required String chatToken, + required String messageToken, + required int pollId, + required String pollName, +}) { + final pollState = GetPollState(token: messageToken, pollId: pollId).run(); + showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: Text(pollName, overflow: TextOverflow.ellipsis), + content: FutureBuilder( + future: pollState, + builder: (_, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Column(mainAxisSize: MainAxisSize.min, children: [LoadingSpinner()]); + } + final pollData = snapshot.data!.data; + return SingleChildScrollView( + child: PollOptionsList( + pollData: pollData, + chatToken: chatToken, + ), + ); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(), + child: const Text('Zurück'), + ), + ], + ), + ); +} diff --git a/lib/view/pages/talk/widgets/chat_bubble_reactions.dart b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart new file mode 100644 index 0000000..9761474 --- /dev/null +++ b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message.dart'; +import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart'; +import '../../../../api/marianumcloud/talk/react_message/react_message.dart'; +import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart'; +import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../widget/async_action_button.dart'; + +/// Reactions wrap shown beneath a chat bubble. Tapping a reaction toggles +/// the user's own reaction via the Talk API and notifies via [onChanged]. +class ChatBubbleReactions extends StatelessWidget { + final GetChatResponseObject bubbleData; + final GetRoomResponseObject chatData; + final bool isSender; + final void Function({bool renew}) onChanged; + + const ChatBubbleReactions({ + required this.bubbleData, + required this.chatData, + required this.isSender, + required this.onChanged, + super.key, + }); + + @override + Widget build(BuildContext context) { + final reactions = bubbleData.reactions; + if (reactions == null) return const SizedBox.shrink(); + return Transform.translate( + offset: const Offset(0, -10), + child: Container( + width: MediaQuery.of(context).size.width, + margin: const EdgeInsets.only(left: 15, right: 15), + child: Wrap( + alignment: isSender ? WrapAlignment.end : WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.start, + children: reactions.entries.map((e) { + final hasSelfReacted = bubbleData.reactionsSelf?.contains(e.key) ?? false; + return Container( + margin: const EdgeInsets.only(right: 2.5, left: 2.5), + child: ActionChip( + label: Text('${e.key} ${e.value}'), + visualDensity: const VisualDensity(vertical: VisualDensity.minimumDensity, horizontal: VisualDensity.minimumDensity), + padding: EdgeInsets.zero, + backgroundColor: hasSelfReacted ? Theme.of(context).primaryColor : null, + onPressed: () { + runWithErrorDialog(context, () async { + if (hasSelfReacted) { + await DeleteReactMessage( + chatToken: chatData.token, + messageId: bubbleData.id, + params: DeleteReactMessageParams(e.key), + ).run(); + } else { + await ReactMessage( + chatToken: chatData.token, + messageId: bubbleData.id, + params: ReactMessageParams(e.key), + ).run(); + } + onChanged(renew: true); + }); + }, + ), + ); + }).toList(), + ), + ), + ); + } +} 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 f5c5802..8ba2641 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -1,7 +1,6 @@ import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emojis; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../api/marianumcloud/talk/actions/talk_actions.dart'; @@ -11,6 +10,7 @@ import '../../../../api/marianumcloud/talk/react_message/react_message_params.da import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../../../../utils/clipboard_helper.dart'; import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/debug/debug_tile.dart'; @@ -69,7 +69,7 @@ Future showChatMessageOptionsDialog( leading: const Icon(Icons.copy), title: const Text('Nachricht kopieren'), onTap: () { - Clipboard.setData(ClipboardData(text: bubbleData.message)); + copyToClipboard(parentContext, bubbleData.message); Navigator.of(dialogCtx).pop(); }, ), diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index f3bc333..8cea791 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -14,6 +14,7 @@ import '../../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../widget/async_action_button.dart'; +import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/file_pick.dart'; import '../../../../widget/focus_behaviour.dart'; import '../../files/files_upload_dialog.dart'; @@ -172,36 +173,39 @@ class _ChatTextfieldState extends State { Row(children: [ GestureDetector( onTap: () { - showDialog(context: context, builder: (dialogCtx) => SimpleDialog(children: [ - ListTile( - leading: const Icon(Icons.file_open), - title: const Text('Aus Dateien auswählen'), - onTap: () { - FilePick.documentPick().then(mediaUpload); - Navigator.of(dialogCtx).pop(); - }, - ), - ListTile( - leading: const Icon(Icons.image), - title: const Text('Aus Galerie auswählen'), - onTap: () { - FilePick.multipleGalleryPick().then((value) { - if (value != null) mediaUpload(value.map((e) => e.path).toList()); - }); - Navigator.of(dialogCtx).pop(); - }, - ), - ListTile( - leading: const Icon(Icons.camera_alt_outlined), - title: const Text('Foto aufnehmen'), - onTap: () { - FilePick.cameraPick().then((image) { - if (image != null) mediaUpload([image.path]); - }); - Navigator.of(dialogCtx).pop(); - }, - ), - ])); + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + ListTile( + leading: const Icon(Icons.file_open), + title: const Text('Aus Dateien auswählen'), + onTap: () { + FilePick.documentPick().then(mediaUpload); + Navigator.of(sheetCtx).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.image), + title: const Text('Aus Galerie auswählen'), + onTap: () { + FilePick.multipleGalleryPick().then((value) { + if (value != null) mediaUpload(value.map((e) => e.path).toList()); + }); + Navigator.of(sheetCtx).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.camera_alt_outlined), + title: const Text('Foto aufnehmen'), + onTap: () { + FilePick.cameraPick().then((image) { + if (image != null) mediaUpload([image.path]); + }); + Navigator.of(sheetCtx).pop(); + }, + ), + ], + ); }, child: Material( elevation: 5, diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index 0f44672..c9e8deb 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -2,13 +2,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:jiffy/jiffy.dart'; import '../../../../api/marianumcloud/talk/actions/talk_actions.dart'; import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.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 '../../../../extensions/date_time.dart'; import '../../../../model/account_data.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; @@ -96,7 +96,7 @@ class _ChatTileState extends State { ], ), subtitle: Text( - '${Jiffy.parseFromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).fromNow()}: ' + '${DateTime.fromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).formatRelative()}: ' '${RichObjectStringProcessor.parseToString(widget.data.lastMessage.message.replaceAll("\n", " "), widget.data.lastMessage.messageParameters)}', overflow: TextOverflow.ellipsis, ), diff --git a/lib/view/pages/timetable/custom_events/custom_events_view.dart b/lib/view/pages/timetable/custom_events/custom_events_view.dart index a7c4272..83d0b24 100644 --- a/lib/view/pages/timetable/custom_events/custom_events_view.dart +++ b/lib/view/pages/timetable/custom_events/custom_events_view.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; +import '../../../../extensions/date_time.dart'; import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; @@ -51,7 +51,7 @@ class CustomEventsView extends StatelessWidget { title: Text(e.title), subtitle: Text( '${e.rrule.isNotEmpty ? "wiederholend, " : ""}' - 'beginnend ${Jiffy.parseFromDateTime(e.startDate).fromNow()}', + 'beginnend ${e.startDate.formatRelative()}', ), leading: CenteredLeading(Icon(e.rrule.isEmpty ? Icons.event_outlined : Icons.event_repeat_outlined)), trailing: Row( diff --git a/lib/view/pages/timetable/data/calendar_logic.dart b/lib/view/pages/timetable/data/calendar_logic.dart new file mode 100644 index 0000000..e16faa9 --- /dev/null +++ b/lib/view/pages/timetable/data/calendar_logic.dart @@ -0,0 +1,356 @@ +import 'package:rrule/rrule.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../../../../extensions/date_time.dart'; +import 'arbitrary_appointment.dart'; +import 'calendar_layout.dart'; +import 'lesson_period_schedule.dart'; + +/// Either explicitly marked as all-day, or so long it's effectively a full +/// day from the user's perspective. We compare in minutes (not hours) because +/// `Duration.inHours` truncates: a 9h 30min event would otherwise count as 9. +bool isAllDayLike(Appointment a) => + a.isAllDay || a.endTime.difference(a.startTime).inMinutes >= 8 * 60; + +/// True when the appointment doesn't fit into the school-hours grid: +/// all-day, fully before the grid start, fully after the grid end, engulfing +/// the grid entirely, or lasting 10+ hours (treated as a de-facto all-day +/// event the source system happens to represent with explicit times). +bool isOutsideSchoolHours(Appointment a) { + if (isAllDayLike(a)) return true; + final schoolStart = (kCalendarStartHour * 60).round(); + final schoolEnd = (kCalendarEndHour * 60).round(); + final startMin = a.startTime.hour * 60 + a.startTime.minute; + final endMin = a.endTime.hour * 60 + a.endTime.minute; + if (endMin <= schoolStart) return true; + if (startMin >= schoolEnd) return true; + if (startMin <= schoolStart && endMin >= schoolEnd) return true; + return false; +} + +int dayIndex(DateTime t, DateTime weekStart) => + DateTime(t.year, t.month, t.day).difference(weekStart).inDays; + +class BoundRegion { + final TimeRegion region; + final DateTime start; + final DateTime end; + + BoundRegion({required this.region, required this.start, required this.end}); +} + +List expandRegionsForDay(List regions, DateTime day) { + final result = []; + final dayStart = DateTime(day.year, day.month, day.day); + for (final region in regions) { + final isRecurringDaily = region.recurrenceRule != null && + region.recurrenceRule!.toUpperCase().contains('FREQ=DAILY'); + if (isRecurringDaily) { + final start = dayStart.add(Duration( + hours: region.startTime.hour, + minutes: region.startTime.minute, + )); + final end = dayStart.add(Duration( + hours: region.endTime.hour, + minutes: region.endTime.minute, + )); + result.add(BoundRegion(region: region, start: start, end: end)); + } else if (region.startTime.isSameDay(day)) { + result.add(BoundRegion( + region: region, + start: region.startTime, + end: region.endTime, + )); + } + } + return result; +} + +/// Expands the given list of appointments across the visible 5-day work week +/// (resolving RRULE recurrences) and splits each day's events into two +/// buckets: those that fit within the school-hours grid (`inside`) and those +/// that don't (`outside` — all-day events and events that start before +/// [kCalendarStartHour] or end after [kCalendarEndHour]). The outside bucket +/// is rendered as chips above the grid. +({List> inside, List> outside}) + partitionAppointmentsForWeek( + List appointments, DateTime weekStart) { + final inside = List>.generate(5, (_) => []); + final outside = List>.generate(5, (_) => []); + final weekEnd = weekStart.add(const Duration(days: 5)); + final weekStartUtc = weekStart.toUtc(); + final weekEndUtc = weekEnd.toUtc(); + + void place(int idx, Appointment a) { + if (isOutsideSchoolHours(a)) { + outside[idx].add(a); + } else { + inside[idx].add(a); + } + } + + for (final a in appointments) { + final rule = a.recurrenceRule; + if (rule == null || rule.isEmpty) { + final idx = dayIndex(a.startTime, weekStart); + if (idx >= 0 && idx < 5) place(idx, a); + continue; + } + try { + final parsed = RecurrenceRule.fromString(rule); + final anchorUtc = a.startTime.toUtc(); + final duration = a.endTime.difference(a.startTime); + for (final occUtc in parsed.getInstances(start: anchorUtc)) { + if (!occUtc.isBefore(weekEndUtc)) break; + if (occUtc.isBefore(weekStartUtc)) continue; + final occLocal = occUtc.toLocal(); + final idx = DateTime(occLocal.year, occLocal.month, occLocal.day) + .difference(weekStart) + .inDays; + if (idx < 0 || idx >= 5) continue; + final newStart = DateTime(occLocal.year, occLocal.month, occLocal.day, + a.startTime.hour, a.startTime.minute); + place( + idx, + Appointment( + id: a.id, + startTime: newStart, + endTime: newStart.add(duration), + subject: a.subject, + color: a.color, + location: a.location, + notes: a.notes, + isAllDay: a.isAllDay, + ), + ); + } + } catch (_) { + final idx = dayIndex(a.startTime, weekStart); + if (idx >= 0 && idx < 5) place(idx, a); + } + } + return (inside: inside, outside: outside); +} + +/// Maps lesson periods to vertical screen positions. Every non-break period +/// gets the same fixed height (`lessonHeight`), every break gets `breakHeight`. +/// Short transition gaps (Wechselzeiten) between periods are not represented +/// at all — periods are rendered back-to-back, so a 5-minute gap simply +/// disappears visually. +class PeriodLayout { + final List periods; + final double lessonHeight; + final double breakHeight; + + const PeriodLayout({ + required this.periods, + required this.lessonHeight, + required this.breakHeight, + }); + + double _h(LessonPeriod p) => p.isBreak ? breakHeight : lessonHeight; + + double get totalHeight => + periods.fold(0, (sum, p) => sum + _h(p)); + + double topOf(LessonPeriod period) { + var y = 0.0; + for (final p in periods) { + if (identical(p, period)) return y; + y += _h(p); + } + return y; + } + + double heightOf(LessonPeriod period) => _h(period); + + /// Vertical offset for a given time of day. Times inside a period are mapped + /// proportionally; times that fall into a transition gap are clipped to the + /// end of the preceding period. Times before the first / after the last + /// period clip to 0 / [totalHeight]. + double yOfDateTime(DateTime t) { + final tMin = t.hour * 60 + t.minute + t.second / 60.0; + var y = 0.0; + for (final p in periods) { + final pStart = p.start.hour * 60 + p.start.minute; + final pEnd = p.end.hour * 60 + p.end.minute; + final h = _h(p); + if (tMin < pStart) return y; + if (tMin <= pEnd) { + final span = pEnd - pStart; + final ratio = span > 0 ? (tMin - pStart) / span : 0.0; + return y + ratio * h; + } + y += h; + } + return y; + } + + /// Period at a given y-offset. If y falls into a break, returns the next + /// non-break period. Returns null when y is past the last period. + LessonPeriod? periodAtY(double y) { + var cursor = 0.0; + for (var i = 0; i < periods.length; i++) { + final p = periods[i]; + final h = _h(p); + if (y >= cursor && y < cursor + h) { + if (p.isBreak) { + for (var j = i + 1; j < periods.length; j++) { + if (!periods[j].isBreak) return periods[j]; + } + return null; + } + return p; + } + cursor += h; + } + return null; + } +} + +/// One cell rendered in the day column — either a regular appointment or an +/// overflow placeholder representing several hidden appointments. +sealed class LaidOutCell { + int get lane; + int get laneCount; + DateTime get startTime; + DateTime get endTime; +} + +class LaidOutAppointment extends LaidOutCell { + final Appointment appointment; + @override + final int lane; + @override + final int laneCount; + LaidOutAppointment(this.appointment, this.lane, this.laneCount); + + @override + DateTime get startTime => appointment.startTime; + @override + DateTime get endTime => appointment.endTime; +} + +class LaidOutOverflow extends LaidOutCell { + final List appointments; + @override + final int lane; + @override + final int laneCount; + @override + final DateTime startTime; + @override + final DateTime endTime; + LaidOutOverflow(this.appointments, this.lane, this.laneCount, this.startTime, this.endTime); +} + +/// Horizontal ordering rank for parallel appointments. Lower = further left. +/// User-owned custom events sit on the leftmost lane, cancelled lessons after +/// them, every other lesson last. Only used as a tiebreaker — the greedy lane +/// assignment still has to honor actual time-overlap constraints, so events +/// that start later can't jump left of events that started earlier and are +/// still occupying that lane. +int _appointmentPriority(Appointment a) { + final id = a.id; + if (id is CustomAppointment) return 0; + if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 1; + return 2; +} + +/// Assigns each appointment a lane index using a greedy sweep, then collapses +/// clusters that exceed [maxLanes] into `(maxLanes - 1)` visible appointments +/// + one trailing overflow cell. +/// +/// Greedy sweep: +/// 1. Sort by `startTime` ascending, then [_appointmentPriority] (custom → +/// cancelled → other) so parallel events land in the requested left-to- +/// right order, then `endTime` descending as a final tiebreaker. +/// 2. Walk the list, placing each appointment in the lowest-index lane that +/// is free at its `startTime`. When no lane is free, open a new one. +/// 3. A cluster ends as soon as every active lane's end is at or before the +/// next appointment's start. +List assignLanes(List appts, {required int maxLanes}) { + assert(maxLanes >= 2, 'maxLanes must reserve at least one slot for overflow'); + if (appts.isEmpty) return const []; + + final sorted = [...appts]..sort((a, b) { + final c = a.startTime.compareTo(b.startTime); + if (c != 0) return c; + final p = _appointmentPriority(a).compareTo(_appointmentPriority(b)); + if (p != 0) return p; + return b.endTime.compareTo(a.endTime); + }); + + // Phase 1: greedy lane assignment, grouped by cluster. + final clusters = >[]; + var current = <({Appointment apt, int lane})>[]; + var laneEnds = []; + + for (final apt in sorted) { + final allFree = + laneEnds.isNotEmpty && laneEnds.every((end) => !end.isAfter(apt.startTime)); + if (allFree) { + clusters.add(current); + current = <({Appointment apt, int lane})>[]; + laneEnds = []; + } + + var laneIdx = -1; + for (var i = 0; i < laneEnds.length; i++) { + if (!laneEnds[i].isAfter(apt.startTime)) { + laneIdx = i; + break; + } + } + if (laneIdx == -1) { + laneIdx = laneEnds.length; + laneEnds.add(apt.endTime); + } else { + laneEnds[laneIdx] = apt.endTime; + } + + current.add((apt: apt, lane: laneIdx)); + } + if (current.isNotEmpty) clusters.add(current); + + // Phase 2: emit cells per cluster, collapsing if too wide. + final result = []; + for (final cluster in clusters) { + final laneCount = + cluster.fold(0, (m, e) => e.lane + 1 > m ? e.lane + 1 : m); + + if (laneCount <= maxLanes) { + for (final entry in cluster) { + result.add(LaidOutAppointment(entry.apt, entry.lane, laneCount)); + } + } else { + // Too many parallel appointments: keep the highest-priority + // (maxLanes - 1) and collapse the rest into a single overflow cell in + // the trailing lane. Sorting by priority first means custom and + // cancelled lessons stay visible when the cluster has to be trimmed, + // matching the requested left-to-right order in the visible lanes. + final visibleCount = maxLanes - 1; + final byPriority = [...cluster.map((e) => e.apt)] + ..sort((a, b) { + final p = _appointmentPriority(a).compareTo(_appointmentPriority(b)); + if (p != 0) return p; + return a.startTime.compareTo(b.startTime); + }); + + for (var i = 0; i < visibleCount; i++) { + result.add(LaidOutAppointment(byPriority[i], i, maxLanes)); + } + + final overflow = byPriority.sublist(visibleCount); + var earliest = overflow.first.startTime; + var latest = overflow.first.endTime; + for (final a in overflow.skip(1)) { + if (a.startTime.isBefore(earliest)) earliest = a.startTime; + if (a.endTime.isAfter(latest)) latest = a.endTime; + } + result.add(LaidOutOverflow( + overflow, maxLanes - 1, maxLanes, earliest, latest)); + } + } + 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 1a66504..1615a9e 100644 --- a/lib/view/pages/timetable/details/custom_event_sheet.dart +++ b/lib/view/pages/timetable/details/custom_event_sheet.dart @@ -1,21 +1,19 @@ import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; import 'package:rrule/rrule.dart'; import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../../../../extensions/date_time.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/debug/debug_tile.dart'; +import '../../../../widget/details_bottom_sheet.dart'; import '../custom_events/custom_event_edit_dialog.dart'; -import 'bottom_sheet.dart'; import 'delete_custom_event.dart'; class CustomEventSheet { static void show(BuildContext context, CustomTimetableEvent event) { - final timeRange = - '${Jiffy.parseFromDateTime(event.startDate).format(pattern: 'HH:mm')} - ' - '${Jiffy.parseFromDateTime(event.endDate).format(pattern: 'HH:mm')}'; + final timeRange = event.startDate.timeRangeTo(event.endDate); - showAppointmentBottomSheet( + showDetailsBottomSheet( context, header: ListTile( leading: const Icon(Icons.event_outlined, size: 32), diff --git a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart index 2ada2ae..afd3230 100644 --- a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart +++ b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart @@ -1,38 +1,35 @@ -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; -import '../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart'; -import '../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart'; import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; +import '../../../../api/webuntis/services/lesson_resolver.dart'; +import '../../../../extensions/date_time.dart'; +import '../../../../extensions/text.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../../widget/debug/debug_tile.dart'; +import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/unimplemented_dialog.dart'; -import 'bottom_sheet.dart'; class WebuntisLessonSheet { static void show(BuildContext context, TimetableBloc bloc, Appointment appointment, GetTimetableResponseObject lesson) { final state = bloc.state.data; if (state == null) return; - final headerSubject = _resolveSubject(state, lesson.su.firstOrNull?.id); - final headerTitle = _firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']); + final headerSubject = LessonResolver.resolveSubject(state, lesson.su.firstOrNull?.id); + final headerTitle = firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']); final headerLongName = headerSubject.longName.isNotEmpty && headerSubject.longName != headerTitle ? headerSubject.longName : ''; - final timeRange = - '${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - ' - '${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}'; + final timeRange = appointment.startTime.timeRangeTo(appointment.endTime); - showAppointmentBottomSheet( + showDetailsBottomSheet( context, header: ListTile( - leading: Icon(_iconForCode(lesson.code), size: 32), + leading: Icon(LessonFormatter.iconForCode(lesson.code), size: 32), title: Text( - '${_codePrefix(lesson.code)}$headerTitle', + '${LessonFormatter.codePrefix(lesson.code)}$headerTitle', style: const TextStyle(fontWeight: FontWeight.bold), ), subtitle: Text(headerLongName.isNotEmpty @@ -43,17 +40,17 @@ class WebuntisLessonSheet { children: (_) => [ ListTile( leading: const Icon(Icons.notifications_active), - title: Text('Status: ${_statusLabel(lesson.code)}'), + title: Text('Status: ${LessonFormatter.statusLabel(lesson.code)}'), ), if (lesson.su.length > 1) _listTile( icon: Icons.book_outlined, label: 'Fächer', entries: lesson.su.map((s) { - final resolved = _resolveSubject(state, s.id); - return _formatLine( - _firstNonEmpty([resolved.name, s.name, '?']), - longname: _firstNonEmpty([resolved.longName, s.longname, '']), + final resolved = LessonResolver.resolveSubject(state, s.id); + return LessonFormatter.formatLine( + firstNonEmpty([resolved.name, s.name, '?']), + longname: firstNonEmpty([resolved.longName, s.longname, '']), ); }).toList(), ), @@ -69,7 +66,7 @@ class WebuntisLessonSheet { icon: Icons.people, label: lesson.kl.length == 1 ? 'Klasse' : 'Klassen', entries: lesson.kl - .map((k) => _formatLine( + .map((k) => LessonFormatter.formatLine( k.name.isNotEmpty ? k.name : '?', longname: k.longname, )) @@ -81,17 +78,6 @@ class WebuntisLessonSheet { ); } - static IconData _iconForCode(String? code) { - switch (code) { - case 'cancelled': - return Icons.event_busy_outlined; - case 'irregular': - return Icons.swap_horiz; - default: - return Icons.school_outlined; - } - } - static Widget _roomTile(BuildContext context, TimetableState state, GetTimetableResponseObject lesson) { final trailing = IconButton( icon: const Icon(Icons.house_outlined), @@ -107,11 +93,11 @@ class WebuntisLessonSheet { } final entries = lesson.ro.map((r) { - final resolved = _resolveRoom(state, r.id); - final name = _firstNonEmpty([resolved.name, r.name, '?']); - final longname = _firstNonEmpty([resolved.longName, r.longname, '']); + final resolved = LessonResolver.resolveRoom(state, r.id); + final name = firstNonEmpty([resolved.name, r.name, '?']); + final longname = firstNonEmpty([resolved.longName, r.longname, '']); final building = resolved.building.trim(); - return _formatLine( + return LessonFormatter.formatLine( name, longname: longname, extra: (building.isNotEmpty && building != '?') ? building : null, @@ -144,7 +130,7 @@ class WebuntisLessonSheet { } final entries = lesson.te.map((t) { - final base = _formatLine( + final base = LessonFormatter.formatLine( t.name.isNotEmpty ? t.name : '?', longname: t.longname, ); @@ -206,54 +192,4 @@ class WebuntisLessonSheet { subtitle: Text(text), ); } - - static String _formatLine(String name, {String? longname, String? extra}) { - final parts = [ - if (name.isNotEmpty) name else '?', - ]; - final ln = (longname ?? '').trim(); - if (ln.isNotEmpty && ln != name) parts.add('($ln)'); - final ex = (extra ?? '').trim(); - if (ex.isNotEmpty) parts.add('· $ex'); - return parts.join(' '); - } - - static String _firstNonEmpty(List values) { - for (final v in values) { - if (v.trim().isNotEmpty) return v; - } - return ''; - } - - static String _statusLabel(String? code) { - switch (code) { - case null: - case '': - return 'Regulär'; - case 'cancelled': - return 'Entfällt'; - case 'irregular': - return 'Geändert'; - default: - return code; - } - } - - static String _codePrefix(String? code) { - if (code == 'cancelled') return 'Entfällt: '; - if (code == 'irregular') return 'Änderung: '; - return code ?? ''; - } - - static GetSubjectsResponseObject _resolveSubject(TimetableState state, int? id) { - final fallback = GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true); - if (id == null) return fallback; - return state.subjects?.result.firstWhereOrNull((s) => s.id == id) ?? fallback; - } - - static GetRoomsResponseObject _resolveRoom(TimetableState state, int? id) { - final fallback = GetRoomsResponseObject(0, '?', 'Unbekannt', true, ''); - if (id == null) return fallback; - return state.rooms?.result.firstWhereOrNull((r) => r.id == id) ?? fallback; - } } diff --git a/lib/view/pages/timetable/widgets/calendar/day_header.dart b/lib/view/pages/timetable/widgets/calendar/day_header.dart new file mode 100644 index 0000000..3bd683d --- /dev/null +++ b/lib/view/pages/timetable/widgets/calendar/day_header.dart @@ -0,0 +1,84 @@ +part of '../custom_workweek_calendar.dart'; + +class _DayHeaderStrip extends StatelessWidget { + final DateTime weekStart; + final DateTime today; + final double rulerWidth; + + const _DayHeaderStrip({ + super.key, + required this.weekStart, + required this.today, + required this.rulerWidth, + }); + + @override + Widget build(BuildContext context) => Row( + children: [ + SizedBox(width: rulerWidth), + for (var d = 0; d < 5; d++) + Expanded( + child: _DayHeaderCell( + date: weekStart.add(Duration(days: d)), + today: today, + ), + ), + ], + ); +} + +class _DayHeaderCell extends StatelessWidget { + final DateTime date; + final DateTime today; + + const _DayHeaderCell({required this.date, required this.today}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isToday = date.isSameDay(today); + final dayName = DateFormat('EE', Localizations.localeOf(context).toString()).format(date).toUpperCase(); + + final accent = theme.colorScheme.primary; + final onAccent = theme.colorScheme.onPrimary; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + dayName, + style: theme.textTheme.labelSmall?.copyWith( + color: isToday ? accent : theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + fontSize: 12, + height: 1.1, + ), + ), + const SizedBox(height: 2), + AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + width: 28, + height: 28, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isToday ? accent : Colors.transparent, + ), + alignment: Alignment.center, + child: Text( + '${date.day}', + style: theme.textTheme.titleSmall?.copyWith( + color: isToday ? onAccent : theme.colorScheme.onSurface, + fontWeight: isToday ? FontWeight.bold : FontWeight.normal, + height: 1.0, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart new file mode 100644 index 0000000..098da70 --- /dev/null +++ b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart @@ -0,0 +1,271 @@ +part of '../custom_workweek_calendar.dart'; + +class _OutsideHoursStrip extends StatelessWidget { + static const int _maxVisibleChips = 2; + static const double _chipHeight = 22; + static const double _chipSpacing = 3; + static const double _verticalPadding = 3; + + final DateTime weekStart; + final List appointments; + final double rulerWidth; + final void Function(Appointment) onAppointmentTap; + final bool Function(Appointment) isCrossedOut; + + const _OutsideHoursStrip({ + super.key, + required this.weekStart, + required this.appointments, + required this.rulerWidth, + required this.onAppointmentTap, + required this.isCrossedOut, + }); + + @override + Widget build(BuildContext context) { + final outside = partitionAppointmentsForWeek(appointments, weekStart).outside; + if (outside.every((day) => day.isEmpty)) return const SizedBox.shrink(); + + final theme = Theme.of(context); + final maxChipsPerDay = outside + .map((day) => day.length > _maxVisibleChips ? _maxVisibleChips : day.length) + .fold(0, (m, c) => c > m ? c : m); + final stripHeight = _verticalPadding * 2 + + maxChipsPerDay * _chipHeight + + (maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * _chipSpacing : 0); + + return Container( + color: theme.colorScheme.surfaceContainerLowest, + padding: const EdgeInsets.symmetric(vertical: _verticalPadding), + child: SizedBox( + height: stripHeight - _verticalPadding * 2, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: rulerWidth), + for (var d = 0; d < 5; d++) + Expanded( + child: _OutsideDayColumn( + appointments: outside[d], + maxVisible: _maxVisibleChips, + chipHeight: _chipHeight, + chipSpacing: _chipSpacing, + onAppointmentTap: onAppointmentTap, + isCrossedOut: isCrossedOut, + ), + ), + ], + ), + ), + ); + } +} + +class _OutsideDayColumn extends StatelessWidget { + final List appointments; + final int maxVisible; + final double chipHeight; + final double chipSpacing; + final void Function(Appointment) onAppointmentTap; + final bool Function(Appointment) isCrossedOut; + + const _OutsideDayColumn({ + required this.appointments, + required this.maxVisible, + required this.chipHeight, + required this.chipSpacing, + required this.onAppointmentTap, + required this.isCrossedOut, + }); + + void _showOverflow(BuildContext context, List hidden) { + showDetailsBottomSheet( + context, + children: (sheetCtx) { + final tiles = []; + for (var i = 0; i < hidden.length; i++) { + if (i > 0) tiles.add(const Divider(height: 1)); + final apt = hidden[i]; + tiles.add(ListTile( + leading: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: apt.color, + borderRadius: BorderRadius.circular(3), + ), + ), + title: Text( + apt.subject, + style: isCrossedOut(apt) + ? const TextStyle(decoration: TextDecoration.lineThrough) + : null, + ), + subtitle: Text(_subtitleFor(apt)), + onTap: () { + Navigator.of(sheetCtx).pop(); + onAppointmentTap(apt); + }, + )); + } + return tiles; + }, + ); + } + + static String _subtitleFor(Appointment a) { + if (isAllDayLike(a)) return 'Ganztägig'; + return '${_hm(a.startTime)}–${_hm(a.endTime)}'; + } + + static String _hm(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + + @override + Widget build(BuildContext context) { + if (appointments.isEmpty) return const SizedBox.shrink(); + final sorted = [...appointments] + ..sort((a, b) { + final aLike = isAllDayLike(a); + final bLike = isAllDayLike(b); + if (aLike && !bLike) return -1; + if (!aLike && bLike) return 1; + return a.startTime.compareTo(b.startTime); + }); + final visible = sorted.length <= maxVisible + ? sorted + : sorted.take(maxVisible - 1).toList(); + final overflow = + sorted.length <= maxVisible ? const [] : sorted.skip(maxVisible - 1).toList(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < visible.length; i++) ...[ + if (i > 0) SizedBox(height: chipSpacing), + SizedBox( + height: chipHeight, + child: _OutsideChip( + appointment: visible[i], + onTap: () => onAppointmentTap(visible[i]), + ), + ), + ], + if (overflow.isNotEmpty) ...[ + SizedBox(height: chipSpacing), + SizedBox( + height: chipHeight, + child: _OutsideOverflowChip( + count: overflow.length, + onTap: () => _showOverflow(context, overflow), + ), + ), + ], + ], + ), + ); + } +} + +class _OutsideChip extends StatelessWidget { + final Appointment appointment; + final VoidCallback onTap; + + const _OutsideChip({required this.appointment, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final allDay = isAllDayLike(appointment); + final timeLabel = allDay + ? null + : '${appointment.startTime.hour.toString().padLeft(2, '0')}:${appointment.startTime.minute.toString().padLeft(2, '0')}'; + + // Past chips fade further, future/ongoing ones get a more saturated tint + // so the strip no longer reads as one uniform grey block. + final isPast = appointment.endTime.isBefore(DateTime.now()); + final backgroundAlpha = isPast ? 38 : 120; + final subjectColor = isPast + ? theme.colorScheme.onSurfaceVariant + : theme.colorScheme.onSurface; + final subjectWeight = isPast ? FontWeight.w400 : FontWeight.w600; + + return Material( + color: appointment.color.withAlpha(backgroundAlpha), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(7)), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Text( + appointment.subject, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + style: theme.textTheme.labelSmall?.copyWith( + color: subjectColor, + fontWeight: subjectWeight, + ), + ), + ), + if (timeLabel != null) ...[ + const SizedBox(width: 4), + Flexible( + child: Text( + timeLabel, + maxLines: 1, + overflow: TextOverflow.fade, + softWrap: false, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 10, + ), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +class _OutsideOverflowChip extends StatelessWidget { + final int count; + final VoidCallback onTap; + + const _OutsideOverflowChip({required this.count, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: theme.colorScheme.secondaryContainer, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Center( + child: Text( + '+$count weitere', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } +} diff --git a/lib/view/pages/timetable/widgets/calendar/week_grid.dart b/lib/view/pages/timetable/widgets/calendar/week_grid.dart new file mode 100644 index 0000000..df4741f --- /dev/null +++ b/lib/view/pages/timetable/widgets/calendar/week_grid.dart @@ -0,0 +1,489 @@ +part of '../custom_workweek_calendar.dart'; + +class _WeekGrid extends StatelessWidget { + final DateTime weekStart; + final LessonPeriodSchedule schedule; + final List appointments; + final List timeRegions; + final void Function(Appointment) onAppointmentTap; + final bool Function(Appointment) isCrossedOut; + final void Function(DateTime start, DateTime end)? onCreateEvent; + final DateTime today; + final ValueListenable nowNotifier; + final double rulerWidth; + final PeriodLayout layout; + + const _WeekGrid({ + required this.weekStart, + required this.schedule, + required this.appointments, + required this.timeRegions, + required this.onAppointmentTap, + required this.isCrossedOut, + required this.onCreateEvent, + required this.today, + required this.nowNotifier, + required this.rulerWidth, + required this.layout, + }); + + @override + Widget build(BuildContext context) { + final partitioned = partitionAppointmentsForWeek(appointments, weekStart); + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _PeriodRuler( + schedule: schedule, + layout: layout, + width: rulerWidth, + ), + for (var d = 0; d < 5; d++) + Expanded( + child: _DayColumn( + date: weekStart.add(Duration(days: d)), + schedule: schedule, + appointments: partitioned.inside[d], + timeRegions: timeRegions, + layout: layout, + today: today, + nowNotifier: nowNotifier, + onAppointmentTap: onAppointmentTap, + isCrossedOut: isCrossedOut, + onCreateEvent: onCreateEvent, + ), + ), + ], + ); + } +} + +class _PeriodRuler extends StatelessWidget { + final LessonPeriodSchedule schedule; + final PeriodLayout layout; + final double width; + + const _PeriodRuler({ + required this.schedule, + required this.layout, + required this.width, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + width: width, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (final period in schedule.periods) + Positioned( + top: layout.topOf(period), + height: layout.heightOf(period), + left: 0, + right: 0, + child: _PeriodLabel(period: period, theme: theme), + ), + ], + ), + ); + } +} + +class _PeriodLabel extends StatelessWidget { + final LessonPeriod period; + final ThemeData theme; + + const _PeriodLabel({required this.period, required this.theme}); + + @override + Widget build(BuildContext context) { + final dividerColor = theme.dividerColor.withAlpha(110); + final secondaryTextColor = theme.colorScheme.onSurfaceVariant; + + if (period.isBreak) { + return Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: dividerColor, width: 0.5), + bottom: BorderSide(color: dividerColor, width: 0.5), + ), + ), + alignment: Alignment.center, + child: Icon(Icons.coffee_outlined, size: 12, color: secondaryTextColor.withAlpha(180)), + ); + } + + final timeStyle = theme.textTheme.labelSmall?.copyWith( + color: secondaryTextColor.withAlpha(140), + height: 1.0, + fontSize: 9, + ); + const tightTextHeight = TextHeightBehavior( + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, + ); + + return LayoutBuilder( + builder: (context, constraints) { + final showTimes = constraints.maxHeight >= 38; + return DecoratedBox( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: dividerColor, width: 0.5)), + ), + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + if (showTimes) + Positioned( + top: 3, + left: 0, + right: 0, + child: Text( + _format(period.start), + style: timeStyle, + textAlign: TextAlign.center, + textHeightBehavior: tightTextHeight, + ), + ), + Text( + period.name, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w500, + height: 1.0, + ), + textAlign: TextAlign.center, + textHeightBehavior: tightTextHeight, + ), + if (showTimes) + Positioned( + bottom: 3, + left: 0, + right: 0, + child: Text( + _format(period.end), + style: timeStyle, + textAlign: TextAlign.center, + textHeightBehavior: tightTextHeight, + ), + ), + ], + ), + ); + }, + ); + } + + static String _format(TimeOfDay t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; +} + +class _DayColumn extends StatelessWidget { + final DateTime date; + final LessonPeriodSchedule schedule; + final List appointments; + final List timeRegions; + final PeriodLayout layout; + final DateTime today; + final ValueListenable nowNotifier; + final void Function(Appointment) onAppointmentTap; + final bool Function(Appointment) isCrossedOut; + final void Function(DateTime start, DateTime end)? onCreateEvent; + + const _DayColumn({ + required this.date, + required this.schedule, + required this.appointments, + required this.timeRegions, + required this.layout, + required this.today, + required this.nowNotifier, + required this.onAppointmentTap, + required this.isCrossedOut, + required this.onCreateEvent, + }); + + bool _overlapsExistingAppointment(DateTime start, DateTime end, List dayAppts) { + for (final a in dayAppts) { + if (a.endTime.isAfter(start) && a.startTime.isBefore(end)) return true; + } + return false; + } + + void _handleLongPress(LongPressStartDetails details, List dayAppts) { + if (onCreateEvent == null) return; + 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); + if (_overlapsExistingAppointment(start, end, dayAppts)) return; + + HapticFeedback.mediumImpact(); + onCreateEvent!(start, end); + } + + void _showOverflowSheet(BuildContext context, List appointments) { + final sorted = [...appointments] + ..sort((a, b) => a.startTime.compareTo(b.startTime)); + showDetailsBottomSheet( + context, + children: (sheetContext) { + final tiles = []; + for (var i = 0; i < sorted.length; i++) { + if (i > 0) tiles.add(const Divider(height: 1)); + final apt = sorted[i]; + tiles.add(ListTile( + leading: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: apt.color, + borderRadius: BorderRadius.circular(3), + ), + ), + title: Text( + apt.subject, + style: isCrossedOut(apt) + ? const TextStyle(decoration: TextDecoration.lineThrough) + : null, + ), + subtitle: Text(_overflowSubtitle(apt)), + onTap: () { + Navigator.of(sheetContext).pop(); + onAppointmentTap(apt); + }, + )); + } + return tiles; + }, + ); + } + + static String _overflowSubtitle(Appointment apt) { + final time = '${_formatHm(apt.startTime)}–${_formatHm(apt.endTime)}'; + final loc = apt.location?.replaceAll('\n', ' · '); + return loc != null && loc.isNotEmpty ? '$time · $loc' : time; + } + + static String _formatHm(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final dayAppointments = appointments; + final dayRegions = expandRegionsForDay(timeRegions, date); + final isToday = date.isSameDay(today); + + final isTablet = MediaQuery.of(context).size.shortestSide >= 600; + final laidOut = assignLanes(dayAppointments, maxLanes: isTablet ? 3 : 2); + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPressStart: (details) => _handleLongPress(details, dayAppointments), + child: DecoratedBox( + decoration: BoxDecoration( + color: isToday ? theme.colorScheme.primary.withAlpha(14) : null, + border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + return Stack( + clipBehavior: Clip.none, + children: [ + for (final period in schedule.periods) + Positioned( + top: layout.topOf(period), + left: 0, + right: 0, + child: Container( + height: 0.5, + color: theme.dividerColor.withAlpha(60), + ), + ), + for (final region in dayRegions) + Positioned( + top: layout.yOfDateTime(region.start), + height: (layout.yOfDateTime(region.end) - + layout.yOfDateTime(region.start)) + .clamp(0, double.infinity), + left: 0, + right: 0, + child: TimeRegionTile(region: region.region), + ), + for (final cell in laidOut) + Positioned( + top: layout.yOfDateTime(cell.startTime), + height: (layout.yOfDateTime(cell.endTime) - + layout.yOfDateTime(cell.startTime)) + .clamp(0, double.infinity), + left: cell.lane * width / cell.laneCount, + width: width / cell.laneCount, + child: switch (cell) { + LaidOutAppointment(:final appointment) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onAppointmentTap(appointment), + child: AppointmentTile( + appointment: appointment, + crossedOut: isCrossedOut(appointment), + ), + ), + LaidOutOverflow(:final appointments) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => + _showOverflowSheet(context, appointments), + child: _OverflowTile(count: appointments.length), + ), + }, + ), + if (isToday) + ValueListenableBuilder( + valueListenable: nowNotifier, + builder: (_, now, child) => + _CurrentTimeMarker(now: now, layout: layout, theme: theme), + ), + ], + ); + }, + ), + ), + ); + } +} + +class _CurrentTimeMarker extends StatelessWidget { + final DateTime now; + final PeriodLayout layout; + final ThemeData theme; + + const _CurrentTimeMarker({ + required this.now, + required this.layout, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + final periods = layout.periods; + if (periods.isEmpty) return const SizedBox.shrink(); + final tMin = now.hour * 60 + now.minute; + final firstStart = + periods.first.start.hour * 60 + periods.first.start.minute; + final lastEnd = + periods.last.end.hour * 60 + periods.last.end.minute; + if (tMin < firstStart || tMin > lastEnd) return const SizedBox.shrink(); + + final y = layout.yOfDateTime(now); + + return AnimatedPositioned( + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + top: y - 1, + left: 0, + right: 0, + child: IgnorePointer( + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + height: 2, + color: theme.colorScheme.primary, + ), + Positioned( + top: -3, + left: -4, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: theme.colorScheme.primary, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _OverflowTile extends StatelessWidget { + final int count; + const _OverflowTile({required this.count}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + const radius = BorderRadius.all(Radius.circular(7)); + + return Padding( + padding: const EdgeInsets.all(1), + child: Stack( + children: [ + // Card peeking out at the bottom — visual hint that more cards lie + // underneath the visible one. + Positioned( + top: 4, + left: 2, + right: 2, + bottom: 0, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: radius, + color: scheme.secondaryContainer.withAlpha(120), + ), + ), + ), + // Front card with the "+N" indicator. + Positioned( + top: 0, + left: 0, + right: 0, + bottom: 4, + child: Container( + decoration: BoxDecoration( + borderRadius: radius, + color: scheme.secondaryContainer, + ), + alignment: Alignment.center, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.unfold_more_rounded, + size: 18, + color: scheme.onSecondaryContainer, + ), + Text( + '+$count', + style: theme.textTheme.titleSmall?.copyWith( + color: scheme.onSecondaryContainer, + fontWeight: FontWeight.w700, + height: 1.0, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 8a6ba56..642618f 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -1,18 +1,30 @@ +/// 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. +library; + import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; -import 'package:rrule/rrule.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; -import '../data/arbitrary_appointment.dart'; +import '../../../../extensions/date_time.dart'; +import '../../../../widget/details_bottom_sheet.dart'; import '../data/calendar_layout.dart'; +import '../data/calendar_logic.dart'; import '../data/lesson_period_schedule.dart'; import 'appointment_tile.dart'; import 'time_region_tile.dart'; +part 'calendar/day_header.dart'; +part 'calendar/outside_chips.dart'; +part 'calendar/week_grid.dart'; + class CustomWorkWeekCalendar extends StatefulWidget { final LessonPeriodSchedule schedule; final List appointments; @@ -166,7 +178,7 @@ class CustomWorkWeekCalendarState extends State { final lessonH = fitLessonH < kLessonBlockMinHeight ? kLessonBlockMinHeight : fitLessonH; - final layout = _PeriodLayout( + final layout = PeriodLayout( periods: periods, lessonHeight: lessonH, breakHeight: kBreakBlockHeight, @@ -211,1223 +223,3 @@ class CustomWorkWeekCalendarState extends State { ); } } - -class _OutsideHoursStrip extends StatelessWidget { - static const int _maxVisibleChips = 2; - static const double _chipHeight = 22; - static const double _chipSpacing = 3; - static const double _verticalPadding = 3; - - final DateTime weekStart; - final List appointments; - final double rulerWidth; - final void Function(Appointment) onAppointmentTap; - final bool Function(Appointment) isCrossedOut; - - const _OutsideHoursStrip({ - super.key, - required this.weekStart, - required this.appointments, - required this.rulerWidth, - required this.onAppointmentTap, - required this.isCrossedOut, - }); - - @override - Widget build(BuildContext context) { - final outside = _partitionAppointmentsForWeek(appointments, weekStart).outside; - if (outside.every((day) => day.isEmpty)) return const SizedBox.shrink(); - - final theme = Theme.of(context); - final maxChipsPerDay = outside - .map((day) => day.length > _maxVisibleChips ? _maxVisibleChips : day.length) - .fold(0, (m, c) => c > m ? c : m); - final stripHeight = _verticalPadding * 2 + - maxChipsPerDay * _chipHeight + - (maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * _chipSpacing : 0); - - return Container( - color: theme.colorScheme.surfaceContainerLowest, - padding: const EdgeInsets.symmetric(vertical: _verticalPadding), - child: SizedBox( - height: stripHeight - _verticalPadding * 2, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(width: rulerWidth), - for (var d = 0; d < 5; d++) - Expanded( - child: _OutsideDayColumn( - appointments: outside[d], - maxVisible: _maxVisibleChips, - chipHeight: _chipHeight, - chipSpacing: _chipSpacing, - onAppointmentTap: onAppointmentTap, - isCrossedOut: isCrossedOut, - ), - ), - ], - ), - ), - ); - } -} - -class _OutsideDayColumn extends StatelessWidget { - final List appointments; - final int maxVisible; - final double chipHeight; - final double chipSpacing; - final void Function(Appointment) onAppointmentTap; - final bool Function(Appointment) isCrossedOut; - - const _OutsideDayColumn({ - required this.appointments, - required this.maxVisible, - required this.chipHeight, - required this.chipSpacing, - required this.onAppointmentTap, - required this.isCrossedOut, - }); - - void _showOverflow(BuildContext context, List hidden) { - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetCtx) => SafeArea( - child: ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: 4), - itemCount: hidden.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (_, i) { - final apt = hidden[i]; - return ListTile( - leading: Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: apt.color, - borderRadius: BorderRadius.circular(3), - ), - ), - title: Text( - apt.subject, - style: isCrossedOut(apt) - ? const TextStyle(decoration: TextDecoration.lineThrough) - : null, - ), - subtitle: Text(_subtitleFor(apt)), - onTap: () { - Navigator.of(sheetCtx).pop(); - onAppointmentTap(apt); - }, - ); - }, - ), - ), - ); - } - - static String _subtitleFor(Appointment a) { - if (_isAllDayLike(a)) return 'Ganztägig'; - return '${_hm(a.startTime)}–${_hm(a.endTime)}'; - } - - static String _hm(DateTime t) => - '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; - - @override - Widget build(BuildContext context) { - if (appointments.isEmpty) return const SizedBox.shrink(); - final sorted = [...appointments] - ..sort((a, b) { - final aLike = _isAllDayLike(a); - final bLike = _isAllDayLike(b); - if (aLike && !bLike) return -1; - if (!aLike && bLike) return 1; - return a.startTime.compareTo(b.startTime); - }); - final visible = sorted.length <= maxVisible - ? sorted - : sorted.take(maxVisible - 1).toList(); - final overflow = - sorted.length <= maxVisible ? const [] : sorted.skip(maxVisible - 1).toList(); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (var i = 0; i < visible.length; i++) ...[ - if (i > 0) SizedBox(height: chipSpacing), - SizedBox( - height: chipHeight, - child: _OutsideChip( - appointment: visible[i], - onTap: () => onAppointmentTap(visible[i]), - ), - ), - ], - if (overflow.isNotEmpty) ...[ - SizedBox(height: chipSpacing), - SizedBox( - height: chipHeight, - child: _OutsideOverflowChip( - count: overflow.length, - onTap: () => _showOverflow(context, overflow), - ), - ), - ], - ], - ), - ); - } -} - -class _OutsideChip extends StatelessWidget { - final Appointment appointment; - final VoidCallback onTap; - - const _OutsideChip({required this.appointment, required this.onTap}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final allDay = _isAllDayLike(appointment); - final timeLabel = allDay - ? null - : '${appointment.startTime.hour.toString().padLeft(2, '0')}:${appointment.startTime.minute.toString().padLeft(2, '0')}'; - - // Past chips fade further, future/ongoing ones get a more saturated tint - // so the strip no longer reads as one uniform grey block. - final isPast = appointment.endTime.isBefore(DateTime.now()); - final backgroundAlpha = isPast ? 38 : 120; - final subjectColor = isPast - ? theme.colorScheme.onSurfaceVariant - : theme.colorScheme.onSurface; - final subjectWeight = isPast ? FontWeight.w400 : FontWeight.w600; - - return Material( - color: appointment.color.withAlpha(backgroundAlpha), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(7)), - ), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - child: Row( - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - child: Text( - appointment.subject, - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - style: theme.textTheme.labelSmall?.copyWith( - color: subjectColor, - fontWeight: subjectWeight, - ), - ), - ), - if (timeLabel != null) ...[ - const SizedBox(width: 4), - Flexible( - child: Text( - timeLabel, - maxLines: 1, - overflow: TextOverflow.fade, - softWrap: false, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontSize: 10, - ), - ), - ), - ], - ], - ), - ), - ), - ); - } -} - -class _OutsideOverflowChip extends StatelessWidget { - final int count; - final VoidCallback onTap; - - const _OutsideOverflowChip({required this.count, required this.onTap}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Material( - color: theme.colorScheme.secondaryContainer, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Center( - child: Text( - '+$count weitere', - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSecondaryContainer, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ); - } -} - -class _DayHeaderStrip extends StatelessWidget { - final DateTime weekStart; - final DateTime today; - final double rulerWidth; - - const _DayHeaderStrip({ - super.key, - required this.weekStart, - required this.today, - required this.rulerWidth, - }); - - @override - Widget build(BuildContext context) => Row( - children: [ - SizedBox(width: rulerWidth), - for (var d = 0; d < 5; d++) - Expanded( - child: _DayHeaderCell( - date: weekStart.add(Duration(days: d)), - today: today, - ), - ), - ], - ); -} - -class _DayHeaderCell extends StatelessWidget { - final DateTime date; - final DateTime today; - - const _DayHeaderCell({required this.date, required this.today}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isToday = _isSameDay(date, today); - final dayName = DateFormat('EE', Localizations.localeOf(context).toString()).format(date).toUpperCase(); - - final accent = theme.colorScheme.primary; - final onAccent = theme.colorScheme.onPrimary; - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - dayName, - style: theme.textTheme.labelSmall?.copyWith( - color: isToday ? accent : theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - fontSize: 12, - height: 1.1, - ), - ), - const SizedBox(height: 2), - AnimatedContainer( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - width: 28, - height: 28, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isToday ? accent : Colors.transparent, - ), - alignment: Alignment.center, - child: Text( - '${date.day}', - style: theme.textTheme.titleSmall?.copyWith( - color: isToday ? onAccent : theme.colorScheme.onSurface, - fontWeight: isToday ? FontWeight.bold : FontWeight.normal, - height: 1.0, - ), - ), - ), - ], - ), - ); - } -} - -class _WeekGrid extends StatelessWidget { - final DateTime weekStart; - final LessonPeriodSchedule schedule; - final List appointments; - final List timeRegions; - final void Function(Appointment) onAppointmentTap; - final bool Function(Appointment) isCrossedOut; - final void Function(DateTime start, DateTime end)? onCreateEvent; - final DateTime today; - final ValueListenable nowNotifier; - final double rulerWidth; - final _PeriodLayout layout; - - const _WeekGrid({ - required this.weekStart, - required this.schedule, - required this.appointments, - required this.timeRegions, - required this.onAppointmentTap, - required this.isCrossedOut, - required this.onCreateEvent, - required this.today, - required this.nowNotifier, - required this.rulerWidth, - required this.layout, - }); - - @override - Widget build(BuildContext context) { - final partitioned = _partitionAppointmentsForWeek(appointments, weekStart); - - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _PeriodRuler( - schedule: schedule, - layout: layout, - width: rulerWidth, - ), - for (var d = 0; d < 5; d++) - Expanded( - child: _DayColumn( - date: weekStart.add(Duration(days: d)), - schedule: schedule, - appointments: partitioned.inside[d], - timeRegions: timeRegions, - layout: layout, - today: today, - nowNotifier: nowNotifier, - onAppointmentTap: onAppointmentTap, - isCrossedOut: isCrossedOut, - onCreateEvent: onCreateEvent, - ), - ), - ], - ); - } -} - -class _PeriodRuler extends StatelessWidget { - final LessonPeriodSchedule schedule; - final _PeriodLayout layout; - final double width; - - const _PeriodRuler({ - required this.schedule, - required this.layout, - required this.width, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return SizedBox( - width: width, - child: Stack( - clipBehavior: Clip.none, - children: [ - for (final period in schedule.periods) - Positioned( - top: layout.topOf(period), - height: layout.heightOf(period), - left: 0, - right: 0, - child: _PeriodLabel(period: period, theme: theme), - ), - ], - ), - ); - } -} - -class _PeriodLabel extends StatelessWidget { - final LessonPeriod period; - final ThemeData theme; - - const _PeriodLabel({required this.period, required this.theme}); - - @override - Widget build(BuildContext context) { - final dividerColor = theme.dividerColor.withAlpha(110); - final secondaryTextColor = theme.colorScheme.onSurfaceVariant; - - if (period.isBreak) { - return Container( - decoration: BoxDecoration( - border: Border( - top: BorderSide(color: dividerColor, width: 0.5), - bottom: BorderSide(color: dividerColor, width: 0.5), - ), - ), - alignment: Alignment.center, - child: Icon(Icons.coffee_outlined, size: 12, color: secondaryTextColor.withAlpha(180)), - ); - } - - final timeStyle = theme.textTheme.labelSmall?.copyWith( - color: secondaryTextColor.withAlpha(140), - height: 1.0, - fontSize: 9, - ); - const tightTextHeight = TextHeightBehavior( - applyHeightToFirstAscent: false, - applyHeightToLastDescent: false, - ); - - return LayoutBuilder( - builder: (context, constraints) { - final showTimes = constraints.maxHeight >= 38; - return DecoratedBox( - decoration: BoxDecoration( - border: Border(top: BorderSide(color: dividerColor, width: 0.5)), - ), - child: Stack( - clipBehavior: Clip.none, - alignment: Alignment.center, - children: [ - if (showTimes) - Positioned( - top: 3, - left: 0, - right: 0, - child: Text( - _format(period.start), - style: timeStyle, - textAlign: TextAlign.center, - textHeightBehavior: tightTextHeight, - ), - ), - Text( - period.name, - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onSurface, - fontWeight: FontWeight.w500, - height: 1.0, - ), - textAlign: TextAlign.center, - textHeightBehavior: tightTextHeight, - ), - if (showTimes) - Positioned( - bottom: 3, - left: 0, - right: 0, - child: Text( - _format(period.end), - style: timeStyle, - textAlign: TextAlign.center, - textHeightBehavior: tightTextHeight, - ), - ), - ], - ), - ); - }, - ); - } - - static String _format(TimeOfDay t) => - '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; -} - -class _DayColumn extends StatelessWidget { - final DateTime date; - final LessonPeriodSchedule schedule; - final List appointments; - final List timeRegions; - final _PeriodLayout layout; - final DateTime today; - final ValueListenable nowNotifier; - final void Function(Appointment) onAppointmentTap; - final bool Function(Appointment) isCrossedOut; - final void Function(DateTime start, DateTime end)? onCreateEvent; - - const _DayColumn({ - required this.date, - required this.schedule, - required this.appointments, - required this.timeRegions, - required this.layout, - required this.today, - required this.nowNotifier, - required this.onAppointmentTap, - required this.isCrossedOut, - required this.onCreateEvent, - }); - - bool _overlapsExistingAppointment(DateTime start, DateTime end, List dayAppts) { - for (final a in dayAppts) { - if (a.endTime.isAfter(start) && a.startTime.isBefore(end)) return true; - } - return false; - } - - void _handleLongPress(LongPressStartDetails details, List dayAppts) { - if (onCreateEvent == null) return; - 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); - if (_overlapsExistingAppointment(start, end, dayAppts)) return; - - HapticFeedback.mediumImpact(); - onCreateEvent!(start, end); - } - - void _showOverflowSheet(BuildContext context, List appointments) { - final sorted = [...appointments] - ..sort((a, b) => a.startTime.compareTo(b.startTime)); - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: 4), - itemCount: sorted.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (_, i) { - final apt = sorted[i]; - return ListTile( - leading: Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: apt.color, - borderRadius: BorderRadius.circular(3), - ), - ), - title: Text( - apt.subject, - style: isCrossedOut(apt) - ? const TextStyle(decoration: TextDecoration.lineThrough) - : null, - ), - subtitle: Text(_overflowSubtitle(apt)), - onTap: () { - Navigator.of(sheetContext).pop(); - onAppointmentTap(apt); - }, - ); - }, - ), - ), - ); - } - - static String _overflowSubtitle(Appointment apt) { - final time = '${_formatHm(apt.startTime)}–${_formatHm(apt.endTime)}'; - final loc = apt.location?.replaceAll('\n', ' · '); - return loc != null && loc.isNotEmpty ? '$time · $loc' : time; - } - - static String _formatHm(DateTime t) => - '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - final dayAppointments = appointments; - final dayRegions = _expandRegionsForDay(timeRegions, date); - final isToday = _isSameDay(date, today); - - final isTablet = MediaQuery.of(context).size.shortestSide >= 600; - final laidOut = _assignLanes(dayAppointments, maxLanes: isTablet ? 3 : 2); - - return GestureDetector( - behavior: HitTestBehavior.translucent, - onLongPressStart: (details) => _handleLongPress(details, dayAppointments), - child: DecoratedBox( - decoration: BoxDecoration( - color: isToday ? theme.colorScheme.primary.withAlpha(14) : null, - border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)), - ), - child: LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - return Stack( - clipBehavior: Clip.none, - children: [ - for (final period in schedule.periods) - Positioned( - top: layout.topOf(period), - left: 0, - right: 0, - child: Container( - height: 0.5, - color: theme.dividerColor.withAlpha(60), - ), - ), - for (final region in dayRegions) - Positioned( - top: layout.yOfDateTime(region.start), - height: (layout.yOfDateTime(region.end) - - layout.yOfDateTime(region.start)) - .clamp(0, double.infinity), - left: 0, - right: 0, - child: TimeRegionTile(region: region.region), - ), - for (final cell in laidOut) - Positioned( - top: layout.yOfDateTime(cell.startTime), - height: (layout.yOfDateTime(cell.endTime) - - layout.yOfDateTime(cell.startTime)) - .clamp(0, double.infinity), - left: cell.lane * width / cell.laneCount, - width: width / cell.laneCount, - child: switch (cell) { - _LaidOutAppointment(:final appointment) => GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => onAppointmentTap(appointment), - child: AppointmentTile( - appointment: appointment, - crossedOut: isCrossedOut(appointment), - ), - ), - _LaidOutOverflow(:final appointments) => GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => - _showOverflowSheet(context, appointments), - child: _OverflowTile(count: appointments.length), - ), - }, - ), - if (isToday) - ValueListenableBuilder( - valueListenable: nowNotifier, - builder: (_, now, child) => - _CurrentTimeMarker(now: now, layout: layout, theme: theme), - ), - ], - ); - }, - ), - ), - ); - } -} - -class _CurrentTimeMarker extends StatelessWidget { - final DateTime now; - final _PeriodLayout layout; - final ThemeData theme; - - const _CurrentTimeMarker({ - required this.now, - required this.layout, - required this.theme, - }); - - @override - Widget build(BuildContext context) { - final periods = layout.periods; - if (periods.isEmpty) return const SizedBox.shrink(); - final tMin = now.hour * 60 + now.minute; - final firstStart = - periods.first.start.hour * 60 + periods.first.start.minute; - final lastEnd = - periods.last.end.hour * 60 + periods.last.end.minute; - if (tMin < firstStart || tMin > lastEnd) return const SizedBox.shrink(); - - final y = layout.yOfDateTime(now); - - return AnimatedPositioned( - duration: const Duration(milliseconds: 400), - curve: Curves.easeInOut, - top: y - 1, - left: 0, - right: 0, - child: IgnorePointer( - child: Stack( - clipBehavior: Clip.none, - children: [ - Container( - height: 2, - color: theme.colorScheme.primary, - ), - Positioned( - top: -3, - left: -4, - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: theme.colorScheme.primary, - shape: BoxShape.circle, - ), - ), - ), - ], - ), - ), - ); - } -} - -class _BoundRegion { - final TimeRegion region; - final DateTime start; - final DateTime end; - - _BoundRegion({required this.region, required this.start, required this.end}); -} - -List<_BoundRegion> _expandRegionsForDay(List regions, DateTime day) { - final result = <_BoundRegion>[]; - final dayStart = DateTime(day.year, day.month, day.day); - for (final region in regions) { - final isRecurringDaily = region.recurrenceRule != null && - region.recurrenceRule!.toUpperCase().contains('FREQ=DAILY'); - if (isRecurringDaily) { - final start = dayStart.add(Duration( - hours: region.startTime.hour, - minutes: region.startTime.minute, - )); - final end = dayStart.add(Duration( - hours: region.endTime.hour, - minutes: region.endTime.minute, - )); - result.add(_BoundRegion(region: region, start: start, end: end)); - } else if (_isSameDay(region.startTime, day)) { - result.add(_BoundRegion( - region: region, - start: region.startTime, - end: region.endTime, - )); - } - } - return result; -} - -bool _isSameDay(DateTime a, DateTime b) => - a.year == b.year && a.month == b.month && a.day == b.day; - -/// Expands the given list of appointments across the visible 5-day work week -/// (resolving RRULE recurrences) and splits each day's events into two -/// buckets: those that fit within the school-hours grid (`inside`) and those -/// that don't (`outside` — all-day events and events that start before -/// [kCalendarStartHour] or end after [kCalendarEndHour]). The outside bucket -/// is rendered as chips above the grid. -({List> inside, List> outside}) - _partitionAppointmentsForWeek( - List appointments, DateTime weekStart) { - final inside = List>.generate(5, (_) => []); - final outside = List>.generate(5, (_) => []); - final weekEnd = weekStart.add(const Duration(days: 5)); - final weekStartUtc = weekStart.toUtc(); - final weekEndUtc = weekEnd.toUtc(); - - void place(int idx, Appointment a) { - if (_isOutsideSchoolHours(a)) { - outside[idx].add(a); - } else { - inside[idx].add(a); - } - } - - for (final a in appointments) { - final rule = a.recurrenceRule; - if (rule == null || rule.isEmpty) { - final idx = _dayIndex(a.startTime, weekStart); - if (idx >= 0 && idx < 5) place(idx, a); - continue; - } - try { - final parsed = RecurrenceRule.fromString(rule); - final anchorUtc = a.startTime.toUtc(); - final duration = a.endTime.difference(a.startTime); - for (final occUtc in parsed.getInstances(start: anchorUtc)) { - if (!occUtc.isBefore(weekEndUtc)) break; - if (occUtc.isBefore(weekStartUtc)) continue; - final occLocal = occUtc.toLocal(); - final idx = DateTime(occLocal.year, occLocal.month, occLocal.day) - .difference(weekStart) - .inDays; - if (idx < 0 || idx >= 5) continue; - final newStart = DateTime(occLocal.year, occLocal.month, occLocal.day, - a.startTime.hour, a.startTime.minute); - place( - idx, - Appointment( - id: a.id, - startTime: newStart, - endTime: newStart.add(duration), - subject: a.subject, - color: a.color, - location: a.location, - notes: a.notes, - isAllDay: a.isAllDay, - ), - ); - } - } catch (_) { - final idx = _dayIndex(a.startTime, weekStart); - if (idx >= 0 && idx < 5) place(idx, a); - } - } - return (inside: inside, outside: outside); -} - -int _dayIndex(DateTime t, DateTime weekStart) => - DateTime(t.year, t.month, t.day).difference(weekStart).inDays; - -/// True when the appointment doesn't fit into the school-hours grid: -/// all-day, fully before the grid start, fully after the grid end, engulfing -/// the grid entirely, or lasting 10+ hours (treated as a de-facto all-day -/// event the source system happens to represent with explicit times). -bool _isOutsideSchoolHours(Appointment a) { - if (_isAllDayLike(a)) return true; - final schoolStart = (kCalendarStartHour * 60).round(); - final schoolEnd = (kCalendarEndHour * 60).round(); - final startMin = a.startTime.hour * 60 + a.startTime.minute; - final endMin = a.endTime.hour * 60 + a.endTime.minute; - if (endMin <= schoolStart) return true; - if (startMin >= schoolEnd) return true; - if (startMin <= schoolStart && endMin >= schoolEnd) return true; - return false; -} - -/// Either explicitly marked as all-day, or so long it's effectively a full -/// day from the user's perspective. We compare in minutes (not hours) because -/// `Duration.inHours` truncates: a 9h 30min event would otherwise count as 9. -bool _isAllDayLike(Appointment a) => - a.isAllDay || a.endTime.difference(a.startTime).inMinutes >= 8 * 60; - -/// Maps lesson periods to vertical screen positions. Every non-break period -/// gets the same fixed height (`lessonHeight`), every break gets `breakHeight`. -/// Short transition gaps (Wechselzeiten) between periods are not represented -/// at all — periods are rendered back-to-back, so a 5-minute gap simply -/// disappears visually. -class _PeriodLayout { - final List periods; - final double lessonHeight; - final double breakHeight; - - const _PeriodLayout({ - required this.periods, - required this.lessonHeight, - required this.breakHeight, - }); - - double _h(LessonPeriod p) => p.isBreak ? breakHeight : lessonHeight; - - double get totalHeight => - periods.fold(0, (sum, p) => sum + _h(p)); - - double topOf(LessonPeriod period) { - var y = 0.0; - for (final p in periods) { - if (identical(p, period)) return y; - y += _h(p); - } - return y; - } - - double heightOf(LessonPeriod period) => _h(period); - - /// Vertical offset for a given time of day. Times inside a period are mapped - /// proportionally; times that fall into a transition gap are clipped to the - /// end of the preceding period. Times before the first / after the last - /// period clip to 0 / [totalHeight]. - double yOf(TimeOfDay t) { - final tMin = t.hour * 60 + t.minute; - var y = 0.0; - for (final p in periods) { - final pStart = p.start.hour * 60 + p.start.minute; - final pEnd = p.end.hour * 60 + p.end.minute; - final h = _h(p); - if (tMin < pStart) return y; - if (tMin <= pEnd) { - final span = pEnd - pStart; - final ratio = span > 0 ? (tMin - pStart) / span : 0.0; - return y + ratio * h; - } - y += h; - } - return y; - } - - double yOfDateTime(DateTime t) { - final tMin = t.hour * 60 + t.minute + t.second / 60.0; - var y = 0.0; - for (final p in periods) { - final pStart = p.start.hour * 60 + p.start.minute; - final pEnd = p.end.hour * 60 + p.end.minute; - final h = _h(p); - if (tMin < pStart) return y; - if (tMin <= pEnd) { - final span = pEnd - pStart; - final ratio = span > 0 ? (tMin - pStart) / span : 0.0; - return y + ratio * h; - } - y += h; - } - return y; - } - - /// Period at a given y-offset. If y falls into a break, returns the next - /// non-break period. Returns null when y is past the last period. - LessonPeriod? periodAtY(double y) { - var cursor = 0.0; - for (var i = 0; i < periods.length; i++) { - final p = periods[i]; - final h = _h(p); - if (y >= cursor && y < cursor + h) { - if (p.isBreak) { - for (var j = i + 1; j < periods.length; j++) { - if (!periods[j].isBreak) return periods[j]; - } - return null; - } - return p; - } - cursor += h; - } - return null; - } -} - - -class _OverflowTile extends StatelessWidget { - final int count; - const _OverflowTile({required this.count}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - const radius = BorderRadius.all(Radius.circular(7)); - - return Padding( - padding: const EdgeInsets.all(1), - child: Stack( - children: [ - // Card peeking out at the bottom — visual hint that more cards lie - // underneath the visible one. - Positioned( - top: 4, - left: 2, - right: 2, - bottom: 0, - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: radius, - color: scheme.secondaryContainer.withAlpha(120), - ), - ), - ), - // Front card with the "+N" indicator. - Positioned( - top: 0, - left: 0, - right: 0, - bottom: 4, - child: Container( - decoration: BoxDecoration( - borderRadius: radius, - color: scheme.secondaryContainer, - ), - alignment: Alignment.center, - child: FittedBox( - fit: BoxFit.scaleDown, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.unfold_more_rounded, - size: 18, - color: scheme.onSecondaryContainer, - ), - Text( - '+$count', - style: theme.textTheme.titleSmall?.copyWith( - color: scheme.onSecondaryContainer, - fontWeight: FontWeight.w700, - height: 1.0, - ), - ), - ], - ), - ), - ), - ), - ), - ], - ), - ); - } -} - -/// One cell rendered in the day column — either a regular appointment or an -/// overflow placeholder representing several hidden appointments. -sealed class _LaidOutCell { - int get lane; - int get laneCount; - DateTime get startTime; - DateTime get endTime; -} - -class _LaidOutAppointment extends _LaidOutCell { - final Appointment appointment; - @override - final int lane; - @override - final int laneCount; - _LaidOutAppointment(this.appointment, this.lane, this.laneCount); - - @override - DateTime get startTime => appointment.startTime; - @override - DateTime get endTime => appointment.endTime; -} - -class _LaidOutOverflow extends _LaidOutCell { - final List appointments; - @override - final int lane; - @override - final int laneCount; - @override - final DateTime startTime; - @override - final DateTime endTime; - _LaidOutOverflow(this.appointments, this.lane, this.laneCount, this.startTime, this.endTime); -} - -/// Horizontal ordering rank for parallel appointments. Lower = further left. -/// User-owned custom events sit on the leftmost lane, cancelled lessons after -/// them, every other lesson last. Only used as a tiebreaker — the greedy lane -/// assignment still has to honor actual time-overlap constraints, so events -/// that start later can't jump left of events that started earlier and are -/// still occupying that lane. -int _appointmentPriority(Appointment a) { - final id = a.id; - if (id is CustomAppointment) return 0; - if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 1; - return 2; -} - -/// Assigns each appointment a lane index using a greedy sweep, then collapses -/// clusters that exceed [maxLanes] into `(maxLanes - 1)` visible appointments -/// + one trailing overflow cell. -/// -/// Greedy sweep: -/// 1. Sort by `startTime` ascending, then [_appointmentPriority] (custom → -/// cancelled → other) so parallel events land in the requested left-to- -/// right order, then `endTime` descending as a final tiebreaker. -/// 2. Walk the list, placing each appointment in the lowest-index lane that -/// is free at its `startTime`. When no lane is free, open a new one. -/// 3. A cluster ends as soon as every active lane's end is at or before the -/// next appointment's start. -List<_LaidOutCell> _assignLanes(List appts, {required int maxLanes}) { - assert(maxLanes >= 2, 'maxLanes must reserve at least one slot for overflow'); - if (appts.isEmpty) return const <_LaidOutCell>[]; - - final sorted = [...appts]..sort((a, b) { - final c = a.startTime.compareTo(b.startTime); - if (c != 0) return c; - final p = _appointmentPriority(a).compareTo(_appointmentPriority(b)); - if (p != 0) return p; - return b.endTime.compareTo(a.endTime); - }); - - // Phase 1: greedy lane assignment, grouped by cluster. - final clusters = >[]; - var current = <({Appointment apt, int lane})>[]; - var laneEnds = []; - - for (final apt in sorted) { - final allFree = - laneEnds.isNotEmpty && laneEnds.every((end) => !end.isAfter(apt.startTime)); - if (allFree) { - clusters.add(current); - current = <({Appointment apt, int lane})>[]; - laneEnds = []; - } - - var laneIdx = -1; - for (var i = 0; i < laneEnds.length; i++) { - if (!laneEnds[i].isAfter(apt.startTime)) { - laneIdx = i; - break; - } - } - if (laneIdx == -1) { - laneIdx = laneEnds.length; - laneEnds.add(apt.endTime); - } else { - laneEnds[laneIdx] = apt.endTime; - } - - current.add((apt: apt, lane: laneIdx)); - } - if (current.isNotEmpty) clusters.add(current); - - // Phase 2: emit cells per cluster, collapsing if too wide. - final result = <_LaidOutCell>[]; - for (final cluster in clusters) { - final laneCount = - cluster.fold(0, (m, e) => e.lane + 1 > m ? e.lane + 1 : m); - - if (laneCount <= maxLanes) { - for (final entry in cluster) { - result.add(_LaidOutAppointment(entry.apt, entry.lane, laneCount)); - } - } else { - // Too many parallel appointments: keep the highest-priority - // (maxLanes - 1) and collapse the rest into a single overflow cell in - // the trailing lane. Sorting by priority first means custom and - // cancelled lessons stay visible when the cluster has to be trimmed, - // matching the requested left-to-right order in the visible lanes. - final visibleCount = maxLanes - 1; - final byPriority = [...cluster.map((e) => e.apt)] - ..sort((a, b) { - final p = _appointmentPriority(a).compareTo(_appointmentPriority(b)); - if (p != 0) return p; - return a.startTime.compareTo(b.startTime); - }); - - for (var i = 0; i < visibleCount; i++) { - result.add(_LaidOutAppointment(byPriority[i], i, maxLanes)); - } - - final overflow = byPriority.sublist(visibleCount); - var earliest = overflow.first.startTime; - var latest = overflow.first.endTime; - for (final a in overflow.skip(1)) { - if (a.startTime.isBefore(earliest)) earliest = a.startTime; - if (a.endTime.isAfter(latest)) latest = a.endTime; - } - result.add(_LaidOutOverflow( - overflow, maxLanes - 1, maxLanes, earliest, latest)); - } - } - return result; -} diff --git a/lib/widget/async_action_button.dart b/lib/widget/async_action_button.dart index 1044e82..96ef4eb 100644 --- a/lib/widget/async_action_button.dart +++ b/lib/widget/async_action_button.dart @@ -1,543 +1,20 @@ +/// Family of async-aware buttons + helpers. Implementation is split across +/// `async_actions/` for readability; everything still lives in this single +/// library so private widgets like `_AsyncMixin` and `_InlineErrorWrapper` +/// can stay private and shared. +library; + import 'package:flutter/material.dart'; import '../api/errors/error_mapper.dart'; import 'app_progress_indicator.dart'; import 'info_dialog.dart'; -Future runWithErrorDialog( - BuildContext context, - AsyncActionCallback action, { - AsyncErrorBuilder? errorBuilder, -}) async { - try { - await action(); - return true; - } catch (e) { - if (!context.mounted) return false; - final message = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e); - final details = errorToTechnicalDetails(e); - final body = details != null && details != message ? '$message\n\n$details' : message; - InfoDialog.show(context, body, copyable: true, title: 'Fehler'); - return false; - } -} - -typedef AsyncActionCallback = Future Function(); -typedef AsyncErrorBuilder = String Function(Object error); - -class AsyncActionController extends ChangeNotifier { - bool _busy = false; - String? _error; - - bool get busy => _busy; - String? get error => _error; - - Future run( - AsyncActionCallback action, { - AsyncErrorBuilder? errorBuilder, - }) async { - if (_busy) return false; - _busy = true; - _error = null; - notifyListeners(); - try { - await action(); - _busy = false; - notifyListeners(); - return true; - } catch (e) { - _busy = false; - _error = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e); - notifyListeners(); - return false; - } - } - - void clearError() { - if (_error == null) return; - _error = null; - notifyListeners(); - } -} - -class _AsyncMixin extends StatefulWidget { - final AsyncActionCallback? onPressed; - final AsyncActionController? controller; - final AsyncErrorBuilder? errorBuilder; - final void Function(String message)? onError; - final VoidCallback? onSuccess; - final Widget Function(BuildContext context, bool busy, VoidCallback? handler) builder; - - const _AsyncMixin({ - required this.onPressed, - required this.builder, - this.controller, - this.errorBuilder, - this.onError, - this.onSuccess, - }); - - @override - State<_AsyncMixin> createState() => _AsyncMixinState(); -} - -class _AsyncMixinState extends State<_AsyncMixin> { - late final AsyncActionController _internal; - AsyncActionController get _controller => widget.controller ?? _internal; - - @override - void initState() { - super.initState(); - if (widget.controller == null) { - _internal = AsyncActionController(); - } - _controller.addListener(_onControllerChange); - } - - @override - void didUpdateWidget(covariant _AsyncMixin oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - (oldWidget.controller ?? _internal).removeListener(_onControllerChange); - _controller.addListener(_onControllerChange); - } - } - - @override - void dispose() { - _controller.removeListener(_onControllerChange); - if (widget.controller == null) { - _internal.dispose(); - } - super.dispose(); - } - - void _onControllerChange() { - if (mounted) setState(() {}); - } - - Future _trigger() async { - final action = widget.onPressed; - if (action == null) return; - final success = await _controller.run(action, errorBuilder: widget.errorBuilder); - if (!mounted) return; - if (success) { - widget.onSuccess?.call(); - } else if (widget.onError != null && _controller.error != null) { - widget.onError!(_controller.error!); - } - } - - @override - Widget build(BuildContext context) { - final handler = widget.onPressed == null ? null : _trigger; - return widget.builder(context, _controller.busy, _controller.busy ? null : handler); - } -} - -class AsyncActionButton extends StatelessWidget { - final AsyncActionCallback? onPressed; - final Widget child; - final IconData? icon; - final ButtonStyle? style; - final AsyncActionController? controller; - final AsyncErrorBuilder? errorBuilder; - final void Function(String message)? onError; - final VoidCallback? onSuccess; - final bool showInlineError; - - const AsyncActionButton({ - required this.onPressed, - required this.child, - this.icon, - this.style, - this.controller, - this.errorBuilder, - this.onError, - this.onSuccess, - this.showInlineError = true, - super.key, - }); - - @override - Widget build(BuildContext context) => _AsyncMixin( - onPressed: onPressed, - controller: controller, - errorBuilder: errorBuilder, - onError: onError, - onSuccess: onSuccess, - builder: (context, busy, handler) { - final spinner = AppProgressIndicator.small( - color: Theme.of(context).colorScheme.onPrimary, - ); - final content = busy - ? Row( - mainAxisSize: MainAxisSize.min, - children: [spinner, const SizedBox(width: 8), child], - ) - : (icon != null - ? Row( - mainAxisSize: MainAxisSize.min, - children: [Icon(icon), const SizedBox(width: 8), child], - ) - : child); - final button = ElevatedButton( - onPressed: handler, - style: style, - child: content, - ); - return _withInlineError(context, button); - }, - ); - - Widget _withInlineError(BuildContext context, Widget button) { - if (!showInlineError) return button; - return _InlineErrorWrapper(controller: controller, child: button); - } -} - -class AsyncTextButton extends StatelessWidget { - final AsyncActionCallback? onPressed; - final Widget child; - final AsyncActionController? controller; - final AsyncErrorBuilder? errorBuilder; - final void Function(String message)? onError; - final VoidCallback? onSuccess; - final bool showInlineError; - - const AsyncTextButton({ - required this.onPressed, - required this.child, - this.controller, - this.errorBuilder, - this.onError, - this.onSuccess, - this.showInlineError = true, - super.key, - }); - - @override - Widget build(BuildContext context) => _AsyncMixin( - onPressed: onPressed, - controller: controller, - errorBuilder: errorBuilder, - onError: onError, - onSuccess: onSuccess, - builder: (context, busy, handler) { - final content = busy - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - AppProgressIndicator.small( - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - child, - ], - ) - : child; - return _InlineErrorWrapper( - controller: controller, - child: TextButton(onPressed: handler, child: content), - ); - }, - ); -} - -class AsyncIconButton extends StatelessWidget { - final AsyncActionCallback? onPressed; - final IconData icon; - final Color? color; - final String? tooltip; - final AsyncActionController? controller; - final AsyncErrorBuilder? errorBuilder; - final void Function(String message)? onError; - final VoidCallback? onSuccess; - - const AsyncIconButton({ - required this.onPressed, - required this.icon, - this.color, - this.tooltip, - this.controller, - this.errorBuilder, - this.onError, - this.onSuccess, - super.key, - }); - - @override - Widget build(BuildContext context) => _AsyncMixin( - onPressed: onPressed, - controller: controller, - errorBuilder: errorBuilder, - onError: onError, - onSuccess: onSuccess, - builder: (context, busy, handler) { - if (busy) { - return Padding( - padding: const EdgeInsets.all(12), - child: AppProgressIndicator.small(color: color), - ); - } - return IconButton( - icon: Icon(icon, color: color), - tooltip: tooltip, - onPressed: handler, - ); - }, - ); -} - -class AsyncFab extends StatelessWidget { - final AsyncActionCallback? onPressed; - final IconData icon; - final Color? backgroundColor; - final Color? foregroundColor; - final Object? heroTag; - final AsyncActionController? controller; - final AsyncErrorBuilder? errorBuilder; - final void Function(String message)? onError; - final VoidCallback? onSuccess; - final bool mini; - - const AsyncFab({ - required this.onPressed, - required this.icon, - this.backgroundColor, - this.foregroundColor, - this.heroTag, - this.controller, - this.errorBuilder, - this.onError, - this.onSuccess, - this.mini = false, - super.key, - }); - - @override - Widget build(BuildContext context) => _AsyncMixin( - onPressed: onPressed, - controller: controller, - errorBuilder: errorBuilder, - onError: onError, - onSuccess: onSuccess, - builder: (context, busy, handler) { - final fg = foregroundColor ?? Theme.of(context).colorScheme.onPrimary; - return FloatingActionButton( - heroTag: heroTag, - backgroundColor: backgroundColor, - foregroundColor: fg, - mini: mini, - onPressed: handler, - child: busy ? AppProgressIndicator.small(color: fg) : Icon(icon), - ); - }, - ); -} - -class AsyncListTile extends StatefulWidget { - final AsyncActionCallback onPressed; - final Widget? leading; - final Widget title; - final Widget? subtitle; - final bool closeOnSuccess; - final VoidCallback? onSuccess; - final AsyncErrorBuilder? errorBuilder; - final bool enabled; - - const AsyncListTile({ - required this.onPressed, - required this.title, - this.leading, - this.subtitle, - this.closeOnSuccess = true, - this.onSuccess, - this.errorBuilder, - this.enabled = true, - super.key, - }); - - @override - State createState() => _AsyncListTileState(); -} - -class _AsyncListTileState extends State { - final AsyncActionController _controller = AsyncActionController(); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - Future _handleTap() async { - final ok = await _controller.run(widget.onPressed, errorBuilder: widget.errorBuilder); - if (!mounted) return; - if (ok) { - widget.onSuccess?.call(); - if (widget.closeOnSuccess && Navigator.of(context).canPop()) { - Navigator.of(context).pop(); - } - } - } - - @override - Widget build(BuildContext context) => AnimatedBuilder( - animation: _controller, - builder: (context, _) { - final busy = _controller.busy; - final err = _controller.error; - final leading = busy - ? const SizedBox( - width: 24, - height: 24, - child: AppProgressIndicator.small(), - ) - : widget.leading; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ListTile( - leading: leading, - title: widget.title, - subtitle: widget.subtitle, - enabled: widget.enabled && !busy, - onTap: busy ? null : _handleTap, - ), - if (err != null) - Padding( - padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), - child: Text( - err, - style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13), - ), - ), - ], - ); - }, - ); -} - -class _InlineErrorWrapper extends StatelessWidget { - final AsyncActionController? controller; - final Widget child; - const _InlineErrorWrapper({required this.controller, required this.child}); - - @override - Widget build(BuildContext context) { - final c = controller; - if (c == null) return child; - return AnimatedBuilder( - animation: c, - builder: (context, _) { - final err = c.error; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - child, - if (err != null) ...[ - const SizedBox(height: 8), - Text( - err, - textAlign: TextAlign.center, - style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13), - ), - ], - ], - ); - }, - ); - } -} - -class AsyncDialogAction extends StatefulWidget { - final String confirmLabel; - final AsyncActionCallback onConfirm; - final String? cancelLabel; - final AsyncErrorBuilder? errorBuilder; - final ButtonStyle? confirmStyle; - - const AsyncDialogAction({ - required this.confirmLabel, - required this.onConfirm, - this.cancelLabel = 'Abbrechen', - this.errorBuilder, - this.confirmStyle, - super.key, - }); - - @override - State createState() => _AsyncDialogActionState(); -} - -class _AsyncDialogActionState extends State { - final AsyncActionController _controller = AsyncActionController(); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => AnimatedBuilder( - animation: _controller, - builder: (context, _) { - final err = _controller.error; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (err != null) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - err, - textAlign: TextAlign.center, - style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13), - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (widget.cancelLabel != null) - TextButton( - onPressed: _controller.busy ? null : () => Navigator.of(context).pop(), - child: Text(widget.cancelLabel!), - ), - TextButton( - style: widget.confirmStyle, - onPressed: _controller.busy - ? null - : () async { - final ok = await _controller.run( - widget.onConfirm, - errorBuilder: widget.errorBuilder, - ); - if (ok && context.mounted) { - Navigator.of(context).pop(true); - } - }, - child: _controller.busy - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - AppProgressIndicator.small( - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Text(widget.confirmLabel), - ], - ) - : Text(widget.confirmLabel), - ), - ], - ), - ], - ); - }, - ); -} +part 'async_actions/async_action_controller.dart'; +part 'async_actions/async_action_button.dart'; +part 'async_actions/async_dialog_action.dart'; +part 'async_actions/async_fab.dart'; +part 'async_actions/async_icon_button.dart'; +part 'async_actions/async_list_tile.dart'; +part 'async_actions/async_mixin.dart'; +part 'async_actions/async_text_button.dart'; diff --git a/lib/widget/async_actions/async_action_button.dart b/lib/widget/async_actions/async_action_button.dart new file mode 100644 index 0000000..29367b2 --- /dev/null +++ b/lib/widget/async_actions/async_action_button.dart @@ -0,0 +1,58 @@ +part of '../async_action_button.dart'; + +class AsyncActionButton extends StatelessWidget { + final AsyncActionCallback? onPressed; + final Widget child; + final IconData? icon; + final ButtonStyle? style; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + final bool showInlineError; + + const AsyncActionButton({ + required this.onPressed, + required this.child, + this.icon, + this.style, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + this.showInlineError = true, + super.key, + }); + + @override + Widget build(BuildContext context) => _AsyncMixin( + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + final spinner = AppProgressIndicator.small( + color: Theme.of(context).colorScheme.onPrimary, + ); + final content = busy + ? Row( + mainAxisSize: MainAxisSize.min, + children: [spinner, const SizedBox(width: 8), child], + ) + : (icon != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [Icon(icon), const SizedBox(width: 8), child], + ) + : child); + final button = ElevatedButton( + onPressed: handler, + style: style, + child: content, + ); + if (!showInlineError) return button; + return _InlineErrorWrapper(controller: controller, child: button); + }, + ); +} diff --git a/lib/widget/async_actions/async_action_controller.dart b/lib/widget/async_actions/async_action_controller.dart new file mode 100644 index 0000000..8b63cbe --- /dev/null +++ b/lib/widget/async_actions/async_action_controller.dart @@ -0,0 +1,63 @@ +part of '../async_action_button.dart'; + +typedef AsyncActionCallback = Future Function(); +typedef AsyncErrorBuilder = String Function(Object error); + +/// 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. +Future runWithErrorDialog( + BuildContext context, + AsyncActionCallback action, { + AsyncErrorBuilder? errorBuilder, +}) async { + try { + await action(); + return true; + } catch (e) { + if (!context.mounted) return false; + final message = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e); + final details = errorToTechnicalDetails(e); + final body = details != null && details != message ? '$message\n\n$details' : message; + InfoDialog.show(context, body, copyable: true, title: 'Fehler'); + return false; + } +} + +/// Reusable busy/error state for the async-button family. Multiple buttons +/// can share the same controller (e.g. a parent toolbar wanting to disable +/// while any one child is running). +class AsyncActionController extends ChangeNotifier { + bool _busy = false; + String? _error; + + bool get busy => _busy; + String? get error => _error; + + Future run( + AsyncActionCallback action, { + AsyncErrorBuilder? errorBuilder, + }) async { + if (_busy) return false; + _busy = true; + _error = null; + notifyListeners(); + try { + await action(); + _busy = false; + notifyListeners(); + return true; + } catch (e) { + _busy = false; + _error = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e); + notifyListeners(); + return false; + } + } + + void clearError() { + if (_error == null) return; + _error = null; + notifyListeners(); + } +} diff --git a/lib/widget/async_actions/async_dialog_action.dart b/lib/widget/async_actions/async_dialog_action.dart new file mode 100644 index 0000000..24309c2 --- /dev/null +++ b/lib/widget/async_actions/async_dialog_action.dart @@ -0,0 +1,90 @@ +part of '../async_action_button.dart'; + +class AsyncDialogAction extends StatefulWidget { + final String confirmLabel; + final AsyncActionCallback onConfirm; + final String? cancelLabel; + final AsyncErrorBuilder? errorBuilder; + final ButtonStyle? confirmStyle; + + const AsyncDialogAction({ + required this.confirmLabel, + required this.onConfirm, + this.cancelLabel = 'Abbrechen', + this.errorBuilder, + this.confirmStyle, + super.key, + }); + + @override + State createState() => _AsyncDialogActionState(); +} + +class _AsyncDialogActionState extends State { + final AsyncActionController _controller = AsyncActionController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final err = _controller.error; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (err != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + err, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (widget.cancelLabel != null) + TextButton( + onPressed: _controller.busy ? null : () => Navigator.of(context).pop(), + child: Text(widget.cancelLabel!), + ), + TextButton( + style: widget.confirmStyle, + onPressed: _controller.busy + ? null + : () async { + final ok = await _controller.run( + widget.onConfirm, + errorBuilder: widget.errorBuilder, + ); + if (ok && context.mounted) { + Navigator.of(context).pop(true); + } + }, + child: _controller.busy + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppProgressIndicator.small( + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text(widget.confirmLabel), + ], + ) + : Text(widget.confirmLabel), + ), + ], + ), + ], + ); + }, + ); +} diff --git a/lib/widget/async_actions/async_fab.dart b/lib/widget/async_actions/async_fab.dart new file mode 100644 index 0000000..04eff65 --- /dev/null +++ b/lib/widget/async_actions/async_fab.dart @@ -0,0 +1,48 @@ +part of '../async_action_button.dart'; + +class AsyncFab extends StatelessWidget { + final AsyncActionCallback? onPressed; + final IconData icon; + final Color? backgroundColor; + final Color? foregroundColor; + final Object? heroTag; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + final bool mini; + + const AsyncFab({ + required this.onPressed, + required this.icon, + this.backgroundColor, + this.foregroundColor, + this.heroTag, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + this.mini = false, + super.key, + }); + + @override + Widget build(BuildContext context) => _AsyncMixin( + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + final fg = foregroundColor ?? Theme.of(context).colorScheme.onPrimary; + return FloatingActionButton( + heroTag: heroTag, + backgroundColor: backgroundColor, + foregroundColor: fg, + mini: mini, + onPressed: handler, + child: busy ? AppProgressIndicator.small(color: fg) : Icon(icon), + ); + }, + ); +} diff --git a/lib/widget/async_actions/async_icon_button.dart b/lib/widget/async_actions/async_icon_button.dart new file mode 100644 index 0000000..de9cea4 --- /dev/null +++ b/lib/widget/async_actions/async_icon_button.dart @@ -0,0 +1,46 @@ +part of '../async_action_button.dart'; + +class AsyncIconButton extends StatelessWidget { + final AsyncActionCallback? onPressed; + final IconData icon; + final Color? color; + final String? tooltip; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + + const AsyncIconButton({ + required this.onPressed, + required this.icon, + this.color, + this.tooltip, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + super.key, + }); + + @override + Widget build(BuildContext context) => _AsyncMixin( + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + if (busy) { + return Padding( + padding: const EdgeInsets.all(12), + child: AppProgressIndicator.small(color: color), + ); + } + return IconButton( + icon: Icon(icon, color: color), + tooltip: tooltip, + onPressed: handler, + ); + }, + ); +} diff --git a/lib/widget/async_actions/async_list_tile.dart b/lib/widget/async_actions/async_list_tile.dart new file mode 100644 index 0000000..526ee25 --- /dev/null +++ b/lib/widget/async_actions/async_list_tile.dart @@ -0,0 +1,85 @@ +part of '../async_action_button.dart'; + +class AsyncListTile extends StatefulWidget { + final AsyncActionCallback onPressed; + final Widget? leading; + final Widget title; + final Widget? subtitle; + final bool closeOnSuccess; + final VoidCallback? onSuccess; + final AsyncErrorBuilder? errorBuilder; + final bool enabled; + + const AsyncListTile({ + required this.onPressed, + required this.title, + this.leading, + this.subtitle, + this.closeOnSuccess = true, + this.onSuccess, + this.errorBuilder, + this.enabled = true, + super.key, + }); + + @override + State createState() => _AsyncListTileState(); +} + +class _AsyncListTileState extends State { + final AsyncActionController _controller = AsyncActionController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _handleTap() async { + final ok = await _controller.run(widget.onPressed, errorBuilder: widget.errorBuilder); + if (!mounted) return; + if (ok) { + widget.onSuccess?.call(); + if (widget.closeOnSuccess && Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + } + } + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final busy = _controller.busy; + final err = _controller.error; + final leading = busy + ? const SizedBox( + width: 24, + height: 24, + child: AppProgressIndicator.small(), + ) + : widget.leading; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + leading: leading, + title: widget.title, + subtitle: widget.subtitle, + enabled: widget.enabled && !busy, + onTap: busy ? null : _handleTap, + ), + if (err != null) + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), + child: Text( + err, + style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13), + ), + ), + ], + ); + }, + ); +} diff --git a/lib/widget/async_actions/async_mixin.dart b/lib/widget/async_actions/async_mixin.dart new file mode 100644 index 0000000..72ff984 --- /dev/null +++ b/lib/widget/async_actions/async_mixin.dart @@ -0,0 +1,109 @@ +part of '../async_action_button.dart'; + +class _AsyncMixin extends StatefulWidget { + final AsyncActionCallback? onPressed; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + final Widget Function(BuildContext context, bool busy, VoidCallback? handler) builder; + + const _AsyncMixin({ + required this.onPressed, + required this.builder, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + }); + + @override + State<_AsyncMixin> createState() => _AsyncMixinState(); +} + +class _AsyncMixinState extends State<_AsyncMixin> { + late final AsyncActionController _internal; + AsyncActionController get _controller => widget.controller ?? _internal; + + @override + void initState() { + super.initState(); + if (widget.controller == null) { + _internal = AsyncActionController(); + } + _controller.addListener(_onControllerChange); + } + + @override + void didUpdateWidget(covariant _AsyncMixin oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + (oldWidget.controller ?? _internal).removeListener(_onControllerChange); + _controller.addListener(_onControllerChange); + } + } + + @override + void dispose() { + _controller.removeListener(_onControllerChange); + if (widget.controller == null) { + _internal.dispose(); + } + super.dispose(); + } + + void _onControllerChange() { + if (mounted) setState(() {}); + } + + Future _trigger() async { + final action = widget.onPressed; + if (action == null) return; + final success = await _controller.run(action, errorBuilder: widget.errorBuilder); + if (!mounted) return; + if (success) { + widget.onSuccess?.call(); + } else if (widget.onError != null && _controller.error != null) { + widget.onError!(_controller.error!); + } + } + + @override + Widget build(BuildContext context) { + final handler = widget.onPressed == null ? null : _trigger; + return widget.builder(context, _controller.busy, _controller.busy ? null : handler); + } +} + +class _InlineErrorWrapper extends StatelessWidget { + final AsyncActionController? controller; + final Widget child; + const _InlineErrorWrapper({required this.controller, required this.child}); + + @override + Widget build(BuildContext context) { + final c = controller; + if (c == null) return child; + return AnimatedBuilder( + animation: c, + builder: (context, _) { + final err = c.error; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + child, + if (err != null) ...[ + const SizedBox(height: 8), + Text( + err, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13), + ), + ], + ], + ); + }, + ); + } +} diff --git a/lib/widget/async_actions/async_text_button.dart b/lib/widget/async_actions/async_text_button.dart new file mode 100644 index 0000000..2938089 --- /dev/null +++ b/lib/widget/async_actions/async_text_button.dart @@ -0,0 +1,49 @@ +part of '../async_action_button.dart'; + +class AsyncTextButton extends StatelessWidget { + final AsyncActionCallback? onPressed; + final Widget child; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + final bool showInlineError; + + const AsyncTextButton({ + required this.onPressed, + required this.child, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + this.showInlineError = true, + super.key, + }); + + @override + Widget build(BuildContext context) => _AsyncMixin( + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + final content = busy + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppProgressIndicator.small( + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + child, + ], + ) + : child; + return _InlineErrorWrapper( + controller: controller, + child: TextButton(onPressed: handler, child: content), + ); + }, + ); +} diff --git a/lib/widget/debug/json_viewer.dart b/lib/widget/debug/json_viewer.dart index 71f85ee..461a15e 100644 --- a/lib/widget/debug/json_viewer.dart +++ b/lib/widget/debug/json_viewer.dart @@ -1,7 +1,8 @@ import 'dart:convert'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; + +import '../../utils/clipboard_helper.dart'; class JsonViewer extends StatelessWidget { final String title; @@ -19,30 +20,26 @@ class JsonViewer extends StatelessWidget { child: Text(format(data)), ), ); - + static final _encoder = const JsonEncoder.withIndent(' '); static String format(Map jsonInput) => _encoder.convert(jsonInput); static void asDialog(BuildContext context, Map dataMap) { - showDialog(context: context, builder: (context) => AlertDialog( + showDialog(context: context, builder: (dialogCtx) => AlertDialog( scrollable: true, title: const Row(children: [Icon(Icons.bug_report_outlined), Text('Rohdaten')]), content: Text(JsonViewer.format(dataMap)), actions: [ - TextButton(onPressed: () { - Clipboard.setData(ClipboardData(text: JsonViewer.format(dataMap))).then((value) { - if (!context.mounted) return; - showDialog(context: context, builder: (context) => const AlertDialog(content: Text('Formatiertes JSON wurde erfolgreich in deiner Zwischenlage abgelegt.'))); - }); - }, child: const Text('Kopieren')), - TextButton(onPressed: () { - Clipboard.setData(ClipboardData(text: dataMap.toString())).then((value) { - if (!context.mounted) return; - showDialog(context: context, builder: (context) => const AlertDialog(content: Text('Unformatiertes JSON wurde erfolgreich in deiner Zwischenablage abgelegt.'))); - }); - }, child: const Text('Inline Kopieren')), - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Schließen')) + TextButton( + onPressed: () => copyToClipboard(dialogCtx, JsonViewer.format(dataMap), successMessage: 'Formatiertes JSON kopiert'), + child: const Text('Kopieren'), + ), + TextButton( + onPressed: () => copyToClipboard(dialogCtx, dataMap.toString(), successMessage: 'Inline JSON kopiert'), + child: const Text('Inline Kopieren'), + ), + TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Schließen')) ], )); } diff --git a/lib/view/pages/timetable/details/bottom_sheet.dart b/lib/widget/details_bottom_sheet.dart similarity index 63% rename from lib/view/pages/timetable/details/bottom_sheet.dart rename to lib/widget/details_bottom_sheet.dart index c0066b1..0618f40 100644 --- a/lib/view/pages/timetable/details/bottom_sheet.dart +++ b/lib/widget/details_bottom_sheet.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; -/// Shows a modal bottom sheet for an appointment, matching the design of the -/// other sheets in the app (file details, file actions, overflow lessons): -/// drag handle on top, default theme background, ListTile-style header +/// Shows a modal bottom sheet for a detail view (appointment, file, lesson, +/// custom event, etc.). All detail sheets in the app share this layout: drag +/// handle on top, default theme background, optional ListTile-style header /// followed by a divider, scrollable body below. -void showAppointmentBottomSheet( +void showDetailsBottomSheet( BuildContext context, { - required Widget header, + Widget? header, required List Function(BuildContext sheetContext) children, }) { showModalBottomSheet( @@ -21,8 +21,10 @@ void showAppointmentBottomSheet( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - header, - const Divider(height: 1), + if (header != null) ...[ + header, + const Divider(height: 1), + ], ...children(sheetContext), ], ), diff --git a/lib/widget/info_dialog.dart b/lib/widget/info_dialog.dart index 59be35c..22c4624 100644 --- a/lib/widget/info_dialog.dart +++ b/lib/widget/info_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; + +import '../utils/clipboard_helper.dart'; class InfoDialog { /// Shows a single-text dialog. When [copyable] is true (default for error @@ -26,16 +27,7 @@ class InfoDialog { actions: [ if (copyable) TextButton.icon( - onPressed: () async { - await Clipboard.setData(ClipboardData(text: info)); - if (!dialogContext.mounted) return; - ScaffoldMessenger.of(dialogContext).showSnackBar( - const SnackBar( - content: Text('In Zwischenablage kopiert'), - duration: Duration(seconds: 2), - ), - ); - }, + onPressed: () => copyToClipboard(dialogContext, info), icon: const Icon(Icons.copy_outlined, size: 18), label: const Text('Kopieren'), ), diff --git a/pubspec.yaml b/pubspec.yaml index 92eb3e2..e6cd8c4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,6 +71,10 @@ dependencies: enough_icalendar: ^0.17.0 dev_dependencies: + flutter_test: + sdk: flutter + fake_async: ^1.3.1 + flutter_launcher_icons: ^0.14.3 flutter_native_splash: ^2.4.4 diff --git a/test/api/errors/error_mapper_test.dart b/test/api/errors/error_mapper_test.dart new file mode 100644 index 0000000..2f8bab8 --- /dev/null +++ b/test/api/errors/error_mapper_test.dart @@ -0,0 +1,105 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:marianum_mobile/api/api_error.dart'; +import 'package:marianum_mobile/api/errors/auth_exception.dart'; +import 'package:marianum_mobile/api/errors/error_mapper.dart'; +import 'package:marianum_mobile/api/errors/network_exception.dart'; +import 'package:marianum_mobile/api/errors/parse_exception.dart'; + +void main() { + group('errorToUserMessage', () { + test('null falls back to the default message', () { + expect(errorToUserMessage(null), contains('Etwas ist schiefgelaufen')); + }); + + test('AppException returns its own userMessage', () { + final exception = AuthException.unauthorized(); + expect(errorToUserMessage(exception), exception.userMessage); + }); + + test('SocketException maps to NetworkException message', () { + expect(errorToUserMessage(const SocketException('boom')), + const NetworkException().userMessage); + }); + + test('TimeoutException maps to the timeout-specific NetworkException message', () { + expect(errorToUserMessage(TimeoutException('slow')), + NetworkException.timeout().userMessage); + }); + + test('http.ClientException maps to NetworkException message', () { + expect(errorToUserMessage(http.ClientException('failed')), + const NetworkException().userMessage); + }); + + test('HandshakeException maps to a TLS-specific message', () { + expect(errorToUserMessage(const HandshakeException('bad cert')), + 'Sichere Verbindung konnte nicht hergestellt werden.'); + }); + + test('FormatException maps to ParseException message', () { + expect(errorToUserMessage(const FormatException('bad json')), + const ParseException().userMessage); + }); + + test('ApiError surfaces only the first line of its message', () { + final err = ApiError('Boom\nGET https://example.com/foo'); + expect(errorToUserMessage(err), 'Boom'); + }); + + test('ApiError with empty message falls back to default', () { + final err = ApiError(''); + expect(errorToUserMessage(err), contains('Etwas ist schiefgelaufen')); + }); + + test('unknown error type falls back', () { + expect(errorToUserMessage(StateError('weird')), + contains('Etwas ist schiefgelaufen')); + }); + + test('custom fallback overrides the default', () { + expect(errorToUserMessage(null, fallback: 'meins'), 'meins'); + }); + }); + + group('errorToTechnicalDetails', () { + test('null returns null', () { + expect(errorToTechnicalDetails(null), isNull); + }); + + test('AppException uses its technicalDetails when set', () { + final ex = AuthException.unauthorized(technicalDetails: 'http 401, foo'); + expect(errorToTechnicalDetails(ex), 'http 401, foo'); + }); + + test('AppException without details falls back to toString()', () { + final ex = AuthException.unauthorized(); + expect(errorToTechnicalDetails(ex), ex.toString()); + }); + + test('arbitrary object stringifies', () { + expect(errorToTechnicalDetails(StateError('x')), contains('x')); + }); + }); + + group('errorAllowsRetry', () { + test('null allows retry by default', () { + expect(errorAllowsRetry(null), isTrue); + }); + + test('AuthException disallows retry (allowRetry=false)', () { + expect(errorAllowsRetry(AuthException.unauthorized()), isFalse); + }); + + test('NetworkException allows retry (allowRetry=true)', () { + expect(errorAllowsRetry(const NetworkException()), isTrue); + }); + + test('non-AppException allows retry by default', () { + expect(errorAllowsRetry(StateError('x')), isTrue); + }); + }); +} diff --git a/test/api/talk/rich_object_string_processor_test.dart b/test/api/talk/rich_object_string_processor_test.dart new file mode 100644 index 0000000..8b346d2 --- /dev/null +++ b/test/api/talk/rich_object_string_processor_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/chat/get_chat_response.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/chat/rich_object_string_processor.dart'; + +RichObjectString _r(String name, {RichObjectStringObjectType type = RichObjectStringObjectType.user}) => + RichObjectString(type, 'id-$name', name, null, null); + +void main() { + group('RichObjectStringProcessor.parseToString', () { + test('null data returns the message unchanged', () { + expect( + RichObjectStringProcessor.parseToString('Hallo {actor}', null), + 'Hallo {actor}', + ); + }); + + test('substitutes a single placeholder by .name', () { + expect( + RichObjectStringProcessor.parseToString( + '{actor} hat eine Datei geteilt', + {'actor': _r('Elias')}, + ), + 'Elias hat eine Datei geteilt', + ); + }); + + test('substitutes multiple placeholders independently', () { + expect( + RichObjectStringProcessor.parseToString( + '{actor} hat {file} mit {target} geteilt', + { + 'actor': _r('Elias'), + 'file': _r('foo.pdf', type: RichObjectStringObjectType.file), + 'target': _r('Klasse 11a', type: RichObjectStringObjectType.group), + }, + ), + 'Elias hat foo.pdf mit Klasse 11a geteilt', + ); + }); + + test('replaces every occurrence of the same placeholder', () { + expect( + RichObjectStringProcessor.parseToString( + '{actor} {actor} {actor}', + {'actor': _r('A')}, + ), + 'A A A', + ); + }); + + test('placeholders with no matching key remain unchanged', () { + expect( + RichObjectStringProcessor.parseToString( + '{actor} sah {file}', + {'actor': _r('Elias')}, + ), + 'Elias sah {file}', + ); + }); + + test('empty data map returns the message unchanged', () { + expect( + RichObjectStringProcessor.parseToString('Hallo {actor}', const {}), + 'Hallo {actor}', + ); + }); + + test('messages without placeholders are returned verbatim', () { + expect( + RichObjectStringProcessor.parseToString('reine Textnachricht', + {'actor': _r('A')}), + 'reine Textnachricht', + ); + }); + }); +} diff --git a/test/api/webuntis/lesson_resolver_test.dart b/test/api/webuntis/lesson_resolver_test.dart new file mode 100644 index 0000000..4c900c7 --- /dev/null +++ b/test/api/webuntis/lesson_resolver_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/webuntis/queries/get_rooms/get_rooms_response.dart'; +import 'package:marianum_mobile/api/webuntis/queries/get_subjects/get_subjects_response.dart'; +import 'package:marianum_mobile/api/webuntis/services/lesson_resolver.dart'; +import 'package:marianum_mobile/state/app/modules/timetable/bloc/timetable_state.dart'; + +TimetableState _state({ + Set subjects = const {}, + Set rooms = const {}, +}) => + TimetableState( + subjects: subjects.isEmpty ? null : GetSubjectsResponse(subjects), + rooms: rooms.isEmpty ? null : GetRoomsResponse(rooms), + startDate: DateTime(2026, 1, 1), + endDate: DateTime(2026, 12, 31), + ); + +void main() { + group('LessonResolver.resolveSubject', () { + test('returns the matching subject when the id is found', () { + final math = GetSubjectsResponseObject(7, 'M', 'Mathe', 'Ma', true); + final state = _state(subjects: {math}); + + final result = LessonResolver.resolveSubject(state, 7); + expect(result.id, 7); + expect(result.name, 'M'); + expect(result.longName, 'Mathe'); + }); + + test('returns the placeholder fallback when id is null', () { + final state = _state(subjects: const {}); + final result = LessonResolver.resolveSubject(state, null); + expect(result.id, 0); + expect(result.name, '?'); + expect(result.longName, 'Unbekannt'); + }); + + test('returns the placeholder fallback when id is unknown', () { + final math = GetSubjectsResponseObject(7, 'M', 'Mathe', 'Ma', true); + final state = _state(subjects: {math}); + + final result = LessonResolver.resolveSubject(state, 999); + expect(result.id, 0); + expect(result.longName, 'Unbekannt'); + }); + }); + + group('LessonResolver.resolveRoom', () { + test('returns the matching room when the id is found', () { + final room = GetRoomsResponseObject(3, 'A1', 'Aula 1', true, 'Hauptgebäude'); + final state = _state(rooms: {room}); + + final result = LessonResolver.resolveRoom(state, 3); + expect(result.id, 3); + expect(result.name, 'A1'); + expect(result.building, 'Hauptgebäude'); + }); + + test('returns the placeholder fallback when id is unknown', () { + final state = _state(rooms: const {}); + final result = LessonResolver.resolveRoom(state, 42); + expect(result.id, 0); + expect(result.name, '?'); + }); + }); + + group('LessonFormatter', () { + test('iconForCode picks the right icon per status', () { + expect(LessonFormatter.iconForCode('cancelled').codePoint, + isNot(LessonFormatter.iconForCode('irregular').codePoint)); + expect(LessonFormatter.iconForCode(null).codePoint, + isNot(LessonFormatter.iconForCode('cancelled').codePoint)); + }); + + test('statusLabel maps known codes to German labels', () { + expect(LessonFormatter.statusLabel(null), 'Regulär'); + expect(LessonFormatter.statusLabel(''), 'Regulär'); + expect(LessonFormatter.statusLabel('cancelled'), 'Entfällt'); + expect(LessonFormatter.statusLabel('irregular'), 'Geändert'); + expect(LessonFormatter.statusLabel('something-else'), 'something-else'); + }); + + test('codePrefix prepends a label for known codes', () { + expect(LessonFormatter.codePrefix('cancelled'), 'Entfällt: '); + expect(LessonFormatter.codePrefix('irregular'), 'Änderung: '); + expect(LessonFormatter.codePrefix(null), ''); + }); + + test('formatLine renders name + (longname) + · extra in that order', () { + expect( + LessonFormatter.formatLine('Mathe', longname: 'Mathematik', extra: 'Hauptgebäude'), + 'Mathe (Mathematik) · Hauptgebäude', + ); + }); + + test('formatLine omits longname when it equals name', () { + expect(LessonFormatter.formatLine('Mathe', longname: 'Mathe'), 'Mathe'); + }); + + test('formatLine substitutes ? when name is empty', () { + expect(LessonFormatter.formatLine(''), '?'); + }); + }); +} diff --git a/test/extensions/date_time_test.dart b/test/extensions/date_time_test.dart new file mode 100644 index 0000000..2f174a9 --- /dev/null +++ b/test/extensions/date_time_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:marianum_mobile/extensions/date_time.dart'; + +void main() { + setUpAll(() async { + // Jiffy needs locale data once before any formatting calls. + await Jiffy.setLocale('de'); + }); + + group('IsSameDay', () { + test('isSameDay matches by year/month/day, ignoring time', () { + final a = DateTime(2026, 5, 8, 9, 30); + final b = DateTime(2026, 5, 8, 22, 0); + expect(a.isSameDay(b), isTrue); + }); + + test('isSameDay differs across midnight', () { + final a = DateTime(2026, 5, 8, 23, 59); + final b = DateTime(2026, 5, 9, 0, 0); + expect(a.isSameDay(b), isFalse); + }); + + test('isSameOrAfter is inclusive', () { + final a = DateTime(2026, 5, 8, 12); + final b = DateTime(2026, 5, 8, 12); + expect(a.isSameOrAfter(b), isTrue); + expect(a.add(const Duration(seconds: 1)).isSameOrAfter(b), isTrue); + expect(a.subtract(const Duration(seconds: 1)).isSameOrAfter(b), isFalse); + }); + }); + + group('DateTimeFormatting', () { + final dt = DateTime(2026, 5, 8, 9, 7); + + test('formatHm pads hours and minutes to two digits', () { + expect(dt.formatHm(), '09:07'); + }); + + test('formatDate uses dd.MM.yyyy', () { + expect(dt.formatDate(), '08.05.2026'); + }); + + test('formatDateTime combines date and time', () { + expect(dt.formatDateTime(), '08.05.2026 09:07'); + }); + + test('formatDateShort drops the year', () { + expect(dt.formatDateShort(), '08.05.'); + }); + + test('formatDateShortHm combines short date and time', () { + expect(dt.formatDateShortHm(), '08.05. 09:07'); + }); + + test('timeRangeTo joins start and end with a hyphen', () { + final end = dt.add(const Duration(minutes: 45)); + expect(dt.timeRangeTo(end), '09:07 - 09:52'); + }); + }); +} diff --git a/test/utils/debouncer_test.dart b/test/utils/debouncer_test.dart new file mode 100644 index 0000000..6084188 --- /dev/null +++ b/test/utils/debouncer_test.dart @@ -0,0 +1,112 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/utils/debouncer.dart'; + +void main() { + // Each test is wrapped in fakeAsync so timers fire deterministically. + group('Debouncer.debounce', () { + test('runs the action once after the delay elapses without further calls', () { + fakeAsync((async) { + var calls = 0; + Debouncer.debounce('tag', const Duration(milliseconds: 100), () => calls++); + + async.elapse(const Duration(milliseconds: 99)); + expect(calls, 0); + + async.elapse(const Duration(milliseconds: 1)); + expect(calls, 1); + }); + }); + + test('subsequent calls within the delay reset the timer (coalesce)', () { + fakeAsync((async) { + var calls = 0; + void schedule() => Debouncer.debounce( + 'tag', const Duration(milliseconds: 100), () => calls++); + + schedule(); + async.elapse(const Duration(milliseconds: 80)); + schedule(); // resets + async.elapse(const Duration(milliseconds: 80)); + schedule(); // resets + async.elapse(const Duration(milliseconds: 80)); + expect(calls, 0, reason: 'each schedule() resets the timer'); + + async.elapse(const Duration(milliseconds: 100)); + expect(calls, 1); + }); + }); + + test('different tags are independent', () { + fakeAsync((async) { + var aCalls = 0; + var bCalls = 0; + Debouncer.debounce('a', const Duration(milliseconds: 100), () => aCalls++); + Debouncer.debounce('b', const Duration(milliseconds: 100), () => bCalls++); + + async.elapse(const Duration(milliseconds: 100)); + expect(aCalls, 1); + expect(bCalls, 1); + }); + }); + }); + + group('Debouncer.throttle', () { + test('first call runs immediately, subsequent calls within window are dropped', () { + fakeAsync((async) { + var calls = 0; + Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++); + expect(calls, 1, reason: 'throttle fires the first call synchronously'); + + Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++); + Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++); + expect(calls, 1, reason: 'subsequent calls within the gate are ignored'); + + async.elapse(const Duration(milliseconds: 100)); + Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++); + expect(calls, 2, reason: 'after the window elapses, throttle fires again'); + }); + }); + + test('different tags throttle independently', () { + fakeAsync((async) { + var aCalls = 0; + var bCalls = 0; + Debouncer.throttle('a', const Duration(milliseconds: 100), () => aCalls++); + Debouncer.throttle('b', const Duration(milliseconds: 100), () => bCalls++); + expect(aCalls, 1); + expect(bCalls, 1); + + async.elapse(const Duration(milliseconds: 100)); + }); + }); + }); + + group('Debouncer.cancel', () { + test('cancels a pending debounce so the action never runs', () { + fakeAsync((async) { + var calls = 0; + Debouncer.debounce('tag', const Duration(milliseconds: 100), () => calls++); + Debouncer.cancel('tag'); + + async.elapse(const Duration(milliseconds: 200)); + expect(calls, 0); + }); + }); + + test('cancels an active throttle gate so the next call fires immediately', () { + fakeAsync((async) { + var calls = 0; + Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++); + expect(calls, 1); + + Debouncer.cancel('tag'); + Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++); + expect(calls, 2, + reason: 'cancel removed the gate so the next throttle fires again'); + + async.elapse(const Duration(milliseconds: 100)); + }); + }); + }); +} diff --git a/test/utils/file_clipboard_test.dart b/test/utils/file_clipboard_test.dart new file mode 100644 index 0000000..52ceaa6 --- /dev/null +++ b/test/utils/file_clipboard_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import 'package:marianum_mobile/utils/file_clipboard.dart'; + +CacheableFile _file(String name) => + CacheableFile(path: '/$name', isDirectory: false, name: name); + +void main() { + // FileClipboard is a singleton — clear between tests so state doesn't leak. + setUp(FileClipboard.instance.clear); + + group('FileClipboard.cut', () { + test('switches to cut state and notifies listeners', () { + final cb = FileClipboard.instance; + var notifyCount = 0; + void listener() => notifyCount++; + cb.addListener(listener); + addTearDown(() => cb.removeListener(listener)); + + cb.cut([_file('a.txt')]); + + expect(cb.operation, FileClipboardOperation.cut); + expect(cb.files.map((f) => f.name), ['a.txt']); + expect(cb.isEmpty, isFalse); + expect(notifyCount, 1); + }); + + test('empty input is a no-op', () { + final cb = FileClipboard.instance; + var notifyCount = 0; + void listener() => notifyCount++; + cb.addListener(listener); + addTearDown(() => cb.removeListener(listener)); + + cb.cut(const []); + + expect(cb.operation, isNull); + expect(cb.isEmpty, isTrue); + expect(notifyCount, 0, reason: 'no state change → no notifyListeners'); + }); + + test('files getter returns an unmodifiable view', () { + final cb = FileClipboard.instance; + cb.cut([_file('a.txt')]); + expect(() => cb.files.add(_file('b.txt')), throwsUnsupportedError); + }); + }); + + group('FileClipboard.copy', () { + test('switches to copy state and notifies listeners', () { + final cb = FileClipboard.instance; + cb.copy([_file('a.txt'), _file('b.txt')]); + + expect(cb.operation, FileClipboardOperation.copy); + expect(cb.files, hasLength(2)); + }); + + test('overwrites a previous cut state', () { + final cb = FileClipboard.instance; + cb.cut([_file('cut.txt')]); + cb.copy([_file('copy.txt')]); + + expect(cb.operation, FileClipboardOperation.copy); + expect(cb.files.single.name, 'copy.txt'); + }); + }); + + group('FileClipboard.clear', () { + test('resets state and notifies', () { + final cb = FileClipboard.instance; + cb.copy([_file('a.txt')]); + var notifyCount = 0; + void listener() => notifyCount++; + cb.addListener(listener); + addTearDown(() => cb.removeListener(listener)); + + cb.clear(); + + expect(cb.operation, isNull); + expect(cb.isEmpty, isTrue); + expect(notifyCount, 1); + }); + + test('clearing an already-empty clipboard is a no-op', () { + final cb = FileClipboard.instance; + var notifyCount = 0; + void listener() => notifyCount++; + cb.addListener(listener); + addTearDown(() => cb.removeListener(listener)); + + cb.clear(); + cb.clear(); + + expect(notifyCount, 0); + }); + }); +} diff --git a/test/view/files/sort_options_test.dart b/test/view/files/sort_options_test.dart new file mode 100644 index 0000000..d7ddbe6 --- /dev/null +++ b/test/view/files/sort_options_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; +import 'package:marianum_mobile/view/pages/files/data/sort_options.dart'; + +CacheableFile _file({ + required String name, + bool isDirectory = false, + int? size, + DateTime? modifiedAt, +}) => + CacheableFile( + path: '/$name', + isDirectory: isDirectory, + name: name, + size: size, + modifiedAt: modifiedAt, + ); + +void main() { + group('SortOptions.options', () { + test('name comparator is alphabetic', () { + final cmp = SortOptions.getOption(SortOption.name).compare; + expect(cmp(_file(name: 'a'), _file(name: 'b')), lessThan(0)); + expect(cmp(_file(name: 'b'), _file(name: 'a')), greaterThan(0)); + expect(cmp(_file(name: 'a'), _file(name: 'a')), 0); + }); + + test('date comparator is chronological by modifiedAt', () { + final cmp = SortOptions.getOption(SortOption.date).compare; + final older = _file(name: 'a', modifiedAt: DateTime(2026, 1, 1)); + final newer = _file(name: 'b', modifiedAt: DateTime(2026, 5, 1)); + expect(cmp(older, newer), lessThan(0)); + expect(cmp(newer, older), greaterThan(0)); + }); + + test('size comparator pushes directories to the end (positional 1 vs 0)', () { + final cmp = SortOptions.getOption(SortOption.size).compare; + final dir = _file(name: 'd', isDirectory: true); + final file = _file(name: 'f', size: 100); + // (dir, file) → returns 1 (dir.isDirectory true) → file sorts before dir. + expect(cmp(dir, file), 1); + expect(cmp(file, dir), 0); + }); + + test('size comparator handles null sizes', () { + final cmp = SortOptions.getOption(SortOption.size).compare; + final noSize = _file(name: 'a'); + final withSize = _file(name: 'b', size: 100); + // a.size == null → returns 0 + expect(cmp(noSize, withSize), 0); + // b.size == null → returns 1 + expect(cmp(withSize, noSize), 1); + }); + + test('size comparator orders by file size when both known', () { + final cmp = SortOptions.getOption(SortOption.size).compare; + expect(cmp(_file(name: 'a', size: 100), _file(name: 'b', size: 200)), lessThan(0)); + expect(cmp(_file(name: 'a', size: 200), _file(name: 'b', size: 100)), greaterThan(0)); + }); + + test('options map contains all enum values exactly once', () { + expect(SortOptions.options.keys.toSet(), SortOption.values.toSet()); + }); + }); + + group('ListFilesResponse.sortBy', () { + final folderA = _file(name: 'A', isDirectory: true); + final folderB = _file(name: 'B', isDirectory: true); + final fileA = _file(name: 'aaa', size: 100, modifiedAt: DateTime(2026, 1, 1)); + final fileB = _file(name: 'bbb', size: 50, modifiedAt: DateTime(2026, 5, 1)); + + // Note: sortBy uses a string-buffer sort + compareTo descending. The actual + // list ordering reflects what users see in the file list. + test('foldersToTop=true keeps folders before files regardless of name', () { + final response = ListFilesResponse({fileA, fileB, folderA, folderB}); + final sorted = response.sortBy(sortOption: SortOption.name); + final folderCount = sorted.takeWhile((f) => f.isDirectory).length; + expect(folderCount, 2, reason: 'both folders should sit at the top'); + }); + + test('foldersToTop=false intermixes folders and files', () { + final response = ListFilesResponse({fileA, fileB, folderA, folderB}); + final sorted = response.sortBy(sortOption: SortOption.name, foldersToTop: false); + final folderPositions = []; + for (var i = 0; i < sorted.length; i++) { + if (sorted[i].isDirectory) folderPositions.add(i); + } + // Without foldersToTop, folders aren't guaranteed to be at the front: + // assert at least one folder is somewhere other than the very top of + // a folders-first ordering. + expect(folderPositions, isNot([0, 1])); + }); + + test('reversed flips the order within each section', () { + final response = ListFilesResponse({fileA, fileB}); + final asc = response.sortBy(sortOption: SortOption.name, foldersToTop: false); + final desc = response.sortBy( + sortOption: SortOption.name, foldersToTop: false, reversed: true); + expect(desc, asc.reversed.toList()); + }); + + test('empty input yields an empty list', () { + final response = ListFilesResponse({}); + expect(response.sortBy(), isEmpty); + }); + }); +} diff --git a/test/view/marianum_dates/event_formatter_test.dart b/test/view/marianum_dates/event_formatter_test.dart new file mode 100644 index 0000000..3dabafc --- /dev/null +++ b/test/view/marianum_dates/event_formatter_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:marianum_mobile/state/app/modules/marianum_dates/bloc/marianum_dates_state.dart'; +import 'package:marianum_mobile/view/pages/marianum_dates/data/event_formatter.dart'; + +MarianumDate _event({ + required DateTime start, + required DateTime end, + bool isAllDay = false, +}) => + MarianumDate( + uid: 't', + title: 't', + description: null, + start: start, + end: end, + isAllDay: isAllDay, + ); + +void main() { + setUpAll(() async { + await Jiffy.setLocale('de'); + }); + + group('EventFormatter.trailingLabel', () { + test('all-day events show "Ganztägig"', () { + final e = _event( + start: DateTime(2026, 5, 8), + end: DateTime(2026, 5, 9), + isAllDay: true, + ); + expect(EventFormatter.trailingLabel(e), 'Ganztägig'); + }); + + test('zero-length same-day event shows a single time', () { + final at = DateTime(2026, 5, 8, 9, 30); + final e = _event(start: at, end: at); + expect(EventFormatter.trailingLabel(e), '09:30'); + }); + + test('same-day event shows time range', () { + final e = _event( + start: DateTime(2026, 5, 8, 9), + end: DateTime(2026, 5, 8, 10, 30), + ); + expect(EventFormatter.trailingLabel(e), '09:00–10:30'); + }); + + test('multi-day event shows date+time on both sides', () { + final e = _event( + start: DateTime(2026, 5, 8, 9), + end: DateTime(2026, 5, 9, 11), + ); + expect(EventFormatter.trailingLabel(e), '08.05. 09:00–09.05. 11:00'); + }); + }); + + group('EventFormatter.longRange', () { + test('all-day single-day collapses inclusive end to start date', () { + // ICS-style all-day: end is exclusive (next midnight). Display drops it. + final e = _event( + start: DateTime(2026, 5, 8), + end: DateTime(2026, 5, 9), + isAllDay: true, + ); + expect(EventFormatter.longRange(e), '08.05.2026 · Ganztägig'); + }); + + test('all-day multi-day shows inclusive end (one day before exclusive end)', () { + final e = _event( + start: DateTime(2026, 5, 8), + end: DateTime(2026, 5, 11), // exclusive → display "until 10.05." + isAllDay: true, + ); + expect(EventFormatter.longRange(e), '08.05.2026 – 10.05.2026 · Ganztägig'); + }); + + test('all-day event whose end equals start (degenerate) renders as single day', () { + final e = _event( + start: DateTime(2026, 5, 8), + end: DateTime(2026, 5, 8), + isAllDay: true, + ); + expect(EventFormatter.longRange(e), '08.05.2026 · Ganztägig'); + }); + + test('zero-length same-day timed event shows single time', () { + final at = DateTime(2026, 5, 8, 9, 30); + final e = _event(start: at, end: at); + expect(EventFormatter.longRange(e), '08.05.2026 · 09:30'); + }); + + test('same-day timed event shows date · range', () { + final e = _event( + start: DateTime(2026, 5, 8, 9), + end: DateTime(2026, 5, 8, 10, 30), + ); + expect(EventFormatter.longRange(e), '08.05.2026 · 09:00 – 10:30'); + }); + + test('multi-day timed event shows full datetimes on both sides', () { + final e = _event( + start: DateTime(2026, 5, 8, 9), + end: DateTime(2026, 5, 9, 11), + ); + expect(EventFormatter.longRange(e), '08.05.2026 09:00 – 09.05.2026 11:00'); + }); + }); +} diff --git a/test/view/timetable/calendar_logic_test.dart b/test/view/timetable/calendar_logic_test.dart new file mode 100644 index 0000000..fcd0a63 --- /dev/null +++ b/test/view/timetable/calendar_logic_test.dart @@ -0,0 +1,346 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import 'package:marianum_mobile/api/webuntis/queries/get_timetable/get_timetable_response.dart'; +import 'package:marianum_mobile/view/pages/timetable/data/arbitrary_appointment.dart'; +import 'package:marianum_mobile/view/pages/timetable/data/calendar_logic.dart'; +import 'package:marianum_mobile/view/pages/timetable/data/lesson_period_schedule.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +DateTime _at(int year, int month, int day, [int hour = 0, int minute = 0]) => + DateTime(year, month, day, hour, minute); + +Appointment _appt({ + required DateTime start, + required DateTime end, + String subject = 'Test', + bool isAllDay = false, + Object? id, + String? rrule, +}) => + Appointment( + id: id, + startTime: start, + endTime: end, + subject: subject, + color: Colors.blue, + isAllDay: isAllDay, + recurrenceRule: rrule, + ); + +GetTimetableResponseObject _lesson({String? code}) => GetTimetableResponseObject( + id: 0, + date: 0, + startTime: 0, + endTime: 0, + kl: const [], + te: const [], + su: const [], + ro: const [], + code: code, + ); + +CustomTimetableEvent _customEvent() => CustomTimetableEvent( + id: 'x', + title: '', + description: '', + startDate: DateTime(2026), + endDate: DateTime(2026), + color: null, + rrule: '', + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + +void main() { + group('isAllDayLike', () { + test('explicit isAllDay flag wins', () { + final a = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 10), isAllDay: true); + expect(isAllDayLike(a), isTrue); + }); + + test('events under 8 hours are not all-day-like', () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 15, 59)); + expect(isAllDayLike(a), isFalse); + }); + + test('events of exactly 8 hours count as all-day-like', () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 16)); + expect(isAllDayLike(a), isTrue); + }); + + test('Duration.inHours truncation does not let a 9h 30min event escape', () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 17, 30)); + expect(isAllDayLike(a), isTrue, + reason: 'inHours would say 9; we compare in minutes (570 ≥ 480)'); + }); + }); + + group('isOutsideSchoolHours', () { + // School hours run 7:30 → 17:15 (kCalendarStartHour = 7.5, kCalendarEndHour = 17.25). + + test('lessons fully inside the grid are inside', () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 9)); + expect(isOutsideSchoolHours(a), isFalse); + }); + + test('all-day-like events are always outside', () { + final a = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 10), isAllDay: true); + expect(isOutsideSchoolHours(a), isTrue); + }); + + test('events ending at or before grid start are outside', () { + final a = _appt(start: _at(2026, 5, 8, 6), end: _at(2026, 5, 8, 7, 30)); + expect(isOutsideSchoolHours(a), isTrue); + }); + + test('events starting at or after grid end are outside', () { + final a = _appt(start: _at(2026, 5, 8, 17, 15), end: _at(2026, 5, 8, 18)); + expect(isOutsideSchoolHours(a), isTrue); + }); + + test('events that engulf the entire grid are outside', () { + final a = _appt(start: _at(2026, 5, 8, 6), end: _at(2026, 5, 8, 18)); + expect(isOutsideSchoolHours(a), isTrue); + }); + + test('events that cross only the start boundary are inside', () { + final a = _appt(start: _at(2026, 5, 8, 7), end: _at(2026, 5, 8, 8)); + expect(isOutsideSchoolHours(a), isFalse); + }); + + test('events that cross only the end boundary are inside', () { + final a = _appt(start: _at(2026, 5, 8, 17), end: _at(2026, 5, 8, 18)); + expect(isOutsideSchoolHours(a), isFalse); + }); + }); + + group('partitionAppointmentsForWeek', () { + final monday = _at(2026, 5, 4); // a Monday + + test('single non-recurring lesson lands in the right day bucket', () { + final wednesday9 = _appt( + start: _at(2026, 5, 6, 9), end: _at(2026, 5, 6, 10)); + final result = partitionAppointmentsForWeek([wednesday9], monday); + expect(result.inside[0], isEmpty); + expect(result.inside[1], isEmpty); + expect(result.inside[2], hasLength(1)); + expect(result.inside[3], isEmpty); + expect(result.outside.expand((e) => e), isEmpty); + }); + + test('all-day events go to the outside bucket on their day', () { + final tuesdayAllDay = _appt( + start: _at(2026, 5, 5), + end: _at(2026, 5, 6), + isAllDay: true); + final result = partitionAppointmentsForWeek([tuesdayAllDay], monday); + expect(result.inside.expand((e) => e), isEmpty); + expect(result.outside[1], hasLength(1)); + }); + + test('events outside the visible week are dropped', () { + final lastWeek = _appt( + start: _at(2026, 4, 27, 9), end: _at(2026, 4, 27, 10)); + final nextWeek = _appt( + start: _at(2026, 5, 11, 9), end: _at(2026, 5, 11, 10)); + final result = partitionAppointmentsForWeek([lastWeek, nextWeek], monday); + expect(result.inside.expand((e) => e), isEmpty); + expect(result.outside.expand((e) => e), isEmpty); + }); + + test('weekend events (Sat/Sun) are dropped, only Mon–Fri counted', () { + final saturday = _appt( + start: _at(2026, 5, 9, 9), end: _at(2026, 5, 9, 10)); + final result = partitionAppointmentsForWeek([saturday], monday); + expect(result.inside.expand((e) => e), isEmpty); + }); + + test('weekly RRULE expands to one occurrence per matching week', () { + // Anchor on the Monday before our visible week, repeating weekly. + // The visible week's Monday should produce one occurrence. + final anchor = _appt( + start: _at(2026, 4, 27, 9), + end: _at(2026, 4, 27, 10), + rrule: 'RRULE:FREQ=WEEKLY;BYDAY=MO'); + final result = partitionAppointmentsForWeek([anchor], monday); + expect(result.inside[0], hasLength(1), + reason: 'Monday of the visible week should get one expansion'); + expect(result.inside[0].first.startTime, _at(2026, 5, 4, 9)); + }); + + test('malformed RRULE falls back to placing the anchor', () { + final broken = _appt( + start: _at(2026, 5, 6, 9), + end: _at(2026, 5, 6, 10), + rrule: 'this is not a valid rrule'); + final result = partitionAppointmentsForWeek([broken], monday); + expect(result.inside[2], hasLength(1)); + }); + }); + + group('PeriodLayout', () { + final p1 = const LessonPeriod( + name: '1', start: TimeOfDay(hour: 8, minute: 0), end: TimeOfDay(hour: 9, minute: 0)); + final brk = const LessonPeriod( + name: 'Pause', + start: TimeOfDay(hour: 9, minute: 0), + end: TimeOfDay(hour: 9, minute: 15), + isBreak: true); + final p2 = const LessonPeriod( + name: '2', + start: TimeOfDay(hour: 9, minute: 15), + end: TimeOfDay(hour: 10, minute: 15)); + + final layout = PeriodLayout( + periods: [p1, brk, p2], + lessonHeight: 60, // 60px per lesson + breakHeight: 20, + ); + + test('totalHeight sums lessons and breaks', () { + expect(layout.totalHeight, 60 + 20 + 60); + }); + + test('topOf returns cumulative height of preceding periods', () { + expect(layout.topOf(p1), 0); + expect(layout.topOf(brk), 60); + expect(layout.topOf(p2), 80); + }); + + test('heightOf returns the period-type-specific height', () { + expect(layout.heightOf(p1), 60); + expect(layout.heightOf(brk), 20); + }); + + test('yOfDateTime maps proportionally inside a period', () { + // 8:30 = halfway through the 1st lesson → y = 30 + expect(layout.yOfDateTime(_at(2026, 5, 8, 8, 30)), 30); + }); + + test('yOfDateTime clips to 0 before the first period', () { + expect(layout.yOfDateTime(_at(2026, 5, 8, 6)), 0); + }); + + test('yOfDateTime clips to totalHeight after the last period', () { + expect(layout.yOfDateTime(_at(2026, 5, 8, 18)), layout.totalHeight); + }); + + test('periodAtY returns the lesson under the cursor', () { + expect(layout.periodAtY(0), p1); + expect(layout.periodAtY(59), p1); + }); + + test('periodAtY skips a break to the next non-break lesson', () { + // y=70 falls in the break range; periodAtY should jump to p2. + expect(layout.periodAtY(70), p2); + }); + + test('periodAtY returns null past the last period', () { + expect(layout.periodAtY(layout.totalHeight + 10), isNull); + }); + }); + + group('assignLanes', () { + test('non-overlapping appointments stay on lane 0', () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 9)); + final b = _appt(start: _at(2026, 5, 8, 10), end: _at(2026, 5, 8, 11)); + final result = assignLanes([a, b], maxLanes: 2); + expect(result, hasLength(2)); + for (final cell in result) { + expect(cell.lane, 0); + expect(cell.laneCount, 1, reason: 'separate clusters → laneCount=1 each'); + } + }); + + test('two overlapping appointments split into 2 lanes', () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10)); + final b = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 11)); + final result = assignLanes([a, b], maxLanes: 2); + expect(result, hasLength(2)); + expect(result.map((c) => c.lane).toSet(), {0, 1}); + expect(result.every((c) => c.laneCount == 2), isTrue); + }); + + test('three overlapping appointments with maxLanes=2 collapse the third into overflow', () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 11)); + final b = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 11)); + final c = _appt(start: _at(2026, 5, 8, 10), end: _at(2026, 5, 8, 11)); + final result = assignLanes([a, b, c], maxLanes: 2); + + final visible = result.whereType().toList(); + final overflow = result.whereType().toList(); + expect(visible, hasLength(1), reason: 'maxLanes-1 = 1 visible appointment'); + expect(overflow, hasLength(1)); + expect(overflow.first.appointments, hasLength(2)); + expect(overflow.first.lane, 1); + expect(overflow.first.laneCount, 2); + }); + + test('CustomAppointment beats a regular lesson on lane priority', () { + final custom = _appt( + id: CustomAppointment(_customEvent()), + start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10), + ); + final regular = _appt( + id: WebuntisAppointment(_lesson()), + start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10), + ); + + final result = assignLanes([regular, custom], maxLanes: 2) + .whereType() + .toList(); + // Same startTime → priority decides: custom (0) goes left of regular (2). + final customCell = result.firstWhere((c) => c.appointment.id is CustomAppointment); + final regularCell = result.firstWhere((c) => c.appointment.id is WebuntisAppointment); + expect(customCell.lane, lessThan(regularCell.lane)); + }); + + test('cancelled lesson lands left of a non-cancelled one on tie', () { + final cancelled = _appt( + id: WebuntisAppointment(_lesson(code: 'cancelled')), + start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10), + ); + final regular = _appt( + id: WebuntisAppointment(_lesson()), + start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10), + ); + + final result = assignLanes([regular, cancelled], maxLanes: 2) + .whereType() + .toList(); + String? codeOf(LaidOutAppointment c) { + final id = c.appointment.id; + return id is WebuntisAppointment ? id.lesson.code : null; + } + final cancelledCell = result.firstWhere((c) => codeOf(c) == 'cancelled'); + final regularCell = result.firstWhere((c) => codeOf(c) == null); + expect(cancelledCell.lane, lessThan(regularCell.lane)); + }); + + test('overflow time-range spans earliest start to latest end of collapsed appointments', () { + // 4 overlapping appointments, maxLanes = 2 → 1 visible + overflow of 3. + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 12)); + final b = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 10)); + final c = _appt(start: _at(2026, 5, 8, 9, 30), end: _at(2026, 5, 8, 14)); + final d = _appt(start: _at(2026, 5, 8, 10), end: _at(2026, 5, 8, 11)); + + final overflow = assignLanes([a, b, c, d], maxLanes: 2) + .whereType() + .single; + expect(overflow.appointments, hasLength(3)); + expect(overflow.startTime, _at(2026, 5, 8, 9), + reason: 'earliest non-visible start time'); + expect(overflow.endTime, _at(2026, 5, 8, 14), + reason: 'latest non-visible end time'); + }); + + test('empty input returns an empty list', () { + expect(assignLanes(const [], maxLanes: 2), isEmpty); + }); + + test('asserts maxLanes >= 2', () { + expect(() => assignLanes(const [], maxLanes: 1), throwsA(isA())); + }); + }); +} diff --git a/test/widget/async_action_controller_test.dart b/test/widget/async_action_controller_test.dart new file mode 100644 index 0000000..8bf8d18 --- /dev/null +++ b/test/widget/async_action_controller_test.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/widget/async_action_button.dart'; + +void main() { + group('AsyncActionController.run', () { + test('toggles busy true while running and false after success', () async { + final controller = AsyncActionController(); + addTearDown(controller.dispose); + + var seenBusyInsideCallback = false; + final ok = await controller.run(() async { + seenBusyInsideCallback = controller.busy; + }); + + expect(seenBusyInsideCallback, isTrue, + reason: 'busy must be true while the callback is running'); + expect(ok, isTrue); + expect(controller.busy, isFalse); + expect(controller.error, isNull); + }); + + test('captures mapped error message on failure and returns false', () async { + final controller = AsyncActionController(); + addTearDown(controller.dispose); + + final ok = await controller.run( + () async => throw Exception('boom'), + errorBuilder: (e) => 'custom: $e', + ); + + expect(ok, isFalse); + expect(controller.busy, isFalse); + expect(controller.error, contains('custom:')); + expect(controller.error, contains('boom')); + }); + + test('rejects re-entry while busy', () async { + final controller = AsyncActionController(); + addTearDown(controller.dispose); + + final firstStarted = Completer(); + final firstCanFinish = Completer(); + final firstFuture = controller.run(() async { + firstStarted.complete(); + await firstCanFinish.future; + }); + + await firstStarted.future; + expect(controller.busy, isTrue); + + final reentrant = await controller.run(() async {}); + expect(reentrant, isFalse, + reason: 'second run while busy must be rejected without invoking callback'); + + firstCanFinish.complete(); + expect(await firstFuture, isTrue); + expect(controller.busy, isFalse); + }); + + test('clearError resets error and notifies listeners', () async { + final controller = AsyncActionController(); + addTearDown(controller.dispose); + + var notifyCount = 0; + controller.addListener(() => notifyCount++); + + await controller.run(() async => throw Exception('x')); + expect(controller.error, isNotNull); + final beforeClear = notifyCount; + + controller.clearError(); + expect(controller.error, isNull); + expect(notifyCount, beforeClear + 1); + + // No-op when already cleared. + controller.clearError(); + expect(notifyCount, beforeClear + 1); + }); + }); +} From 9e139b5704d5ea0c5b9728ebd3d1f92b70d90856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Fri, 8 May 2026 20:01:45 +0200 Subject: [PATCH 22/23] refactored data providers with centralized cache resolution, unified UI using custom dialogs and bottom sheets, and enhanced network error handling for Dio and TLS errors --- lib/api/errors/error_mapper.dart | 52 +++++- lib/api/holidays/get_holidays.dart | 16 -- lib/api/holidays/get_holidays_cache.dart | 18 -- lib/api/holidays/get_holidays_response.dart | 38 ---- lib/api/holidays/get_holidays_response.g.dart | 49 ------ lib/api/marianumcloud/nextcloud_ocs.dart | 14 +- .../talk/room/get_room_response.dart | 32 ---- lib/api/request_cache.dart | 34 +++- lib/notification/notification_controller.dart | 18 +- lib/routing/app_routes.dart | 51 ++---- .../data_loader/data_loader.dart | 14 +- .../chat_list_data_provider.dart | 26 ++- .../data_provider/files_data_provider.dart | 28 ++- .../timetable_data_provider.dart | 151 +++++++--------- lib/utils/clipboard_helper.dart | 3 +- lib/view/pages/files/files_upload_dialog.dart | 36 +--- .../pages/files/widgets/file_element.dart | 162 ++++++++---------- .../files/widgets/files_sort_actions.dart | 3 - lib/view/pages/holidays/holidays_view.dart | 49 +++--- .../more/share/select_share_type_dialog.dart | 69 ++++---- lib/view/pages/overhang.dart | 5 +- .../settings/sections/about_section.dart | 77 ++++----- .../settings/sections/dev_tools_section.dart | 73 ++++---- .../pages/settings/widgets/privacy_info.dart | 42 +++-- lib/view/pages/talk/chat_list.dart | 9 +- lib/view/pages/talk/widgets/bubble.dart | 5 +- lib/view/pages/talk/widgets/chat_bubble.dart | 3 - .../widgets/chat_message_options_dialog.dart | 121 +++++++------ lib/view/pages/talk/widgets/chat_tile.dart | 12 +- .../pages/talk/widgets/poll_options_list.dart | 1 - .../pages/timetable/data/calendar_layout.dart | 20 ++- .../timetable/widgets/appointment_tile.dart | 15 +- .../widgets/calendar/outside_chips.dart | 43 ++--- .../timetable/widgets/calendar/week_grid.dart | 4 +- .../async_actions/async_text_button.dart | 7 +- lib/widget/file_viewer.dart | 14 +- test/api/errors/error_mapper_test.dart | 34 +++- 37 files changed, 595 insertions(+), 753 deletions(-) delete mode 100644 lib/api/holidays/get_holidays.dart delete mode 100644 lib/api/holidays/get_holidays_cache.dart delete mode 100644 lib/api/holidays/get_holidays_response.dart delete mode 100644 lib/api/holidays/get_holidays_response.g.dart diff --git a/lib/api/errors/error_mapper.dart b/lib/api/errors/error_mapper.dart index 313fd50..1807359 100644 --- a/lib/api/errors/error_mapper.dart +++ b/lib/api/errors/error_mapper.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:dio/dio.dart'; import 'package:http/http.dart' as http; import '../api_error.dart'; @@ -9,10 +10,46 @@ import '../webuntis/webuntis_error.dart'; import 'app_exception.dart'; import 'network_exception.dart'; import 'parse_exception.dart'; +import 'server_exception.dart'; import 'talk_exception.dart'; import 'webuntis_exception.dart'; const String _defaultFallback = 'Etwas ist schiefgelaufen. Bitte versuche es erneut.'; +const String _tlsErrorMessage = + 'Die sichere Verbindung zum Server wurde abgelehnt (Zertifikat oder TLS-Fehler). ' + 'Häufige Ursachen: falsche Geräte-Uhrzeit oder ein WLAN mit Anmeldeseite (z.B. Café/Hotel).'; + +AppException? _dioToAppException(DioException error) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return NetworkException.timeout(technicalDetails: error.message); + case DioExceptionType.connectionError: + return NetworkException(technicalDetails: error.message); + case DioExceptionType.badCertificate: + return const NetworkException( + userMessage: _tlsErrorMessage, + ); + case DioExceptionType.badResponse: + final status = error.response?.statusCode; + return ServerException( + statusCode: status ?? -1, + technicalDetails: 'HTTP $status: ${error.message}', + ); + case DioExceptionType.cancel: + case DioExceptionType.unknown: + final inner = error.error; + if (inner is SocketException) return NetworkException(technicalDetails: inner.message); + if (inner is HandshakeException) { + return const NetworkException( + userMessage: _tlsErrorMessage, + ); + } + if (inner is FormatException) return ParseException(technicalDetails: inner.message); + return null; + } +} String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) { if (error == null) return fallback; @@ -21,6 +58,11 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) { if (error is TalkError) return TalkException(error).userMessage; if (error is WebuntisError) return WebuntisException(error).userMessage; + if (error is DioException) { + final mapped = _dioToAppException(error); + if (mapped != null) return mapped.userMessage; + } + if (error is SocketException) { return const NetworkException().userMessage; } @@ -31,7 +73,7 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) { return const NetworkException().userMessage; } if (error is HandshakeException) { - return 'Sichere Verbindung konnte nicht hergestellt werden.'; + return _tlsErrorMessage; } if (error is FormatException) { return const ParseException().userMessage; @@ -48,12 +90,20 @@ String? errorToTechnicalDetails(Object? error) { if (error is AppException) return error.technicalDetails ?? error.toString(); if (error is TalkError) return TalkException(error).technicalDetails; if (error is WebuntisError) return WebuntisException(error).technicalDetails; + if (error is DioException) { + final mapped = _dioToAppException(error); + if (mapped != null) return mapped.technicalDetails ?? mapped.toString(); + } return error.toString(); } bool errorAllowsRetry(Object? error) { if (error == null) return true; if (error is AppException) return error.allowRetry; + if (error is DioException) { + final mapped = _dioToAppException(error); + if (mapped != null) return mapped.allowRetry; + } return true; } diff --git a/lib/api/holidays/get_holidays.dart b/lib/api/holidays/get_holidays.dart deleted file mode 100644 index 5014c48..0000000 --- a/lib/api/holidays/get_holidays.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'dart:convert'; -import 'package:http/http.dart' as http; - -import 'get_holidays_response.dart'; - -class GetHolidays { - Future query() async { - var response = (await http.get(Uri.parse('https://ferien-api.de/api/v1/holidays/HE'))).body; - var data = jsonDecode(response) as List; - return GetHolidaysResponse( - List.from( - data.map((e) => GetHolidaysResponseObject.fromJson(e as Map)) - ) - ); - } -} diff --git a/lib/api/holidays/get_holidays_cache.dart b/lib/api/holidays/get_holidays_cache.dart deleted file mode 100644 index 5781b59..0000000 --- a/lib/api/holidays/get_holidays_cache.dart +++ /dev/null @@ -1,18 +0,0 @@ -import '../request_cache.dart'; -import 'get_holidays.dart'; -import 'get_holidays_response.dart'; - -class GetHolidaysCache extends SimpleCache { - GetHolidaysCache({super.onUpdate, super.renew}) - : super( - cacheTime: RequestCache.cacheDay, - loader: () => GetHolidays().query(), - fromJson: (json) => GetHolidaysResponse( - (json['data'] as List) - .map((i) => GetHolidaysResponseObject.fromJson(i as Map)) - .toList(), - ), - ) { - start('state-holidays'); - } -} diff --git a/lib/api/holidays/get_holidays_response.dart b/lib/api/holidays/get_holidays_response.dart deleted file mode 100644 index 7039417..0000000 --- a/lib/api/holidays/get_holidays_response.dart +++ /dev/null @@ -1,38 +0,0 @@ - -import 'package:json_annotation/json_annotation.dart'; - -import '../api_response.dart'; - -part 'get_holidays_response.g.dart'; - -@JsonSerializable(explicitToJson: true) -class GetHolidaysResponse extends ApiResponse { - List data; - - GetHolidaysResponse(this.data); - - factory GetHolidaysResponse.fromJson(Map json) => _$GetHolidaysResponseFromJson(json); - Map toJson() => _$GetHolidaysResponseToJson(this); -} - -@JsonSerializable() -class GetHolidaysResponseObject { - String start; - String end; - int year; - String stateCode; - String name; - String slug; - - GetHolidaysResponseObject({ - required this.start, - required this.end, - required this.year, - required this.stateCode, - required this.name, - required this.slug - }); - - factory GetHolidaysResponseObject.fromJson(Map json) => _$GetHolidaysResponseObjectFromJson(json); - Map toJson() => _$GetHolidaysResponseObjectToJson(this); -} diff --git a/lib/api/holidays/get_holidays_response.g.dart b/lib/api/holidays/get_holidays_response.g.dart deleted file mode 100644 index 593ad0b..0000000 --- a/lib/api/holidays/get_holidays_response.g.dart +++ /dev/null @@ -1,49 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'get_holidays_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -GetHolidaysResponse _$GetHolidaysResponseFromJson(Map json) => - GetHolidaysResponse( - (json['data'] as List) - .map( - (e) => - GetHolidaysResponseObject.fromJson(e as Map), - ) - .toList(), - ) - ..headers = (json['headers'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ); - -Map _$GetHolidaysResponseToJson( - GetHolidaysResponse instance, -) => { - 'headers': ?instance.headers, - 'data': instance.data.map((e) => e.toJson()).toList(), -}; - -GetHolidaysResponseObject _$GetHolidaysResponseObjectFromJson( - Map json, -) => GetHolidaysResponseObject( - start: json['start'] as String, - end: json['end'] as String, - year: (json['year'] as num).toInt(), - stateCode: json['stateCode'] as String, - name: json['name'] as String, - slug: json['slug'] as String, -); - -Map _$GetHolidaysResponseObjectToJson( - GetHolidaysResponseObject instance, -) => { - 'start': instance.start, - 'end': instance.end, - 'year': instance.year, - 'stateCode': instance.stateCode, - 'name': instance.name, - 'slug': instance.slug, -}; diff --git a/lib/api/marianumcloud/nextcloud_ocs.dart b/lib/api/marianumcloud/nextcloud_ocs.dart index 64c04f7..b04d770 100644 --- a/lib/api/marianumcloud/nextcloud_ocs.dart +++ b/lib/api/marianumcloud/nextcloud_ocs.dart @@ -1,27 +1,17 @@ import '../../model/account_data.dart'; import '../../model/endpoint_data.dart'; -/// Shared helpers for Nextcloud OCS v2 endpoints. -/// -/// Three call sites previously duplicated the same header dictionary and the -/// same URI scaffolding (TalkApi, AutocompleteApi, FileSharingApi). Anything -/// that talks to `https:////ocs/v2.php/...` should go through -/// these two helpers so additions like a new header or a different auth -/// scheme only need to change here. +/// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by +/// TalkApi, AutocompleteApi, FileSharingApi. class NextcloudOcs { NextcloudOcs._(); - /// The standard OCS request header set: JSON accept, OCS API marker, - /// HTTP Basic auth from the active [AccountData]. static Map headers() => { 'Accept': 'application/json', 'OCS-APIRequest': 'true', 'Authorization': AccountData().getBasicAuthHeader(), }; - /// Builds an OCS URI by appending [pathSuffix] under `/ocs/v2.php/` of - /// the configured Nextcloud endpoint. Query parameters are converted to - /// strings (Uri rejects non-string values). static Uri uri(String pathSuffix, {Map? queryParameters}) { final endpoint = EndpointData().nextcloud(); return Uri.https( diff --git a/lib/api/marianumcloud/talk/room/get_room_response.dart b/lib/api/marianumcloud/talk/room/get_room_response.dart index c2ce467..da278d1 100644 --- a/lib/api/marianumcloud/talk/room/get_room_response.dart +++ b/lib/api/marianumcloud/talk/room/get_room_response.dart @@ -120,38 +120,6 @@ enum GetRoomResponseObjectParticipantNotificationLevel { @JsonValue(3) neverNotify, } -// @JsonSerializable(explicitToJson: true) -// class GetRoomResponseObjectMessage { -// int id; -// String token; -// GetRoomResponseObjectMessageActorType actorType; -// String actorId; -// String actorDisplayName; -// int timestamp; -// String message; -// String systemMessage; -// GetRoomResponseObjectMessageType messageType; -// bool isReplyable; -// String referenceId; -// -// -// GetRoomResponseObjectMessage( -// this.id, -// this.token, -// this.actorType, -// this.actorId, -// this.actorDisplayName, -// this.timestamp, -// this.message, -// this.systemMessage, -// this.messageType, -// this.isReplyable, -// this.referenceId); -// -// factory GetRoomResponseObjectMessage.fromJson(Map json) => _$GetRoomResponseObjectMessageFromJson(json); -// Map toJson() => _$GetRoomResponseObjectMessageToJson(this); -// } - enum GetRoomResponseObjectMessageActorType { @JsonValue('deleted_users') deletedUsers, @JsonValue('users') user, diff --git a/lib/api/request_cache.dart b/lib/api/request_cache.dart index 8d6fdb6..a5614e2 100644 --- a/lib/api/request_cache.dart +++ b/lib/api/request_cache.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:localstore/localstore.dart'; import 'api_response.dart'; +import 'errors/parse_exception.dart'; abstract class RequestCache { static const int cacheNothing = 0; @@ -81,10 +82,8 @@ abstract class RequestCache { } -/// Concrete [RequestCache] that delegates the two overrides to functions -/// passed in the constructor. Used to collapse the dozens of one-class-per- -/// endpoint cache files that all just forward to `().run()` and -/// `.fromJson(jsonDecode(...))`. +/// Concrete [RequestCache] that takes the two overrides as constructor +/// callbacks instead of requiring a subclass per endpoint. class SimpleCache extends RequestCache { final Future Function() _loader; final T Function(Map json) _fromJson; @@ -115,3 +114,30 @@ class SimpleCache extends RequestCache { @override T onLocalData(String json) => _fromJson(jsonDecode(json) as Map); } + +/// Captures the latest cache payload (cached or network) and rethrows the +/// captured network error if no payload arrived. Collapses the +/// `latest`/`capturedError`/`await ready` boilerplate that DataProviders +/// otherwise repeat per endpoint. +Future resolveFromCache( + RequestCache Function(void Function(T) onUpdate, void Function(Exception) onError) build, { + void Function(Object)? onError, + String? operationName, +}) async { + T? latest; + Object? capturedError; + final cache = build( + (data) => latest = data, + (e) { + capturedError = e; + onError?.call(e); + }, + ); + await cache.ready; + if (latest != null) return latest as T; + final err = capturedError; + if (err != null) throw err; + throw ParseException( + technicalDetails: operationName != null ? 'No data and no error from $operationName' : null, + ); +} diff --git a/lib/notification/notification_controller.dart b/lib/notification/notification_controller.dart index a9de28b..162ff28 100644 --- a/lib/notification/notification_controller.dart +++ b/lib/notification/notification_controller.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../widget/debug/debug_tile.dart'; import '../widget/debug/json_viewer.dart'; +import '../widget/info_dialog.dart'; import 'notification_tasks.dart'; class NotificationController { @@ -22,16 +23,13 @@ class NotificationController { NotificationTasks.updateProviders(context); DebugTile(context).run(() { - showDialog(context: context, builder: (context) => AlertDialog( - title: const Text('Notification report'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Dieser Bericht wird angezeigt, da du den Entwicklermodus aktiviert hast und die App über eine Benachrichtigung geöffnet wurde.'), - Text(JsonViewer.format(message.data)), - ], - ), - )); + InfoDialog.show( + context, + 'Dieser Bericht wird angezeigt, da du den Entwicklermodus aktiviert hast und die App über eine Benachrichtigung geöffnet wurde.\n\n' + '${JsonViewer.format(message.data)}', + copyable: true, + title: 'Notification report', + ); }); } diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index bbe31c7..dd7bb97 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -25,22 +25,15 @@ import '../widget/debug/cache_view.dart'; import '../widget/file_viewer.dart'; import '../widget/user_avatar.dart'; -/// Single entry point for full-page navigations across the app. -/// -/// Every full-page push in modules should go through one of these methods. -/// Dialogs (`showDialog`), bottom sheets (`showStickyFlexibleBottomSheet`, -/// `showDetailsBottomSheet`), and `Navigator.pop` for closing those -/// remain unchanged and live at the call sites. +/// Single entry point for full-page navigations. Dialogs and bottom sheets +/// stay at the call sites; only full-page pushes go through here. class AppRoutes { AppRoutes._(); - /// Token of a chat that should be auto-opened in the Talk tab once - /// the chat list view picks it up. Set by [openChatByToken] (e.g. from - /// a tapped notification) and consumed by the `ChatList` widget. + /// Set by [openChatByToken] (e.g. from a tapped notification) and consumed + /// by `ChatList` once the matching room is loaded. static final ValueNotifier pendingChatToken = ValueNotifier(null); - // -- Files -------------------------------------------------------------- - static void openFolder(BuildContext context, List path) { pushScreen(context, withNavBar: false, screen: Files(path: path)); } @@ -53,14 +46,10 @@ class AppRoutes { ); } - // -- Timetable ---------------------------------------------------------- - static void openCustomEvents(BuildContext context) { pushScreen(context, withNavBar: false, screen: const CustomEventsView()); } - // -- Marianum Message --------------------------------------------------- - static void openMarianumMessage(BuildContext context, String basePath, MarianumMessage message) { pushScreen( context, @@ -69,8 +58,6 @@ class AppRoutes { ); } - // -- Sharing / Settings / Feedback / DevTools --------------------------- - static void openQrShare(BuildContext context) { pushScreen(context, withNavBar: false, screen: const QrShareView()); } @@ -95,8 +82,6 @@ class AppRoutes { pushScreen(context, withNavBar: false, screen: const Roomplan()); } - // -- Talk --------------------------------------------------------------- - static void openMessageReactions(BuildContext context, String token, int messageId) { pushScreen( context, @@ -105,8 +90,6 @@ class AppRoutes { ); } - /// Opens a chat from a known [GetRoomResponseObject]. Delegates to - /// [TalkNavigator.pushSplitView] so tablet split-view behaviour stays intact. static void openChatView( BuildContext context, { required GetRoomResponseObject room, @@ -122,9 +105,8 @@ class AppRoutes { context.read().setToken(room.token); } - /// Schedules a chat to be opened in the Talk tab. Use this when only the - /// token is known (e.g. from a tapped notification) — the actual push - /// happens inside the `ChatList` widget once the room is available. + /// Schedules a chat to be opened in the Talk tab once the room is loaded. + /// Use when only the token is known (e.g. from a tapped notification). static void openChatByToken(BuildContext context, String token) { pendingChatToken.value = token; goToTalkTab(context); @@ -135,11 +117,9 @@ class AppRoutes { } } - /// Resolves a pending chat token (set via [openChatByToken]) using the - /// [ChatListBloc]'s current rooms and the active [AccountData] credentials. - /// Returns `null` if the token cannot yet be matched (e.g. the room is - /// still being loaded). Callers should keep listening to [pendingChatToken] - /// and the bloc state and retry when either changes. + /// Resolves a pending chat token (set via [openChatByToken]). Returns null + /// if the room or account is not ready yet — callers should retry when + /// [pendingChatToken] or the bloc state change. static ResolvedPendingChat? resolvePendingChat(BuildContext context) { final token = pendingChatToken.value; if (token == null) return null; @@ -166,17 +146,14 @@ class AppRoutes { return null; } - // -- Module / Tab navigation ------------------------------------------- - - /// Opens an [AppModule]'s root view as a full screen push (used by the - /// "Mehr" tab list). Modules that live in the bottom bar are reached via - /// [goToTab] instead. + /// Pushes a module from the "Mehr" tab list. Modules already in the bottom + /// bar are switched to via [goToTab] instead. static void openModule(BuildContext context, AppModule module) { pushScreen(context, withNavBar: false, screen: module.create()); } - /// Switches the bottom navigation to the given [module] if it is currently - /// in the bottom bar. Returns `true` if the jump happened. + /// Switches the bottom navigation to [module]. Returns false when the + /// module is not currently in the bottom bar. static bool goToTab(BuildContext context, Modules module) { final index = AppModule.getBottomBarModules(context) .map((e) => e.module) @@ -187,8 +164,6 @@ class AppRoutes { return true; } - /// Convenience wrapper for the Talk tab — preserved for the notification - /// handler API which only knows about Talk. static void goToTalkTab(BuildContext context) { goToTab(context, Modules.talk); } diff --git a/lib/state/app/infrastructure/data_loader/data_loader.dart b/lib/state/app/infrastructure/data_loader/data_loader.dart index ceec932..fabb054 100644 --- a/lib/state/app/infrastructure/data_loader/data_loader.dart +++ b/lib/state/app/infrastructure/data_loader/data_loader.dart @@ -12,21 +12,15 @@ abstract class DataLoader { } Future run() async { - var fetcher = fetch(); - await Future.wait([ - fetcher, - Future.delayed(const Duration(milliseconds: 500)) // TODO tune or remove - ]); - - var response = await fetcher; + final response = await fetch(); try { return assemble(DataLoaderResult( json: jsonDecode(response.data!), headers: response.headers.map.map((key, value) => MapEntry(key, value.join(';'))), )); - } catch(trace, e) { - log(trace.toString()); - throw e; + } catch (e, stack) { + log('DataLoader assemble failed', error: e, stackTrace: stack); + rethrow; } } diff --git a/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart b/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart index 6549df4..6fe28b0 100644 --- a/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart +++ b/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart @@ -2,26 +2,22 @@ import '../../../../../api/marianumcloud/talk/create_room/create_room.dart'; import '../../../../../api/marianumcloud/talk/create_room/create_room_params.dart'; import '../../../../../api/marianumcloud/talk/room/get_room_cache.dart'; import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../../api/request_cache.dart'; class ChatListDataProvider { Future getRooms({ void Function(Object)? onError, bool renew = false, - }) async { - GetRoomResponse? latest; - Object? capturedError; - final cache = GetRoomCache( - renew: renew, - onUpdate: (data) => latest = data, - onError: (e) { - capturedError = e; - onError?.call(e); - }, - ); - await cache.ready; - if (latest != null) return latest!; - throw capturedError ?? Exception('No data and no error from getRooms'); - } + }) => + resolveFromCache( + (onUpdate, onError) => GetRoomCache( + renew: renew, + onUpdate: onUpdate, + onError: onError, + ), + onError: onError, + operationName: 'getRooms', + ); Future createDirectRoom(String invite) => CreateRoom(CreateRoomParams(roomType: 1, invite: invite)).run(); diff --git a/lib/state/app/modules/files/data_provider/files_data_provider.dart b/lib/state/app/modules/files/data_provider/files_data_provider.dart index 708cb29..39913b2 100644 --- a/lib/state/app/modules/files/data_provider/files_data_provider.dart +++ b/lib/state/app/modules/files/data_provider/files_data_provider.dart @@ -3,6 +3,7 @@ import 'package:nextcloud/nextcloud.dart'; import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; import '../../../../../api/marianumcloud/webdav/webdav_api.dart'; +import '../../../../../api/request_cache.dart'; class FilesDataProvider { /// Lists files at [path]. Cached payload is delivered via [onCacheData] as @@ -14,22 +15,17 @@ class FilesDataProvider { String path, { void Function(ListFilesResponse)? onCacheData, void Function(Object)? onError, - }) async { - ListFilesResponse? latest; - Object? capturedError; - final cache = ListFilesCache( - path: path, - onUpdate: (data) => latest = data, - onCacheData: onCacheData, - onError: (e) { - capturedError = e; - onError?.call(e); - }, - ); - await cache.ready; - if (latest != null) return latest!; - throw capturedError ?? Exception('No data and no error from listFiles'); - } + }) => + resolveFromCache( + (onUpdate, onError) => ListFilesCache( + path: path, + onUpdate: onUpdate, + onCacheData: onCacheData, + onError: onError, + ), + onError: onError, + operationName: 'listFiles', + ); Future createFolder(String fullPath) async { final webdav = await WebdavApi.webdav; diff --git a/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart index 8859d1d..afd993f 100644 --- a/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart +++ b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart @@ -10,6 +10,7 @@ import '../../../../../api/mhsl/custom_timetable_event/remove/remove_custom_time import '../../../../../api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart'; import '../../../../../api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart'; import '../../../../../api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart'; +import '../../../../../api/request_cache.dart'; import '../../../../../api/webuntis/queries/get_holidays/get_holidays_cache.dart'; import '../../../../../api/webuntis/queries/get_holidays/get_holidays_response.dart'; import '../../../../../api/webuntis/queries/get_rooms/get_rooms_cache.dart'; @@ -30,112 +31,84 @@ class TimetableDataProvider { DateTime endDate, { void Function(Object)? onError, bool renew = false, - }) async { - GetTimetableResponse? latest; - Object? capturedError; - final cache = GetTimetableCache( - startdate: int.parse(_dateFormat.format(startDate)), - enddate: int.parse(_dateFormat.format(endDate)), - renew: renew, - onUpdate: (data) => latest = data, - onError: (e) { - capturedError = e; - onError?.call(e); - }, - ); - await cache.ready; - if (latest != null) return latest!; - throw capturedError ?? Exception('No data and no error from getWeek'); - } + }) => + resolveFromCache( + (onUpdate, onError) => GetTimetableCache( + startdate: int.parse(_dateFormat.format(startDate)), + enddate: int.parse(_dateFormat.format(endDate)), + renew: renew, + onUpdate: onUpdate, + onError: onError, + ), + onError: onError, + operationName: 'getWeek', + ); Future getRooms({ void Function(Object)? onError, bool renew = false, - }) async { - GetRoomsResponse? latest; - Object? capturedError; - final cache = GetRoomsCache( - renew: renew, - onUpdate: (data) => latest = data, - onError: (e) { - capturedError = e; - onError?.call(e); - }, - ); - await cache.ready; - if (latest != null) return latest!; - throw capturedError ?? Exception('No data and no error from getRooms'); - } + }) => + resolveFromCache( + (onUpdate, onError) => GetRoomsCache( + renew: renew, + onUpdate: onUpdate, + onError: onError, + ), + onError: onError, + operationName: 'getRooms', + ); Future getSubjects({ void Function(Object)? onError, bool renew = false, - }) async { - GetSubjectsResponse? latest; - Object? capturedError; - final cache = GetSubjectsCache( - renew: renew, - onUpdate: (data) => latest = data, - onError: (e) { - capturedError = e; - onError?.call(e); - }, - ); - await cache.ready; - if (latest != null) return latest!; - throw capturedError ?? Exception('No data and no error from getSubjects'); - } + }) => + resolveFromCache( + (onUpdate, onError) => GetSubjectsCache( + renew: renew, + onUpdate: onUpdate, + onError: onError, + ), + onError: onError, + operationName: 'getSubjects', + ); Future getSchoolHolidays({ void Function(Object)? onError, bool renew = false, - }) async { - GetHolidaysResponse? latest; - Object? capturedError; - final cache = GetHolidaysCache( - renew: renew, - onUpdate: (data) => latest = data, - onError: (e) { - capturedError = e; - onError?.call(e); - }, - ); - await cache.ready; - if (latest != null) return latest!; - throw capturedError ?? Exception('No data and no error from getSchoolHolidays'); - } + }) => + resolveFromCache( + (onUpdate, onError) => GetHolidaysCache( + renew: renew, + onUpdate: onUpdate, + onError: onError, + ), + onError: onError, + operationName: 'getSchoolHolidays', + ); - Future getTimegrid({bool renew = false}) async { - GetTimegridUnitsResponse? latest; - Object? capturedError; - final cache = GetTimegridUnitsCache( - renew: renew, - onUpdate: (data) => latest = data, - ); - await cache.ready; - if (latest != null) return latest!; - throw capturedError ?? Exception('No data and no error from getTimegrid'); - } + Future getTimegrid({bool renew = false}) => + resolveFromCache( + (onUpdate, _) => GetTimegridUnitsCache( + renew: renew, + onUpdate: onUpdate, + ), + operationName: 'getTimegrid', + ); Future getCustomEvents({ bool renew = false, void Function(Object)? onError, - }) async { - GetCustomTimetableEventResponse? latest; - Object? capturedError; - final cache = GetCustomTimetableEventCache( - GetCustomTimetableEventParams(AccountData().getUserSecret()), - renew: renew, - onUpdate: (data) => latest = data, - onError: (e) { - capturedError = e; - onError?.call(e); - }, - ); - await cache.ready; - if (latest != null) return latest!; - throw capturedError ?? Exception('No data and no error from getCustomEvents'); - } + }) => + resolveFromCache( + (onUpdate, onError) => GetCustomTimetableEventCache( + GetCustomTimetableEventParams(AccountData().getUserSecret()), + renew: renew, + onUpdate: onUpdate, + onError: onError, + ), + onError: onError, + operationName: 'getCustomEvents', + ); Future addCustomEvent(CustomTimetableEvent event) => AddCustomTimetableEvent(AddCustomTimetableEventParams(AccountData().getUserSecret(), event)).run(); diff --git a/lib/utils/clipboard_helper.dart b/lib/utils/clipboard_helper.dart index df08022..54c56f1 100644 --- a/lib/utils/clipboard_helper.dart +++ b/lib/utils/clipboard_helper.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -/// Copies [text] to the system clipboard and shows a SnackBar confirmation. -/// Safe to await: respects context lifecycle via the provided [context]. +/// Copies [text] to the system clipboard and shows a SnackBar. Future copyToClipboard( BuildContext context, String text, { diff --git a/lib/view/pages/files/files_upload_dialog.dart b/lib/view/pages/files/files_upload_dialog.dart index 44fe7cf..b7d72b1 100644 --- a/lib/view/pages/files/files_upload_dialog.dart +++ b/lib/view/pages/files/files_upload_dialog.dart @@ -7,6 +7,7 @@ import 'package:nextcloud/nextcloud.dart'; import '../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../widget/confirm_dialog.dart'; import '../../../widget/focus_behaviour.dart'; +import '../../../widget/info_dialog.dart'; class FilesUploadDialog extends StatefulWidget { final List filePaths; @@ -47,20 +48,12 @@ class _FilesUploadDialogState extends State { }).toList(); } - void showHttpErrorCode(int httpErrorCode){ - showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: const Text('Ein Fehler ist aufgetreten'), - contentPadding: const EdgeInsets.all(10), - content: Text('Error code: $httpErrorCode'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Schließen', textAlign: TextAlign.center), - ), - ], - ) + void showHttpErrorCode(int httpErrorCode) { + InfoDialog.show( + context, + 'Error code: $httpErrorCode', + title: 'Ein Fehler ist aufgetreten', + copyable: true, ); } @@ -70,20 +63,7 @@ class _FilesUploadDialogState extends State { _overallProgressValue = 0.0; _infoText = ''; }); - showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: const Text('Upload fehlgeschlagen'), - contentPadding: const EdgeInsets.all(10), - content: Text(message), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Schließen', textAlign: TextAlign.center), - ), - ], - ), - ); + InfoDialog.show(context, message, title: 'Upload fehlgeschlagen', copyable: true); } Future uploadFiles({bool override = false}) async { diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart index d70d856..82a8dc1 100644 --- a/lib/view/pages/files/widgets/file_element.dart +++ b/lib/view/pages/files/widgets/file_element.dart @@ -11,6 +11,7 @@ import '../../../../utils/download_manager.dart'; import '../../../../utils/file_clipboard.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; +import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/info_dialog.dart'; import 'file_details_sheet.dart'; @@ -77,13 +78,7 @@ class _FileElementState extends State { DownloadManager.instance.clear(widget.file.path); _detachJob(); setState(() {}); - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Download'), - content: Text(message), - ), - ); + InfoDialog.show(context, message, title: 'Download', copyable: true); } else if (status is DownloadCancelled) { DownloadManager.instance.clear(widget.file.path); _detachJob(); @@ -172,32 +167,36 @@ class _FileElementState extends State { Future _rename() async { final controller = TextEditingController(text: widget.file.name); - final newName = await showDialog( - context: context, - builder: (dialogCtx) => AlertDialog( - title: const Text('Umbenennen'), - content: TextField( - controller: controller, - decoration: const InputDecoration(labelText: 'Neuer Name'), - autofocus: true, - ), - actions: [ - TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Abbrechen')), - TextButton( - onPressed: () => Navigator.of(dialogCtx).pop(controller.text.trim()), - child: const Text('Umbenennen'), + try { + final newName = await showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: const Text('Umbenennen'), + content: TextField( + controller: controller, + decoration: const InputDecoration(labelText: 'Neuer Name'), + autofocus: true, ), - ], - ), - ); - if (newName == null || newName.isEmpty || newName == widget.file.name) return; + actions: [ + TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Abbrechen')), + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(controller.text.trim()), + child: const Text('Umbenennen'), + ), + ], + ), + ); + if (newName == null || newName.isEmpty || newName == widget.file.name) return; - final parent = _parentPathOf(widget.file.path); - final destination = _joinPath(parent, newName, isDirectory: widget.file.isDirectory); - await _runWebdavOp(() async { - final webdav = await WebdavApi.webdav; - await webdav.move(PathUri.parse(widget.file.path), PathUri.parse(destination)); - }, errorTitle: 'Umbenennen fehlgeschlagen'); + final parent = _parentPathOf(widget.file.path); + final destination = _joinPath(parent, newName, isDirectory: widget.file.isDirectory); + await _runWebdavOp(() async { + final webdav = await WebdavApi.webdav; + await webdav.move(PathUri.parse(widget.file.path), PathUri.parse(destination)); + }, errorTitle: 'Umbenennen fehlgeschlagen'); + } finally { + controller.dispose(); + } } void _putOnClipboard({required bool copy}) { @@ -234,68 +233,55 @@ class _FileElementState extends State { widget.refetch(); } on Object catch (e) { if (!mounted) return; - await showDialog( - context: context, - builder: (dialogCtx) => AlertDialog( - title: Text(errorTitle), - content: Text(e.toString()), - actions: [TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('OK'))], - ), - ); + InfoDialog.show(context, e.toString(), title: errorTitle, copyable: true); } } void _showActionSheet() { - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetCtx) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const CenteredLeading(Icon(Icons.info_outline)), - title: const Text('Info'), - onTap: () { - Navigator.of(sheetCtx).pop(); - showFileDetailsSheet(context, widget.file); - }, - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.drive_file_rename_outline)), - title: const Text('Umbenennen'), - onTap: () { - Navigator.of(sheetCtx).pop(); - _rename(); - }, - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.drive_file_move_outline)), - title: const Text('Verschieben'), - onTap: () { - Navigator.of(sheetCtx).pop(); - _putOnClipboard(copy: false); - }, - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.copy_outlined)), - title: const Text('Kopieren'), - onTap: () { - Navigator.of(sheetCtx).pop(); - _putOnClipboard(copy: true); - }, - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.delete_outline)), - title: const Text('Löschen'), - onTap: () { - Navigator.of(sheetCtx).pop(); - _delete(); - }, - ), - ], + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + ListTile( + leading: const CenteredLeading(Icon(Icons.info_outline)), + title: const Text('Info'), + onTap: () { + Navigator.of(sheetCtx).pop(); + showFileDetailsSheet(context, widget.file); + }, ), - ), + ListTile( + leading: const CenteredLeading(Icon(Icons.drive_file_rename_outline)), + title: const Text('Umbenennen'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _rename(); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.drive_file_move_outline)), + title: const Text('Verschieben'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _putOnClipboard(copy: false); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.copy_outlined)), + title: const Text('Kopieren'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _putOnClipboard(copy: true); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.delete_outline)), + title: const Text('Löschen'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _delete(); + }, + ), + ], ); } diff --git a/lib/view/pages/files/widgets/files_sort_actions.dart b/lib/view/pages/files/widgets/files_sort_actions.dart index 58abb23..269f725 100644 --- a/lib/view/pages/files/widgets/files_sort_actions.dart +++ b/lib/view/pages/files/widgets/files_sort_actions.dart @@ -2,9 +2,6 @@ import 'package:flutter/material.dart'; import '../data/sort_options.dart'; -/// AppBar action buttons for sort direction (asc/desc) and sort field -/// (name/date/size). Pure UI – owners pass current values + selection -/// callbacks. class FilesSortActions extends StatelessWidget { final SortOption currentSort; final bool ascending; diff --git a/lib/view/pages/holidays/holidays_view.dart b/lib/view/pages/holidays/holidays_view.dart index 4fa4fac..21241b7 100644 --- a/lib/view/pages/holidays/holidays_view.dart +++ b/lib/view/pages/holidays/holidays_view.dart @@ -10,6 +10,8 @@ import '../../../state/app/modules/holidays/bloc/holidays_state.dart'; import '../../../widget/animated_time.dart'; import '../../../widget/centered_leading.dart'; import '../../../widget/debug/debug_tile.dart'; +import '../../../widget/details_bottom_sheet.dart'; +import '../../../widget/info_dialog.dart'; import '../../../widget/list_view_util.dart'; import '../../../widget/string_extensions.dart'; @@ -21,18 +23,13 @@ class HolidaysView extends StatelessWidget { create: (context) => HolidaysBloc(), autoRebuild: true, child: (context, bloc, state) { - void showDisclaimer() { - showDialog(context: context, builder: (context) => AlertDialog( - title: const Text('Richtigkeit und Bereitstellung der Daten'), - content: const Text('' - 'Sämtliche Datumsangaben sind ohne Gewähr.\n' - 'Ich übernehme weder Verantwortung für die Richtigkeit der Daten noch hafte ich für wirtschaftliche Schäden die aus der Verwendung dieser Daten entstehen können.\n\n' - 'Die Daten stammen von https://ferien-api.de/'), - actions: [ - TextButton(child: const Text('Okay'), onPressed: () => Navigator.of(context).pop()), - ], - )); - } + void showDisclaimer() => InfoDialog.show( + context, + 'Sämtliche Datumsangaben sind ohne Gewähr.\n' + 'Ich übernehme weder Verantwortung für die Richtigkeit der Daten noch hafte ich für wirtschaftliche Schäden die aus der Verwendung dieser Daten entstehen können.\n\n' + 'Die Daten stammen von https://ferien-api.de/', + title: 'Richtigkeit und Bereitstellung der Daten', + ); return Scaffold( appBar: AppBar( @@ -78,9 +75,16 @@ class HolidaysView extends StatelessWidget { leading: const CenteredLeading(Icon(Icons.calendar_month)), title: Text('$holidayType ${getHolidayYear(holiday.start, holiday.end)}'), subtitle: Text('${formatDate(holiday.start)} - ${formatDate(holiday.end)}'), - onTap: () => showDialog(context: context, builder: (context) => SimpleDialog( - title: Text('$holidayType ${holiday.year} in Hessen'), - children: [ + onTap: () => showDetailsBottomSheet( + context, + header: Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Text( + '$holidayType ${holiday.year} in Hessen', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + children: (sheetCtx) => [ ListTile( leading: const CenteredLeading(Icon(Icons.signpost_outlined)), title: Text(holiday.name.capitalize()), @@ -94,21 +98,20 @@ class HolidaysView extends StatelessWidget { leading: const Icon(Icons.date_range_outlined), title: Text('bis zum ${formatDate(holiday.end)}'), ), - Visibility( - visible: !DateTime.parse(holiday.start).difference(DateTime.now()).isNegative, - replacement: ListTile( + if (DateTime.parse(holiday.start).difference(DateTime.now()).isNegative) + ListTile( leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)), title: Text(Jiffy.parse(holiday.start).fromNow()), - ), - child: ListTile( + ) + else + ListTile( leading: const CenteredLeading(Icon(Icons.timer_outlined)), title: AnimatedTime(callback: () => DateTime.parse(holiday.start).difference(DateTime.now())), subtitle: Text(Jiffy.parse(holiday.start).fromNow()), ), - ), - DebugTile(context).jsonData(holiday.toJson()), + DebugTile(sheetCtx).jsonData(holiday.toJson()), ], - )), + ), trailing: const Icon(Icons.arrow_right), ); }), diff --git a/lib/view/pages/more/share/select_share_type_dialog.dart b/lib/view/pages/more/share/select_share_type_dialog.dart index 50c9491..a1df4f4 100644 --- a/lib/view/pages/more/share/select_share_type_dialog.dart +++ b/lib/view/pages/more/share/select_share_type_dialog.dart @@ -5,34 +5,43 @@ import '../../../../widget/share_position_origin.dart'; enum ShareTargetType { qr } -class SelectShareTypeDialog extends StatelessWidget { - const SelectShareTypeDialog({super.key}); - - @override - Widget build(BuildContext context) => SimpleDialog( - children: [ - ListTile( - leading: const Icon(Icons.qr_code_2_outlined), - title: const Text('Per QR-Code'), - trailing: const Icon(Icons.arrow_right), - onTap: () => Navigator.of(context).pop(ShareTargetType.qr), - ), - ListTile( - leading: const Icon(Icons.link_outlined), - title: const Text('Per Link teilen'), - trailing: const Icon(Icons.arrow_right), - onTap: () { - Navigator.of(context).pop(); - SharePlus.instance.share(ShareParams( - sharePositionOrigin: SharePositionOrigin.get(context), - subject: 'App Teilen', - text: 'Hol dir die für das Marianum maßgeschneiderte App:' - '\n\nAndroid: https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client ' - '\niOS: https://apps.apple.com/us/app/marianum-fulda/id6458789560 ' - '\n\nViel Spaß!', - )); - }, - ) - ], - ); +/// Bottom sheet that lets the user pick how they want to share the app. +/// Resolves with [ShareTargetType.qr] for the QR option, or `null` when the +/// sheet is dismissed (link sharing fires immediately and resolves null). +Future showSelectShareTypeSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + useSafeArea: true, + builder: (sheetCtx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.qr_code_2_outlined), + title: const Text('Per QR-Code'), + trailing: const Icon(Icons.arrow_right), + onTap: () => Navigator.of(sheetCtx).pop(ShareTargetType.qr), + ), + ListTile( + leading: const Icon(Icons.link_outlined), + title: const Text('Per Link teilen'), + trailing: const Icon(Icons.arrow_right), + onTap: () { + Navigator.of(sheetCtx).pop(); + SharePlus.instance.share(ShareParams( + sharePositionOrigin: SharePositionOrigin.get(sheetCtx), + subject: 'App Teilen', + text: 'Hol dir die für das Marianum maßgeschneiderte App:' + '\n\nAndroid: https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client ' + '\niOS: https://apps.apple.com/us/app/marianum-fulda/id6458789560 ' + '\n\nViel Spaß!', + )); + }, + ), + ], + ), + ), + ); } diff --git a/lib/view/pages/overhang.dart b/lib/view/pages/overhang.dart index 214d500..e89ee2a 100644 --- a/lib/view/pages/overhang.dart +++ b/lib/view/pages/overhang.dart @@ -42,10 +42,7 @@ class _OverhangState extends State { subtitle: const Text('Mit Freunden und deiner Klasse teilen'), trailing: const Icon(Icons.arrow_right), onTap: () async { - final result = await showDialog( - context: context, - builder: (_) => const SelectShareTypeDialog(), - ); + final result = await showSelectShareTypeSheet(context); if (!mounted || result != ShareTargetType.qr) return; if (context.mounted) AppRoutes.openQrShare(context); }, diff --git a/lib/view/pages/settings/sections/about_section.dart b/lib/view/pages/settings/sections/about_section.dart index e8b3157..4de6616 100644 --- a/lib/view/pages/settings/sections/about_section.dart +++ b/lib/view/pages/settings/sections/about_section.dart @@ -7,6 +7,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; +import '../../../../widget/details_bottom_sheet.dart'; import '../data/default_settings.dart'; import '../widgets/privacy_info.dart'; import 'dev_tools_section.dart'; @@ -69,45 +70,43 @@ class AboutSection extends StatelessWidget { ); } - void _showPrivacyDialog(BuildContext context) => showDialog( - context: context, - builder: (context) => SimpleDialog( - children: [ - ListTile( - leading: const CenteredLeading(Icon(Icons.school_outlined)), - title: const Text('Infos zum Marianum Fulda'), - subtitle: const Text('Für Talk-Chats und Dateien'), - trailing: const Icon(Icons.arrow_right), - onTap: () => PrivacyInfo( - providerText: 'Marianum', - imprintUrl: 'https://www.marianum-fulda.de/impressum', - privacyUrl: 'https://www.marianum-fulda.de/datenschutz', - ).showPopup(context), - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.date_range_outlined)), - title: const Text('Infos zu Web-/ Untis'), - subtitle: const Text('Für den Stundenplan'), - trailing: const Icon(Icons.arrow_right), - onTap: () => PrivacyInfo( - providerText: 'Untis', - imprintUrl: 'https://www.untis.at/impressum', - privacyUrl: 'https://www.untis.at/datenschutz-wu-apps', - ).showPopup(context), - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.send_time_extension_outlined)), - title: const Text('Infos zu mhsl'), - subtitle: const Text('Für Countdowns, Marianum Message und mehr'), - trailing: const Icon(Icons.arrow_right), - onTap: () => PrivacyInfo( - providerText: 'mhsl', - imprintUrl: 'https://mhsl.eu/id.html', - privacyUrl: 'https://mhsl.eu/datenschutz.html', - ).showPopup(context), - ), - ], - ), + void _showPrivacyDialog(BuildContext context) => showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + ListTile( + leading: const CenteredLeading(Icon(Icons.school_outlined)), + title: const Text('Infos zum Marianum Fulda'), + subtitle: const Text('Für Talk-Chats und Dateien'), + trailing: const Icon(Icons.arrow_right), + onTap: () => PrivacyInfo( + providerText: 'Marianum', + imprintUrl: 'https://www.marianum-fulda.de/impressum', + privacyUrl: 'https://www.marianum-fulda.de/datenschutz', + ).showPopup(sheetCtx), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.date_range_outlined)), + title: const Text('Infos zu Web-/ Untis'), + subtitle: const Text('Für den Stundenplan'), + trailing: const Icon(Icons.arrow_right), + onTap: () => PrivacyInfo( + providerText: 'Untis', + imprintUrl: 'https://www.untis.at/impressum', + privacyUrl: 'https://www.untis.at/datenschutz-wu-apps', + ).showPopup(sheetCtx), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.send_time_extension_outlined)), + title: const Text('Infos zu mhsl'), + subtitle: const Text('Für Countdowns, Marianum Message und mehr'), + trailing: const Icon(Icons.arrow_right), + onTap: () => PrivacyInfo( + providerText: 'mhsl', + imprintUrl: 'https://mhsl.eu/id.html', + privacyUrl: 'https://mhsl.eu/datenschutz.html', + ).showPopup(sheetCtx), + ), + ], ); void _toggleDeveloperMode(BuildContext context, SettingsCubit settings, bool? state) { diff --git a/lib/view/pages/settings/sections/dev_tools_section.dart b/lib/view/pages/settings/sections/dev_tools_section.dart index 7cada5f..82d5019 100644 --- a/lib/view/pages/settings/sections/dev_tools_section.dart +++ b/lib/view/pages/settings/sections/dev_tools_section.dart @@ -11,6 +11,7 @@ import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/debug/cache_view.dart'; import '../../../../widget/debug/json_viewer.dart'; +import '../../../../widget/details_bottom_sheet.dart'; class DevToolsSection extends StatefulWidget { final SettingsCubit settings; @@ -29,42 +30,45 @@ class _DevToolsSectionState extends State { title: const Text('Performance overlays'), trailing: const Icon(Icons.arrow_right), onTap: () { - showDialog( - context: context, - builder: (dialogCtx) => BlocBuilder( - bloc: widget.settings, - builder: (_, _) { - final dev = widget.settings.val().devToolsSettings; - return SimpleDialog( - children: [ - ListTile( - leading: const Icon(Icons.auto_graph_outlined), - title: const Text('Performance graph'), - trailing: Checkbox( - value: dev.showPerformanceOverlay, - onChanged: (e) => widget.settings.val(write: true).devToolsSettings.showPerformanceOverlay = e!, + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + BlocBuilder( + bloc: widget.settings, + builder: (_, _) { + final dev = widget.settings.val().devToolsSettings; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.auto_graph_outlined), + title: const Text('Performance graph'), + trailing: Checkbox( + value: dev.showPerformanceOverlay, + onChanged: (e) => widget.settings.val(write: true).devToolsSettings.showPerformanceOverlay = e!, + ), ), - ), - ListTile( - leading: const Icon(Icons.screen_search_desktop_outlined), - title: const Text('Indicate offscreen layers'), - trailing: Checkbox( - value: dev.checkerboardOffscreenLayers, - onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardOffscreenLayers = e!, + ListTile( + leading: const Icon(Icons.screen_search_desktop_outlined), + title: const Text('Indicate offscreen layers'), + trailing: Checkbox( + value: dev.checkerboardOffscreenLayers, + onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardOffscreenLayers = e!, + ), ), - ), - ListTile( - leading: const Icon(Icons.imagesearch_roller_outlined), - title: const Text('Indicate raster cache images'), - trailing: Checkbox( - value: dev.checkerboardRasterCacheImages, - onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardRasterCacheImages = e!, + ListTile( + leading: const Icon(Icons.imagesearch_roller_outlined), + title: const Text('Indicate raster cache images'), + trailing: Checkbox( + value: dev.checkerboardRasterCacheImages, + onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardRasterCacheImages = e!, + ), ), - ), - ], - ); - }, - ), + ], + ); + }, + ), + ], ); }, ), @@ -122,9 +126,6 @@ class _DevToolsSectionState extends State { leading: const CenteredLeading(Icon(Icons.data_object)), title: const Text('BLOC-storage state cache'), subtitle: const Text('Lange tippen um zu löschen'), - onTap: () { - // Navigator.push(context, MaterialPageRoute(builder: (context) => const CacheView())); - }, onLongPress: () { ConfirmDialog( title: 'BLOC-Cache löschen', diff --git a/lib/view/pages/settings/widgets/privacy_info.dart b/lib/view/pages/settings/widgets/privacy_info.dart index 13ba668..ec96588 100644 --- a/lib/view/pages/settings/widgets/privacy_info.dart +++ b/lib/view/pages/settings/widgets/privacy_info.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; +import '../../../../widget/details_bottom_sheet.dart'; class PrivacyInfo { String providerText; @@ -11,22 +12,29 @@ class PrivacyInfo { PrivacyInfo({required this.providerText, required this.imprintUrl, required this.privacyUrl}); void showPopup(BuildContext context) { - showDialog(context: context, builder: (context) => SimpleDialog( - title: Text('Betreiberinformation | $providerText'), - children: [ - ListTile( - leading: const CenteredLeading(Icon(Icons.person_pin_outlined)), - title: const Text('Impressum'), - subtitle: Text(imprintUrl), - onTap: () => ConfirmDialog.openBrowser(context, imprintUrl), - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.privacy_tip_outlined)), - title: const Text('Datenschutzerklärung'), - subtitle: Text(privacyUrl), - onTap: () => ConfirmDialog.openBrowser(context, privacyUrl), - ), - ], - )); + showDetailsBottomSheet( + context, + header: Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Text( + 'Betreiberinformation | $providerText', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + children: (sheetCtx) => [ + ListTile( + leading: const CenteredLeading(Icon(Icons.person_pin_outlined)), + title: const Text('Impressum'), + subtitle: Text(imprintUrl), + onTap: () => ConfirmDialog.openBrowser(sheetCtx, imprintUrl), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.privacy_tip_outlined)), + title: const Text('Datenschutzerklärung'), + subtitle: Text(privacyUrl), + onTap: () => ConfirmDialog.openBrowser(sheetCtx, privacyUrl), + ), + ], + ); } } diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart index 4a01136..f5bdc85 100644 --- a/lib/view/pages/talk/chat_list.dart +++ b/lib/view/pages/talk/chat_list.dart @@ -12,6 +12,7 @@ import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../widget/confirm_dialog.dart'; +import '../../../widget/info_dialog.dart'; import '../../../widget/placeholder_view.dart'; import 'join_chat.dart'; import 'search_chat.dart'; @@ -98,11 +99,9 @@ class _ChatListViewState extends State<_ChatListView> { NotifyUpdater.enableAfterDisclaimer(_settings).asDialog(context); break; case AuthorizationStatus.denied: - showDialog( - context: context, - builder: (_) => const AlertDialog( - content: Text('Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.'), - ), + InfoDialog.show( + context, + 'Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.', ); break; default: diff --git a/lib/view/pages/talk/widgets/bubble.dart b/lib/view/pages/talk/widgets/bubble.dart index 408564d..bd87a2a 100644 --- a/lib/view/pages/talk/widgets/bubble.dart +++ b/lib/view/pages/talk/widgets/bubble.dart @@ -40,9 +40,8 @@ class BubbleStyle { final double borderRadius; } -/// Lightweight chat bubble. Replaces the abandoned `bubble` package: renders a -/// rounded container with optional shadow / border. The nip is conveyed by -/// flattening one corner so the bubble visually anchors to the speaker side. +/// The "nip" is faked by flattening one corner so the bubble anchors to +/// the speaker side. class Bubble extends StatelessWidget { const Bubble({required this.child, required this.style, super.key}); diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index f226c8e..8958be9 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -246,9 +246,6 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM } } -/// Stack inside the bubble: actor name (top-left, optional), message body -/// (centre), timestamp + read marker (bottom-right, optional), and a -/// download progress bar overlaid at the bottom while a job is running. class _BubbleContent extends StatelessWidget { final Text actorText; final Text timeText; diff --git a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart index 8ba2641..6816d71 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -14,13 +14,14 @@ import '../../../../utils/clipboard_helper.dart'; import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/debug/debug_tile.dart'; +import '../../../../widget/details_bottom_sheet.dart'; const _commonReactions = ['👍', '👎', '😆', '❤️', '👀']; /// Long-press / double-tap options dialog for a single chat message bubble. /// The hosting [ChatBubble] keeps responsibility for rendering the bubble; /// this file owns the modal interactions (react, reply, copy, delete, ...). -Future showChatMessageOptionsDialog( +void showChatMessageOptionsDialog( BuildContext context, { required GetRoomResponseObject chatData, required GetChatResponseObject bubbleData, @@ -34,63 +35,61 @@ Future showChatMessageOptionsDialog( .add(const Duration(hours: 6)) .isAfter(DateTime.now()); - return showDialog( - context: context, - builder: (dialogCtx) => SimpleDialog( - children: [ - if (canReact) - _ReactionsRow( - chatToken: chatData.token, - messageId: bubbleData.id, - onRefetch: onRefetch, - dialogContext: dialogCtx, - ), - if (bubbleData.isReplyable) - ListTile( - leading: const Icon(Icons.reply_outlined), - title: const Text('Antworten'), - onTap: () { - dialogCtx.read().setReferenceMessageId(bubbleData.id); - Navigator.of(dialogCtx).pop(); - }, - ), - if (canReact) - ListTile( - leading: const Icon(Icons.emoji_emotions_outlined), - title: const Text('Reaktionen'), - onTap: () { - Navigator.of(dialogCtx).pop(); - if (!parentContext.mounted) return; - AppRoutes.openMessageReactions(parentContext, chatData.token, bubbleData.id); - }, - ), - if (bubbleData.message != '{file}') - ListTile( - leading: const Icon(Icons.copy), - title: const Text('Nachricht kopieren'), - onTap: () { - copyToClipboard(parentContext, bubbleData.message); - Navigator.of(dialogCtx).pop(); - }, - ), - if (!kReleaseMode && !isSender && chatData.type != GetRoomResponseObjectConversationType.oneToOne) - ListTile( - leading: const Icon(Icons.sms_outlined), - title: Text("Private Nachricht an '${bubbleData.actorDisplayName}'"), - onTap: () => Navigator.of(dialogCtx).pop(), - ), - if (canDelete) - AsyncListTile( - leading: const Icon(Icons.delete_outline), - title: const Text('Nachricht löschen'), - onPressed: () async { - await DeleteMessage(chatData.token, bubbleData.id).run(); - if (dialogCtx.mounted) dialogCtx.read().refresh(); - }, - ), - DebugTile(dialogCtx).jsonData(bubbleData.toJson()), - ], - ), + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + if (canReact) + _ReactionsRow( + chatToken: chatData.token, + messageId: bubbleData.id, + onRefetch: onRefetch, + sheetContext: sheetCtx, + ), + if (bubbleData.isReplyable) + ListTile( + leading: const Icon(Icons.reply_outlined), + title: const Text('Antworten'), + onTap: () { + sheetCtx.read().setReferenceMessageId(bubbleData.id); + Navigator.of(sheetCtx).pop(); + }, + ), + if (canReact) + ListTile( + leading: const Icon(Icons.emoji_emotions_outlined), + title: const Text('Reaktionen'), + onTap: () { + Navigator.of(sheetCtx).pop(); + if (!parentContext.mounted) return; + AppRoutes.openMessageReactions(parentContext, chatData.token, bubbleData.id); + }, + ), + if (bubbleData.message != '{file}') + ListTile( + leading: const Icon(Icons.copy), + title: const Text('Nachricht kopieren'), + onTap: () { + copyToClipboard(parentContext, bubbleData.message); + Navigator.of(sheetCtx).pop(); + }, + ), + if (!kReleaseMode && !isSender && chatData.type != GetRoomResponseObjectConversationType.oneToOne) + ListTile( + leading: const Icon(Icons.sms_outlined), + title: Text("Private Nachricht an '${bubbleData.actorDisplayName}'"), + onTap: () => Navigator.of(sheetCtx).pop(), + ), + if (canDelete) + AsyncListTile( + leading: const Icon(Icons.delete_outline), + title: const Text('Nachricht löschen'), + onPressed: () async { + await DeleteMessage(chatData.token, bubbleData.id).run(); + if (sheetCtx.mounted) sheetCtx.read().refresh(); + }, + ), + DebugTile(sheetCtx).jsonData(bubbleData.toJson()), + ], ); } @@ -98,13 +97,13 @@ class _ReactionsRow extends StatefulWidget { final String chatToken; final int messageId; final void Function({bool renew}) onRefetch; - final BuildContext dialogContext; + final BuildContext sheetContext; const _ReactionsRow({ required this.chatToken, required this.messageId, required this.onRefetch, - required this.dialogContext, + required this.sheetContext, }); @override @@ -131,7 +130,7 @@ class _ReactionsRowState extends State<_ReactionsRow> { if (!mounted) return; if (ok) { widget.onRefetch(renew: true); - if (widget.dialogContext.mounted) Navigator.of(widget.dialogContext).pop(); + if (widget.sheetContext.mounted) Navigator.of(widget.sheetContext).pop(); } } diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index c9e8deb..f6a5cab 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -15,6 +15,7 @@ import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/debug/debug_tile.dart'; +import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/user_avatar.dart'; import '../chat_view.dart'; import '../talk_navigator.dart'; @@ -124,8 +125,9 @@ class _ChatTileState extends State { }, onLongPress: () { if (widget.disableContextActions) return; - showDialog(context: context, builder: (dialogCtx) => SimpleDialog( - children: [ + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ if (widget.data.unreadMessages > 0) AsyncListTile( leading: const Icon(Icons.mark_chat_read_outlined), @@ -163,7 +165,7 @@ class _ChatTileState extends State { leading: const Icon(Icons.delete_outline), title: const Text('Konversation verlassen'), onTap: () { - Navigator.of(dialogCtx).pop(); + Navigator.of(sheetCtx).pop(); ConfirmDialog( title: 'Chat verlassen', content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.', @@ -175,9 +177,9 @@ class _ChatTileState extends State { ).asDialog(context); }, ), - DebugTile(dialogCtx).jsonData(widget.data.toJson()), + DebugTile(sheetCtx).jsonData(widget.data.toJson()), ], - )); + ); }, ); } diff --git a/lib/view/pages/talk/widgets/poll_options_list.dart b/lib/view/pages/talk/widgets/poll_options_list.dart index 44f4162..a02a320 100644 --- a/lib/view/pages/talk/widgets/poll_options_list.dart +++ b/lib/view/pages/talk/widgets/poll_options_list.dart @@ -29,7 +29,6 @@ class _PollOptionsListState extends State { final portion = numVoters == 0 ? 0.0 : (votes / numVoters); return ListTile( - // enabled: false, isThreeLine: portionsVisible, dense: true, title: Text( diff --git a/lib/view/pages/timetable/data/calendar_layout.dart b/lib/view/pages/timetable/data/calendar_layout.dart index b026edd..aafe4dd 100644 --- a/lib/view/pages/timetable/data/calendar_layout.dart +++ b/lib/view/pages/timetable/data/calendar_layout.dart @@ -3,14 +3,22 @@ const double kCalendarEndHour = 17.25; const Duration kCalendarTimeInterval = Duration(minutes: 30); const double kCalendarViewHeaderHeight = 60; -/// Minimum pixels per hour. Below this, the grid scrolls vertically rather -/// than compressing further. +/// Below this, the grid scrolls vertically rather than compressing further. const double kCalendarMinPxPerHour = 56; -/// Minimum height of a lesson block in the period-based layout. The grid -/// scrolls vertically once lessons would otherwise be smaller than this. +/// The grid scrolls vertically once lessons would otherwise be smaller. const double kLessonBlockMinHeight = 50; -/// Fixed height of a break block in the period-based layout. Independent of -/// the actual break duration; breaks are rendered as a compact indicator. +/// Fixed (independent of actual break duration); breaks render as a compact +/// indicator. const double kBreakBlockHeight = 28; + +const int kOutsideChipsMaxVisible = 2; +const double kOutsideChipHeight = 22; +const double kOutsideChipSpacing = 3; +const double kOutsideStripVerticalPadding = 3; + +const double kAppointmentTitleFontSize = 15; +const double kAppointmentTitleMinFontSize = 11; +const double kAppointmentBodyFontSize = 10; +const double kAppointmentBodyLineHeight = 1.15; diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart index bb24afb..940b1d0 100644 --- a/lib/view/pages/timetable/widgets/appointment_tile.dart +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -2,14 +2,11 @@ import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../data/arbitrary_appointment.dart'; +import '../data/calendar_layout.dart'; import 'cross_painter.dart'; class AppointmentTile extends StatelessWidget { static const _radius = BorderRadius.all(Radius.circular(7)); - static const _titleFontSize = 15.0; - static const _titleMinFontSize = 11.0; - static const _bodyFontSize = 10.0; - static const _bodyLineHeight = 1.15; final Appointment appointment; final bool crossedOut; @@ -42,8 +39,8 @@ class AppointmentTile extends StatelessWidget { children: [ _AdaptiveTitle( text: appointment.subject, - fontSize: _titleFontSize, - minFontSize: _titleMinFontSize, + fontSize: kAppointmentTitleFontSize, + minFontSize: kAppointmentTitleMinFontSize, fontWeight: FontWeight.w500, ), if (isCustom) ...[ @@ -53,8 +50,8 @@ class AppointmentTile extends StatelessWidget { padding: const EdgeInsets.only(top: 1), child: _WrappingBody( text: description, - fontSize: _bodyFontSize, - lineHeight: _bodyLineHeight, + fontSize: kAppointmentBodyFontSize, + lineHeight: kAppointmentBodyLineHeight, ), ), ), @@ -63,7 +60,7 @@ class AppointmentTile extends StatelessWidget { .split('\n') .where((p) => p.isNotEmpty) .take(2)) - _ScaledLine(text: line, fontSize: _bodyFontSize), + _ScaledLine(text: line, fontSize: kAppointmentBodyFontSize), ], ], ), diff --git a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart index 098da70..b861f78 100644 --- a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart +++ b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart @@ -1,11 +1,6 @@ part of '../custom_workweek_calendar.dart'; class _OutsideHoursStrip extends StatelessWidget { - static const int _maxVisibleChips = 2; - static const double _chipHeight = 22; - static const double _chipSpacing = 3; - static const double _verticalPadding = 3; - final DateTime weekStart; final List appointments; final double rulerWidth; @@ -28,17 +23,17 @@ class _OutsideHoursStrip extends StatelessWidget { final theme = Theme.of(context); final maxChipsPerDay = outside - .map((day) => day.length > _maxVisibleChips ? _maxVisibleChips : day.length) + .map((day) => day.length > kOutsideChipsMaxVisible ? kOutsideChipsMaxVisible : day.length) .fold(0, (m, c) => c > m ? c : m); - final stripHeight = _verticalPadding * 2 + - maxChipsPerDay * _chipHeight + - (maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * _chipSpacing : 0); + final stripHeight = kOutsideStripVerticalPadding * 2 + + maxChipsPerDay * kOutsideChipHeight + + (maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * kOutsideChipSpacing : 0); return Container( color: theme.colorScheme.surfaceContainerLowest, - padding: const EdgeInsets.symmetric(vertical: _verticalPadding), + padding: const EdgeInsets.symmetric(vertical: kOutsideStripVerticalPadding), child: SizedBox( - height: stripHeight - _verticalPadding * 2, + height: stripHeight - kOutsideStripVerticalPadding * 2, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -47,9 +42,6 @@ class _OutsideHoursStrip extends StatelessWidget { Expanded( child: _OutsideDayColumn( appointments: outside[d], - maxVisible: _maxVisibleChips, - chipHeight: _chipHeight, - chipSpacing: _chipSpacing, onAppointmentTap: onAppointmentTap, isCrossedOut: isCrossedOut, ), @@ -63,17 +55,11 @@ class _OutsideHoursStrip extends StatelessWidget { class _OutsideDayColumn extends StatelessWidget { final List appointments; - final int maxVisible; - final double chipHeight; - final double chipSpacing; final void Function(Appointment) onAppointmentTap; final bool Function(Appointment) isCrossedOut; const _OutsideDayColumn({ required this.appointments, - required this.maxVisible, - required this.chipHeight, - required this.chipSpacing, required this.onAppointmentTap, required this.isCrossedOut, }); @@ -132,11 +118,12 @@ class _OutsideDayColumn extends StatelessWidget { if (!aLike && bLike) return 1; return a.startTime.compareTo(b.startTime); }); - final visible = sorted.length <= maxVisible + final visible = sorted.length <= kOutsideChipsMaxVisible ? sorted - : sorted.take(maxVisible - 1).toList(); - final overflow = - sorted.length <= maxVisible ? const [] : sorted.skip(maxVisible - 1).toList(); + : sorted.take(kOutsideChipsMaxVisible - 1).toList(); + final overflow = sorted.length <= kOutsideChipsMaxVisible + ? const [] + : sorted.skip(kOutsideChipsMaxVisible - 1).toList(); return Padding( padding: const EdgeInsets.symmetric(horizontal: 2), @@ -145,9 +132,9 @@ class _OutsideDayColumn extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (var i = 0; i < visible.length; i++) ...[ - if (i > 0) SizedBox(height: chipSpacing), + if (i > 0) const SizedBox(height: kOutsideChipSpacing), SizedBox( - height: chipHeight, + height: kOutsideChipHeight, child: _OutsideChip( appointment: visible[i], onTap: () => onAppointmentTap(visible[i]), @@ -155,9 +142,9 @@ class _OutsideDayColumn extends StatelessWidget { ), ], if (overflow.isNotEmpty) ...[ - SizedBox(height: chipSpacing), + const SizedBox(height: kOutsideChipSpacing), SizedBox( - height: chipHeight, + height: kOutsideChipHeight, child: _OutsideOverflowChip( count: overflow.length, onTap: () => _showOverflow(context, overflow), diff --git a/lib/view/pages/timetable/widgets/calendar/week_grid.dart b/lib/view/pages/timetable/widgets/calendar/week_grid.dart index df4741f..4cb7762 100644 --- a/lib/view/pages/timetable/widgets/calendar/week_grid.dart +++ b/lib/view/pages/timetable/widgets/calendar/week_grid.dart @@ -429,8 +429,7 @@ class _OverflowTile extends StatelessWidget { padding: const EdgeInsets.all(1), child: Stack( children: [ - // Card peeking out at the bottom — visual hint that more cards lie - // underneath the visible one. + // Stacked-cards effect: a darker layer peeks out below the front card. Positioned( top: 4, left: 2, @@ -443,7 +442,6 @@ class _OverflowTile extends StatelessWidget { ), ), ), - // Front card with the "+N" indicator. Positioned( top: 0, left: 0, diff --git a/lib/widget/async_actions/async_text_button.dart b/lib/widget/async_actions/async_text_button.dart index 2938089..cf662f6 100644 --- a/lib/widget/async_actions/async_text_button.dart +++ b/lib/widget/async_actions/async_text_button.dart @@ -40,10 +40,9 @@ class AsyncTextButton extends StatelessWidget { ], ) : child; - return _InlineErrorWrapper( - controller: controller, - child: TextButton(onPressed: handler, child: content), - ); + final button = TextButton(onPressed: handler, child: content); + if (!showInlineError) return button; + return _InlineErrorWrapper(controller: controller, child: button); }, ); } diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index 4f4e1a2..80c5664 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -81,7 +81,7 @@ class _DeferredPdfViewerState extends State<_DeferredPdfViewer> { } class _FileViewerState extends State { - PhotoViewController photoViewController = PhotoViewController(); + final PhotoViewController photoViewController = PhotoViewController(); late SettingsCubit settings = context.read(); late bool openExternal; @@ -92,6 +92,12 @@ class _FileViewerState extends State { super.initState(); } + @override + void dispose() { + photoViewController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { AppBar appbar({List actions = const []}) => AppBar( @@ -195,10 +201,8 @@ class _FileViewerState extends State { OpenFilex.open(widget.path).then((result) { if (!context.mounted) return; Navigator.of(context).pop(); - if(result.type != ResultType.done) { - showDialog(context: context, builder: (context) => AlertDialog( - content: Text(result.message), - )); + if (result.type != ResultType.done) { + InfoDialog.show(context, result.message); } }); diff --git a/test/api/errors/error_mapper_test.dart b/test/api/errors/error_mapper_test.dart index 2f8bab8..48a4851 100644 --- a/test/api/errors/error_mapper_test.dart +++ b/test/api/errors/error_mapper_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:marianum_mobile/api/api_error.dart'; @@ -36,8 +37,9 @@ void main() { }); test('HandshakeException maps to a TLS-specific message', () { - expect(errorToUserMessage(const HandshakeException('bad cert')), - 'Sichere Verbindung konnte nicht hergestellt werden.'); + final message = errorToUserMessage(const HandshakeException('bad cert')); + expect(message, contains('sichere Verbindung')); + expect(message, contains('Geräte-Uhrzeit')); }); test('FormatException maps to ParseException message', () { @@ -63,6 +65,34 @@ void main() { test('custom fallback overrides the default', () { expect(errorToUserMessage(null, fallback: 'meins'), 'meins'); }); + + test('DioException connectionTimeout maps to timeout NetworkException', () { + final ex = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.connectionTimeout, + ); + expect(errorToUserMessage(ex), NetworkException.timeout().userMessage); + }); + + test('DioException connectionError maps to NetworkException', () { + final ex = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.connectionError, + ); + expect(errorToUserMessage(ex), const NetworkException().userMessage); + }); + + test('DioException badResponse maps to a server status message', () { + final ex = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.badResponse, + response: Response( + requestOptions: RequestOptions(path: '/x'), + statusCode: 503, + ), + ); + expect(errorToUserMessage(ex), contains('503')); + }); }); group('errorToTechnicalDetails', () { From 3b8da1d3d669068c3aa4c29a359e3abe54aeee1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Fri, 8 May 2026 20:12:40 +0200 Subject: [PATCH 23/23] dart format --- lib/api/api_params.dart | 4 +- lib/api/api_request.dart | 6 +- lib/api/api_response.dart | 1 + lib/api/errors/auth_exception.dart | 11 +- lib/api/errors/error_mapper.dart | 21 +- lib/api/errors/network_exception.dart | 9 +- lib/api/errors/server_exception.dart | 8 +- lib/api/errors/talk_exception.dart | 15 +- lib/api/errors/webuntis_exception.dart | 10 +- .../autocomplete/autocomplete_api.dart | 8 +- .../autocomplete/autocomplete_response.dart | 17 +- .../files_sharing/file_sharing_api.dart | 4 +- .../file_sharing_api_params.dart | 5 +- lib/api/marianumcloud/nextcloud_ocs.dart | 8 +- .../talk/actions/talk_actions.dart | 32 +- lib/api/marianumcloud/talk/chat/get_chat.dart | 10 +- .../talk/chat/get_chat_cache.dart | 22 +- .../talk/chat/get_chat_params.dart | 23 +- .../talk/chat/get_chat_response.dart | 77 ++-- .../chat/rich_object_string_processor.dart | 8 +- .../talk/create_room/create_room.dart | 18 +- .../talk/create_room/create_room_params.dart | 5 +- .../delete_react_message.dart | 15 +- .../delete_react_message_params.dart | 3 +- .../get_participants/get_participants.dart | 11 +- .../get_participants_cache.dart | 10 +- .../get_participants_response.dart | 71 ++-- .../talk/get_poll/get_poll_state.dart | 13 +- .../get_poll/get_poll_state_response.dart | 33 +- .../talk/get_reactions/get_reactions.dart | 14 +- .../get_reactions/get_reactions_response.dart | 19 +- .../talk/react_message/react_message.dart | 15 +- .../react_message/react_message_params.dart | 3 +- lib/api/marianumcloud/talk/room/get_room.dart | 10 +- .../talk/room/get_room_cache.dart | 10 +- .../talk/room/get_room_params.dart | 15 +- .../talk/room/get_room_response.dart | 137 ++++--- .../talk/send_message/send_message.dart | 12 +- .../send_message/send_message_params.dart | 3 +- .../talk/set_read_marker/set_read_marker.dart | 20 +- .../set_read_marker_params.dart | 7 +- lib/api/marianumcloud/talk/talk_api.dart | 26 +- .../queries/download_file/download_file.dart | 2 - .../download_file/download_file_params.dart | 9 +- .../download_file/download_file_response.dart | 4 +- .../queries/list_files/cacheable_file.dart | 14 +- .../webdav/queries/list_files/list_files.dart | 10 +- .../queries/list_files/list_files_cache.dart | 19 +- .../queries/list_files/list_files_params.dart | 3 +- .../list_files/list_files_response.dart | 90 +++-- lib/api/marianumcloud/webdav/webdav_api.dart | 7 +- .../breaker/get_breakers/get_breakers.dart | 4 +- .../get_breakers/get_breakers_cache.dart | 10 +- .../get_breakers/get_breakers_response.dart | 22 +- .../add/add_custom_timetable_event.dart | 2 +- .../add_custom_timetable_event_params.dart | 3 +- .../custom_timetable_event.dart | 16 +- .../get/get_custom_timetable_event.dart | 6 +- .../get/get_custom_timetable_event_cache.dart | 11 +- .../get_custom_timetable_event_params.dart | 3 +- .../get_custom_timetable_event_response.dart | 6 +- .../remove/remove_custom_timetable_event.dart | 6 +- .../remove_custom_timetable_event_params.dart | 7 +- .../update/update_custom_timetable_event.dart | 6 +- .../update_custom_timetable_event_params.dart | 8 +- lib/api/mhsl/mhsl_api.dart | 7 +- .../mhsl/notify/register/notify_register.dart | 6 +- .../register/notify_register_params.dart | 5 +- .../mhsl/server/feedback/add_feedback.dart | 4 +- .../server/feedback/add_feedback_params.dart | 4 +- .../update/update_user_index_params.dart | 6 +- .../user_index/update/update_userindex.dart | 23 +- lib/api/request_cache.dart | 63 ++-- .../queries/authenticate/authenticate.dart | 22 +- .../authenticate/authenticate_params.dart | 4 +- .../authenticate/authenticate_response.dart | 11 +- .../queries/get_holidays/get_holidays.dart | 14 +- .../get_holidays/get_holidays_cache.dart | 10 +- .../get_holidays/get_holidays_response.dart | 14 +- .../webuntis/queries/get_rooms/get_rooms.dart | 7 +- .../queries/get_rooms/get_rooms_cache.dart | 10 +- .../queries/get_rooms/get_rooms_response.dart | 14 +- .../queries/get_subjects/get_subjects.dart | 6 +- .../get_subjects/get_subjects_cache.dart | 10 +- .../get_subjects/get_subjects_response.dart | 14 +- .../get_timegrid_units.dart | 14 +- .../get_timegrid_units_cache.dart | 10 +- .../get_timegrid_units_response.dart | 9 +- .../queries/get_timetable/get_timetable.dart | 7 +- .../get_timetable/get_timetable_cache.dart | 10 +- .../get_timetable/get_timetable_params.dart | 50 ++- .../get_timetable/get_timetable_response.dart | 70 +++- .../webuntis/services/lesson_resolver.dart | 12 +- lib/api/webuntis/webuntis_api.dart | 41 ++- lib/app.dart | 164 +++++---- lib/extensions/date_time.dart | 24 +- lib/extensions/render_not_null.dart | 3 +- lib/extensions/text.dart | 6 +- lib/extensions/time_of_day.dart | 3 +- lib/firebase_options.dart | 3 +- lib/main.dart | 265 ++++++++------ lib/model/account_data.dart | 17 +- lib/model/data_cleaner.dart | 17 +- lib/model/endpoint_data.dart | 38 +- lib/notification/notification_controller.dart | 17 +- lib/notification/notification_service.dart | 24 +- lib/notification/notification_tasks.dart | 4 +- lib/notification/notify_updater.dart | 51 +-- lib/routing/app_routes.dart | 39 +- .../basis/dataloader/holiday_data_loader.dart | 5 +- .../basis/dataloader/mhsl_data_loader.dart | 7 +- .../data_loader/data_loader.dart | 15 +- .../bloc/loadable_state_bloc.dart | 48 ++- .../bloc/loadable_state_event.dart | 1 + .../loadable_state_background_loading.dart | 21 +- .../view/loadable_state_consumer.dart | 35 +- .../view/loadable_state_error_bar.dart | 87 +++-- .../view/loadable_state_error_screen.dart | 94 +++-- .../view/loadable_state_primary_loading.dart | 10 +- .../utility_widgets/bloc_module.dart | 24 +- .../loadable_hydrated_bloc.dart | 155 ++++---- .../loadable_hydrated_bloc_event.dart | 5 + .../loadable_save_context.dart | 21 +- .../modules/account/bloc/account_bloc.dart | 7 +- .../modules/account/bloc/account_state.dart | 3 +- lib/state/app/modules/app_modules.dart | 83 ++++- .../modules/breaker/bloc/breaker_bloc.dart | 7 +- .../modules/breaker/bloc/breaker_state.dart | 7 +- .../data_provider/breaker_data_provider.dart | 8 +- .../repository/breaker_repository.dart | 3 +- .../app/modules/chat/bloc/chat_bloc.dart | 17 +- .../app/modules/chat/bloc/chat_state.dart | 3 +- .../chat/repository/chat_repository.dart | 3 +- .../chat_list/bloc/chat_list_bloc.dart | 26 +- .../chat_list/bloc/chat_list_state.dart | 7 +- .../chat_list_data_provider.dart | 16 +- .../repository/chat_list_repository.dart | 3 +- .../app/modules/files/bloc/files_bloc.dart | 28 +- .../app/modules/files/bloc/files_state.dart | 3 +- .../data_provider/files_data_provider.dart | 21 +- .../files/repository/files_repository.dart | 3 +- .../bloc/grade_averages_bloc.dart | 43 ++- .../bloc/grade_averages_event.dart | 5 +- .../bloc/grade_averages_state.dart | 8 +- .../modules/holidays/bloc/holidays_bloc.dart | 39 +- .../modules/holidays/bloc/holidays_event.dart | 2 + .../modules/holidays/bloc/holidays_state.dart | 6 +- .../data_provider/holidays_get_holidays.dart | 3 +- .../bloc/marianum_dates_bloc.dart | 27 +- .../bloc/marianum_dates_event.dart | 3 +- .../bloc/marianum_dates_state.dart | 6 +- .../marianum_dates_get_events.dart | 26 +- .../bloc/marianum_message_bloc.dart | 15 +- .../bloc/marianum_message_event.dart | 4 +- .../bloc/marianum_message_state.dart | 16 +- .../marianum_message_get_messages.dart | 3 +- .../marianum_message_repository.dart | 3 +- .../modules/settings/bloc/settings_cubit.dart | 29 +- .../timetable/bloc/timetable_bloc.dart | 78 +++- .../timetable/bloc/timetable_state.dart | 12 +- .../timetable_data_provider.dart | 106 +++--- .../repository/timetable_repository.dart | 3 +- lib/storage/dev_tools_settings.dart | 9 +- lib/storage/file_settings.dart | 9 +- lib/storage/file_view_settings.dart | 3 +- lib/storage/holidays_settings.dart | 8 +- lib/storage/modules_settings.dart | 3 +- lib/storage/notification_settings.dart | 8 +- lib/storage/settings.dart | 11 +- lib/storage/talk_settings.dart | 10 +- lib/storage/timetable_settings.dart | 3 +- lib/theming/app_theme.dart | 21 +- lib/theming/light_app_theme.dart | 4 +- lib/utils/cache_invalidation_bus.dart | 3 +- lib/utils/download_manager.dart | 9 +- lib/utils/file_downloader.dart | 34 +- lib/utils/url_opener.dart | 2 +- lib/view/login/login.dart | 51 +-- lib/view/login/widgets/login_branding.dart | 106 +++--- lib/view/login/widgets/login_card.dart | 34 +- .../login/widgets/login_error_banner.dart | 27 +- lib/view/pages/files/data/sort_options.dart | 6 +- lib/view/pages/files/files.dart | 50 ++- lib/view/pages/files/files_upload_dialog.dart | 345 ++++++++++-------- .../pages/files/widgets/clipboard_banner.dart | 101 +++-- .../files/widgets/file_details_sheet.dart | 62 ++-- .../pages/files/widgets/file_element.dart | 68 ++-- .../files/widgets/files_sort_actions.dart | 59 +-- .../grade_averages_list_view.dart | 14 +- .../grade_averages/grade_averages_view.dart | 103 +++--- lib/view/pages/holidays/holidays_view.dart | 163 +++++---- .../marianum_dates/marianum_dates_view.dart | 46 ++- .../marianum_dates/search_marianum_dates.dart | 12 +- .../widgets/event_details_sheet.dart | 8 +- .../widgets/event_list_tile.dart | 49 +-- .../widgets/month_section_header.dart | 9 +- .../marianum_message_list_view.dart | 52 +-- .../marianum_message_view.dart | 54 +-- .../pages/more/feedback/feedback_dialog.dart | 207 ++++++----- lib/view/pages/more/roomplan/roomplan.dart | 18 +- .../more/share/app_share_platform_view.dart | 9 +- lib/view/pages/more/share/qr_share_view.dart | 38 +- .../more/share/select_share_type_dialog.dart | 19 +- lib/view/pages/overhang.dart | 121 +++--- .../pages/settings/data/default_settings.dart | 96 +++-- .../pages/settings/modules_settings_page.dart | 215 ++++++----- .../settings/sections/about_section.dart | 104 +++--- .../settings/sections/account_section.dart | 10 +- .../settings/sections/appearance_section.dart | 24 +- .../settings/sections/dev_tools_section.dart | 244 +++++++------ .../settings/sections/files_section.dart | 10 +- .../settings/sections/modules_section.dart | 12 +- .../pages/settings/sections/talk_section.dart | 26 +- .../settings/sections/timetable_section.dart | 35 +- lib/view/pages/settings/settings.dart | 38 +- .../pages/settings/widgets/privacy_info.dart | 6 +- lib/view/pages/talk/chat_list.dart | 38 +- lib/view/pages/talk/chat_view.dart | 169 +++++---- .../pages/talk/data/chat_bubble_styles.dart | 19 +- lib/view/pages/talk/data/chat_message.dart | 83 +++-- lib/view/pages/talk/details/chat_info.dart | 36 +- .../pages/talk/details/message_reactions.dart | 100 ++--- .../talk/details/participants_list_view.dart | 62 ++-- lib/view/pages/talk/join_chat.dart | 26 +- lib/view/pages/talk/search_chat.dart | 19 +- lib/view/pages/talk/talk_navigator.dart | 19 +- .../pages/talk/widgets/answer_reference.dart | 32 +- lib/view/pages/talk/widgets/bubble.dart | 42 ++- lib/view/pages/talk/widgets/chat_bubble.dart | 187 ++++++---- .../pages/talk/widgets/chat_bubble_poll.dart | 10 +- .../talk/widgets/chat_bubble_reactions.dart | 12 +- .../widgets/chat_message_options_dialog.dart | 115 +++--- .../pages/talk/widgets/chat_textfield.dart | 308 +++++++++------- lib/view/pages/talk/widgets/chat_tile.dart | 59 ++- .../pages/talk/widgets/poll_options_list.dart | 51 +-- .../talk/widgets/split_view_placeholder.dart | 38 +- .../custom_events/custom_event_colors.dart | 24 +- .../custom_event_edit_dialog.dart | 222 +++++------ .../custom_events/custom_events_view.dart | 110 +++--- .../timetable/data/arbitrary_appointment.dart | 6 +- .../pages/timetable/data/calendar_logic.dart | 100 +++-- .../data/lesson_period_schedule.dart | 128 +++++-- .../pages/timetable/data/lesson_status.dart | 13 +- .../data/timetable_appointment_factory.dart | 64 +++- .../timetable/data/timetable_name_mode.dart | 15 +- .../pages/timetable/data/webuntis_time.dart | 4 +- .../appointment_details_dispatcher.dart | 9 +- .../timetable/details/custom_event_sheet.dart | 30 +- .../details/delete_custom_event.dart | 8 +- .../details/webuntis_lesson_sheet.dart | 52 ++- lib/view/pages/timetable/timetable.dart | 33 +- .../timetable/widgets/appointment_tile.dart | 64 ++-- .../widgets/calendar/day_header.dart | 27 +- .../widgets/calendar/outside_chips.dart | 58 +-- .../timetable/widgets/calendar/week_grid.dart | 214 ++++++----- .../widgets/custom_workweek_calendar.dart | 28 +- .../widgets/special_regions_builder.dart | 82 +++-- .../timetable/widgets/time_region_tile.dart | 6 +- lib/widget/about/about.dart | 17 +- lib/widget/animated_time.dart | 57 +-- lib/widget/app_progress_indicator.dart | 6 +- .../async_actions/async_action_button.dart | 56 +-- .../async_action_controller.dart | 8 +- .../async_actions/async_dialog_action.dart | 109 +++--- lib/widget/async_actions/async_fab.dart | 32 +- .../async_actions/async_icon_button.dart | 36 +- lib/widget/async_actions/async_list_tile.dart | 72 ++-- lib/widget/async_actions/async_mixin.dart | 19 +- .../async_actions/async_text_button.dart | 46 +-- lib/widget/breaker/breaker.dart | 3 +- lib/widget/centered_leading.dart | 8 +- lib/widget/clickable_app_bar.dart | 3 +- lib/widget/confirm_dialog.dart | 61 ++-- lib/widget/debug/cache_view.dart | 83 +++-- lib/widget/debug/debug_tile.dart | 28 +- lib/widget/debug/json_viewer.dart | 45 ++- lib/widget/details_bottom_sheet.dart | 5 +- lib/widget/file_pick.dart | 3 +- lib/widget/file_viewer.dart | 171 +++++---- lib/widget/large_profile_picture_view.dart | 16 +- lib/widget/list_view_util.dart | 11 +- lib/widget/loading_spinner.dart | 39 +- lib/widget/placeholder_view.dart | 9 +- lib/widget/share_position_origin.dart | 7 +- lib/widget/string_extensions.dart | 3 +- lib/widget/unimplemented_dialog.dart | 6 +- lib/widget/user_avatar.dart | 17 +- test/api/errors/error_mapper_test.dart | 37 +- .../rich_object_string_processor_test.dart | 25 +- test/api/webuntis/lesson_resolver_test.dart | 39 +- test/utils/debouncer_test.dart | 157 +++++--- test/view/files/sort_options_test.dart | 52 ++- .../marianum_dates/event_formatter_test.dart | 63 ++-- test/view/timetable/calendar_logic_test.dart | 262 ++++++++----- test/widget/async_action_controller_test.dart | 42 ++- 295 files changed, 6404 insertions(+), 4161 deletions(-) diff --git a/lib/api/api_params.dart b/lib/api/api_params.dart index b679813..5e9f07f 100644 --- a/lib/api/api_params.dart +++ b/lib/api/api_params.dart @@ -1,3 +1 @@ -class ApiParams { - -} +class ApiParams {} diff --git a/lib/api/api_request.dart b/lib/api/api_request.dart index 705ccbc..2cdf97c 100644 --- a/lib/api/api_request.dart +++ b/lib/api/api_request.dart @@ -1,5 +1 @@ - - -class ApiRequest { - -} +class ApiRequest {} diff --git a/lib/api/api_response.dart b/lib/api/api_response.dart index bcbc91c..cef5697 100644 --- a/lib/api/api_response.dart +++ b/lib/api/api_response.dart @@ -1,5 +1,6 @@ import 'package:http/http.dart' as http; import 'package:json_annotation/json_annotation.dart'; + abstract class ApiResponse { @JsonKey(includeFromJson: false, includeToJson: false) late http.Response rawResponse; diff --git a/lib/api/errors/auth_exception.dart b/lib/api/errors/auth_exception.dart index 60a70f7..495bafd 100644 --- a/lib/api/errors/auth_exception.dart +++ b/lib/api/errors/auth_exception.dart @@ -9,15 +9,16 @@ class AuthException extends AppException { super.technicalDetails, }) : super(allowRetry: false); - factory AuthException.unauthorized({String? technicalDetails}) => AuthException( + factory AuthException.unauthorized({String? technicalDetails}) => + AuthException( statusCode: 401, userMessage: 'Bitte melde dich erneut an, um fortzufahren.', technicalDetails: technicalDetails, ); factory AuthException.forbidden({String? technicalDetails}) => AuthException( - statusCode: 403, - userMessage: 'Du hast keine Berechtigung für diese Aktion.', - technicalDetails: technicalDetails, - ); + statusCode: 403, + userMessage: 'Du hast keine Berechtigung für diese Aktion.', + technicalDetails: technicalDetails, + ); } diff --git a/lib/api/errors/error_mapper.dart b/lib/api/errors/error_mapper.dart index 1807359..a5db580 100644 --- a/lib/api/errors/error_mapper.dart +++ b/lib/api/errors/error_mapper.dart @@ -14,7 +14,8 @@ import 'server_exception.dart'; import 'talk_exception.dart'; import 'webuntis_exception.dart'; -const String _defaultFallback = 'Etwas ist schiefgelaufen. Bitte versuche es erneut.'; +const String _defaultFallback = + 'Etwas ist schiefgelaufen. Bitte versuche es erneut.'; const String _tlsErrorMessage = 'Die sichere Verbindung zum Server wurde abgelehnt (Zertifikat oder TLS-Fehler). ' 'Häufige Ursachen: falsche Geräte-Uhrzeit oder ein WLAN mit Anmeldeseite (z.B. Café/Hotel).'; @@ -28,9 +29,7 @@ AppException? _dioToAppException(DioException error) { case DioExceptionType.connectionError: return NetworkException(technicalDetails: error.message); case DioExceptionType.badCertificate: - return const NetworkException( - userMessage: _tlsErrorMessage, - ); + return const NetworkException(userMessage: _tlsErrorMessage); case DioExceptionType.badResponse: final status = error.response?.statusCode; return ServerException( @@ -40,13 +39,15 @@ AppException? _dioToAppException(DioException error) { case DioExceptionType.cancel: case DioExceptionType.unknown: final inner = error.error; - if (inner is SocketException) return NetworkException(technicalDetails: inner.message); - if (inner is HandshakeException) { - return const NetworkException( - userMessage: _tlsErrorMessage, - ); + if (inner is SocketException) { + return NetworkException(technicalDetails: inner.message); + } + if (inner is HandshakeException) { + return const NetworkException(userMessage: _tlsErrorMessage); + } + if (inner is FormatException) { + return ParseException(technicalDetails: inner.message); } - if (inner is FormatException) return ParseException(technicalDetails: inner.message); return null; } } diff --git a/lib/api/errors/network_exception.dart b/lib/api/errors/network_exception.dart index 10fbb56..06b38df 100644 --- a/lib/api/errors/network_exception.dart +++ b/lib/api/errors/network_exception.dart @@ -2,12 +2,15 @@ import 'app_exception.dart'; class NetworkException extends AppException { const NetworkException({ - super.userMessage = 'Keine Internetverbindung. Bitte prüfe dein Netzwerk und versuche es erneut.', + super.userMessage = + 'Keine Internetverbindung. Bitte prüfe dein Netzwerk und versuche es erneut.', super.technicalDetails, }) : super(allowRetry: true); - factory NetworkException.timeout({String? technicalDetails}) => NetworkException( - userMessage: 'Der Server hat zu lange gebraucht. Bitte versuche es erneut.', + factory NetworkException.timeout({String? technicalDetails}) => + NetworkException( + userMessage: + 'Der Server hat zu lange gebraucht. Bitte versuche es erneut.', technicalDetails: technicalDetails, ); } diff --git a/lib/api/errors/server_exception.dart b/lib/api/errors/server_exception.dart index efabca2..9d5aab1 100644 --- a/lib/api/errors/server_exception.dart +++ b/lib/api/errors/server_exception.dart @@ -8,7 +8,9 @@ class ServerException extends AppException { String? userMessage, super.technicalDetails, }) : super( - userMessage: userMessage ?? 'Der Server hat gerade Probleme (Status $statusCode). Bitte später erneut versuchen.', - allowRetry: true, - ); + userMessage: + userMessage ?? + 'Der Server hat gerade Probleme (Status $statusCode). Bitte später erneut versuchen.', + allowRetry: true, + ); } diff --git a/lib/api/errors/talk_exception.dart b/lib/api/errors/talk_exception.dart index 534d1b2..52190c2 100644 --- a/lib/api/errors/talk_exception.dart +++ b/lib/api/errors/talk_exception.dart @@ -5,11 +5,12 @@ class TalkException extends AppException { final TalkError source; TalkException(this.source) - : super( - userMessage: _mapMessage(source), - technicalDetails: 'Talk ${source.status} (${source.code}): ${source.message}', - allowRetry: source.code >= 500, - ); + : super( + userMessage: _mapMessage(source), + technicalDetails: + 'Talk ${source.status} (${source.code}): ${source.message}', + allowRetry: source.code >= 500, + ); static String _mapMessage(TalkError e) { switch (e.code) { @@ -27,7 +28,9 @@ class TalkException extends AppException { if (e.code >= 500) { return 'Talk-Server hat gerade Probleme (${e.code}).'; } - return e.message.isNotEmpty ? e.message : 'Talk meldet einen Fehler (${e.code}).'; + return e.message.isNotEmpty + ? e.message + : 'Talk meldet einen Fehler (${e.code}).'; } } } diff --git a/lib/api/errors/webuntis_exception.dart b/lib/api/errors/webuntis_exception.dart index 211f5ae..c09f48a 100644 --- a/lib/api/errors/webuntis_exception.dart +++ b/lib/api/errors/webuntis_exception.dart @@ -5,11 +5,11 @@ class WebuntisException extends AppException { final WebuntisError source; WebuntisException(this.source) - : super( - userMessage: _mapMessage(source), - technicalDetails: 'WebUntis (${source.code}): ${source.message}', - allowRetry: true, - ); + : super( + userMessage: _mapMessage(source), + technicalDetails: 'WebUntis (${source.code}): ${source.message}', + allowRetry: true, + ); static String _mapMessage(WebuntisError e) { switch (e.code) { diff --git a/lib/api/marianumcloud/autocomplete/autocomplete_api.dart b/lib/api/marianumcloud/autocomplete/autocomplete_api.dart index e1bb9e3..c18e4d9 100644 --- a/lib/api/marianumcloud/autocomplete/autocomplete_api.dart +++ b/lib/api/marianumcloud/autocomplete/autocomplete_api.dart @@ -20,9 +20,13 @@ class AutocompleteApi { ); final response = await http.get(endpoint, headers: NextcloudOcs.headers()); if (response.statusCode != HttpStatus.ok) { - throw Exception('Api call failed with ${response.statusCode}: ${response.body}'); + throw Exception( + 'Api call failed with ${response.statusCode}: ${response.body}', + ); } final decoded = jsonDecode(response.body) as Map; - return AutocompleteResponse.fromJson(decoded['ocs'] as Map); + return AutocompleteResponse.fromJson( + decoded['ocs'] as Map, + ); } } diff --git a/lib/api/marianumcloud/autocomplete/autocomplete_response.dart b/lib/api/marianumcloud/autocomplete/autocomplete_response.dart index 60b4e7b..d3e938d 100644 --- a/lib/api/marianumcloud/autocomplete/autocomplete_response.dart +++ b/lib/api/marianumcloud/autocomplete/autocomplete_response.dart @@ -8,7 +8,8 @@ class AutocompleteResponse { AutocompleteResponse(this.data); - factory AutocompleteResponse.fromJson(Map json) => _$AutocompleteResponseFromJson(json); + factory AutocompleteResponse.fromJson(Map json) => + _$AutocompleteResponseFromJson(json); Map toJson() => _$AutocompleteResponseToJson(this); } @@ -22,9 +23,17 @@ class AutocompleteResponseObject { String? subline; String? shareWithDisplayNameUniqe; - AutocompleteResponseObject(this.id, this.label, this.icon, this.source, this.status, - this.subline, this.shareWithDisplayNameUniqe); + AutocompleteResponseObject( + this.id, + this.label, + this.icon, + this.source, + this.status, + this.subline, + this.shareWithDisplayNameUniqe, + ); - factory AutocompleteResponseObject.fromJson(Map json) => _$AutocompleteResponseObjectFromJson(json); + factory AutocompleteResponseObject.fromJson(Map json) => + _$AutocompleteResponseObjectFromJson(json); Map toJson() => _$AutocompleteResponseObjectToJson(this); } diff --git a/lib/api/marianumcloud/files_sharing/file_sharing_api.dart b/lib/api/marianumcloud/files_sharing/file_sharing_api.dart index 1551ada..c78ecaf 100644 --- a/lib/api/marianumcloud/files_sharing/file_sharing_api.dart +++ b/lib/api/marianumcloud/files_sharing/file_sharing_api.dart @@ -13,7 +13,9 @@ class FileSharingApi { ); final response = await http.post(endpoint, headers: NextcloudOcs.headers()); if (response.statusCode != HttpStatus.ok) { - throw Exception('Api call failed with ${response.statusCode}: ${response.body}'); + throw Exception( + 'Api call failed with ${response.statusCode}: ${response.body}', + ); } } } diff --git a/lib/api/marianumcloud/files_sharing/file_sharing_api_params.dart b/lib/api/marianumcloud/files_sharing/file_sharing_api_params.dart index 4078d29..7f70f86 100644 --- a/lib/api/marianumcloud/files_sharing/file_sharing_api_params.dart +++ b/lib/api/marianumcloud/files_sharing/file_sharing_api_params.dart @@ -15,9 +15,10 @@ class FileSharingApiParams { required this.shareWith, required this.path, this.referenceId, - this.talkMetaData + this.talkMetaData, }); - factory FileSharingApiParams.fromJson(Map json) => _$FileSharingApiParamsFromJson(json); + factory FileSharingApiParams.fromJson(Map json) => + _$FileSharingApiParamsFromJson(json); Map toJson() => _$FileSharingApiParamsToJson(this); } diff --git a/lib/api/marianumcloud/nextcloud_ocs.dart b/lib/api/marianumcloud/nextcloud_ocs.dart index b04d770..c7086a0 100644 --- a/lib/api/marianumcloud/nextcloud_ocs.dart +++ b/lib/api/marianumcloud/nextcloud_ocs.dart @@ -7,10 +7,10 @@ class NextcloudOcs { NextcloudOcs._(); static Map headers() => { - 'Accept': 'application/json', - 'OCS-APIRequest': 'true', - 'Authorization': AccountData().getBasicAuthHeader(), - }; + 'Accept': 'application/json', + 'OCS-APIRequest': 'true', + 'Authorization': AccountData().getBasicAuthHeader(), + }; static Uri uri(String pathSuffix, {Map? queryParameters}) { final endpoint = EndpointData().nextcloud(); diff --git a/lib/api/marianumcloud/talk/actions/talk_actions.dart b/lib/api/marianumcloud/talk/actions/talk_actions.dart index 59272cb..57a10d0 100644 --- a/lib/api/marianumcloud/talk/actions/talk_actions.dart +++ b/lib/api/marianumcloud/talk/actions/talk_actions.dart @@ -12,39 +12,53 @@ class SetFavorite extends TalkApi { final String chatToken; final bool favoriteState; - SetFavorite(this.chatToken, this.favoriteState) : super('v4/room/$chatToken/favorite', null); + SetFavorite(this.chatToken, this.favoriteState) + : super('v4/room/$chatToken/favorite', null); @override ApiResponse? assemble(String raw) => null; @override - Future request(Uri uri, ApiParams? body, Map? headers) => - favoriteState ? http.post(uri, headers: headers) : http.delete(uri, headers: headers); + Future request( + Uri uri, + ApiParams? body, + Map? headers, + ) => favoriteState + ? http.post(uri, headers: headers) + : http.delete(uri, headers: headers); } class LeaveRoom extends TalkApi { final String chatToken; - LeaveRoom(this.chatToken) : super('v4/room/$chatToken/participants/self', null); + LeaveRoom(this.chatToken) + : super('v4/room/$chatToken/participants/self', null); @override ApiResponse? assemble(String raw) => null; @override - Future request(Uri uri, ApiParams? body, Map? headers) => - http.delete(uri, headers: headers); + Future request( + Uri uri, + ApiParams? body, + Map? headers, + ) => http.delete(uri, headers: headers); } class DeleteMessage extends TalkApi { final String chatToken; final int messageId; - DeleteMessage(this.chatToken, this.messageId) : super('v1/chat/$chatToken/$messageId', null); + DeleteMessage(this.chatToken, this.messageId) + : super('v1/chat/$chatToken/$messageId', null); @override ApiResponse? assemble(String raw) => null; @override - Future request(Uri uri, ApiParams? body, Map? headers) => - http.delete(uri, headers: headers); + Future request( + Uri uri, + ApiParams? body, + Map? headers, + ) => http.delete(uri, headers: headers); } diff --git a/lib/api/marianumcloud/talk/chat/get_chat.dart b/lib/api/marianumcloud/talk/chat/get_chat.dart index 9009744..ff13903 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat.dart @@ -11,7 +11,8 @@ class GetChat extends TalkApi { String chatToken; GetChatParams params; - GetChat(this.chatToken, this.params) : super('v1/chat/$chatToken', null, getParameters: params.toJson()); + GetChat(this.chatToken, this.params) + : super('v1/chat/$chatToken', null, getParameters: params.toJson()); @override GetChatResponse assemble(String raw) { @@ -20,6 +21,9 @@ class GetChat extends TalkApi { } @override - Future request(Uri uri, Object? body, Map? headers) => http.get(uri, headers: headers); - + Future request( + Uri uri, + Object? body, + Map? headers, + ) => http.get(uri, headers: headers); } diff --git a/lib/api/marianumcloud/talk/chat/get_chat_cache.dart b/lib/api/marianumcloud/talk/chat/get_chat_cache.dart index b15a886..01ee56a 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat_cache.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_cache.dart @@ -10,17 +10,17 @@ class GetChatCache extends SimpleCache { super.onError, required String chatToken, }) : super( - cacheTime: RequestCache.cacheNothing, - loader: () => GetChat( - chatToken, - GetChatParams( - lookIntoFuture: GetChatParamsSwitch.off, - setReadMarker: GetChatParamsSwitch.on, - limit: 200, - ), - ).run(), - fromJson: GetChatResponse.fromJson, - ) { + cacheTime: RequestCache.cacheNothing, + loader: () => GetChat( + chatToken, + GetChatParams( + lookIntoFuture: GetChatParamsSwitch.off, + setReadMarker: GetChatParamsSwitch.on, + limit: 200, + ), + ).run(), + fromJson: GetChatResponse.fromJson, + ) { start('nc-chat-$chatToken'); } } diff --git a/lib/api/marianumcloud/talk/chat/get_chat_params.dart b/lib/api/marianumcloud/talk/chat/get_chat_params.dart index 5287a3b..88b4c3a 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat_params.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_params.dart @@ -15,20 +15,23 @@ class GetChatParams extends ApiParams { GetChatParamsSwitch? includeLastKnown; GetChatParams({ - required this.lookIntoFuture, - this.limit, - this.lastKnownMessageId, - this.lastCommonReadId, - this.timeout, - this.setReadMarker, - this.includeLastKnown + required this.lookIntoFuture, + this.limit, + this.lastKnownMessageId, + this.lastCommonReadId, + this.timeout, + this.setReadMarker, + this.includeLastKnown, }); - factory GetChatParams.fromJson(Map json) => _$GetChatParamsFromJson(json); + factory GetChatParams.fromJson(Map json) => + _$GetChatParamsFromJson(json); Map toJson() => _$GetChatParamsToJson(this); } enum GetChatParamsSwitch { - @JsonValue(1) on, - @JsonValue(0) off, + @JsonValue(1) + on, + @JsonValue(0) + off, } diff --git a/lib/api/marianumcloud/talk/chat/get_chat_response.dart b/lib/api/marianumcloud/talk/chat/get_chat_response.dart index 3c6416d..9b54111 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat_response.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_response.dart @@ -12,7 +12,8 @@ class GetChatResponse extends ApiResponse { GetChatResponse(this.data); - factory GetChatResponse.fromJson(Map json) => _$GetChatResponseFromJson(json); + factory GetChatResponse.fromJson(Map json) => + _$GetChatResponseFromJson(json); Map toJson() => _$GetChatResponseToJson(this); List sortByTimestamp() { @@ -37,28 +38,30 @@ class GetChatResponseObject { String message; Map? reactions; List? reactionsSelf; - @JsonKey(fromJson: _fromJson) Map? messageParameters; + @JsonKey(fromJson: _fromJson) + Map? messageParameters; GetChatResponseObject? parent; GetChatResponseObject( - this.id, - this.token, - this.actorType, - this.actorId, - this.actorDisplayName, - this.timestamp, - this.systemMessage, - this.messageType, - this.isReplyable, - this.referenceId, - this.message, - this.messageParameters, - this.reactions, - this.reactionsSelf, - this.parent, + this.id, + this.token, + this.actorType, + this.actorId, + this.actorDisplayName, + this.timestamp, + this.systemMessage, + this.messageType, + this.isReplyable, + this.referenceId, + this.message, + this.messageParameters, + this.reactions, + this.reactionsSelf, + this.parent, ); - factory GetChatResponseObject.fromJson(Map json) => _$GetChatResponseObjectFromJson(json); + factory GetChatResponseObject.fromJson(Map json) => + _$GetChatResponseObjectFromJson(json); Map toJson() => _$GetChatResponseObjectToJson(this); static GetChatResponseObject getDateDummy(int timestamp) { @@ -66,7 +69,8 @@ class GetChatResponseObject { return getTextDummy(elementDate.formatDate()); } - static GetChatResponseObject getTextDummy(String text) => GetChatResponseObject( + static GetChatResponseObject getTextDummy(String text) => + GetChatResponseObject( 0, '', GetRoomResponseObjectMessageActorType.user, @@ -82,15 +86,17 @@ class GetChatResponseObject { null, null, null, - ); - + ); } Map? _fromJson(dynamic json) { if (json is Map) { final data = {}; for (final element in json.keys) { - data.putIfAbsent(element, () => RichObjectString.fromJson(json[element] as Map)); + data.putIfAbsent( + element, + () => RichObjectString.fromJson(json[element] as Map), + ); } return data; } @@ -109,17 +115,26 @@ class RichObjectString { RichObjectString(this.type, this.id, this.name, this.path, this.link); - factory RichObjectString.fromJson(Map json) => _$RichObjectStringFromJson(json); + factory RichObjectString.fromJson(Map json) => + _$RichObjectStringFromJson(json); Map toJson() => _$RichObjectStringToJson(this); } enum RichObjectStringObjectType { - @JsonValue('user') user, - @JsonValue('group') group, - @JsonValue('file') file, - @JsonValue('guest') guest, - @JsonValue('highlight') highlight, - @JsonValue('talk-poll') talkPoll, - @JsonValue('geo-location') geoLocation, - @JsonValue('call') call, + @JsonValue('user') + user, + @JsonValue('group') + group, + @JsonValue('file') + file, + @JsonValue('guest') + guest, + @JsonValue('highlight') + highlight, + @JsonValue('talk-poll') + talkPoll, + @JsonValue('geo-location') + geoLocation, + @JsonValue('call') + call, } diff --git a/lib/api/marianumcloud/talk/chat/rich_object_string_processor.dart b/lib/api/marianumcloud/talk/chat/rich_object_string_processor.dart index af03502..071a51e 100644 --- a/lib/api/marianumcloud/talk/chat/rich_object_string_processor.dart +++ b/lib/api/marianumcloud/talk/chat/rich_object_string_processor.dart @@ -1,9 +1,11 @@ - import 'get_chat_response.dart'; class RichObjectStringProcessor { - static String parseToString(String message, Map? data) { - if(data == null) return message; + static String parseToString( + String message, + Map? data, + ) { + if (data == null) return message; data.forEach((key, value) { message = message.replaceAll(RegExp('{$key}'), value.name); diff --git a/lib/api/marianumcloud/talk/create_room/create_room.dart b/lib/api/marianumcloud/talk/create_room/create_room.dart index e2183b6..626fd11 100644 --- a/lib/api/marianumcloud/talk/create_room/create_room.dart +++ b/lib/api/marianumcloud/talk/create_room/create_room.dart @@ -1,9 +1,9 @@ - import 'package:http/http.dart' as http; import 'package:http/http.dart'; import '../talk_api.dart'; import 'create_room_params.dart'; + class CreateRoom extends TalkApi { CreateRoomParams params; @@ -13,9 +13,19 @@ class CreateRoom extends TalkApi { Null assemble(String raw) => null; @override - Future? request(Uri uri, Object? body, Map? headers) { - if(body is CreateRoomParams) { - return http.post(uri, headers: headers, body: body.toJson().map((key, value) => MapEntry(key, value.toString()))); + Future? request( + Uri uri, + Object? body, + Map? headers, + ) { + if (body is CreateRoomParams) { + return http.post( + uri, + headers: headers, + body: body.toJson().map( + (key, value) => MapEntry(key, value.toString()), + ), + ); } return null; diff --git a/lib/api/marianumcloud/talk/create_room/create_room_params.dart b/lib/api/marianumcloud/talk/create_room/create_room_params.dart index 56ffe1d..69aa024 100644 --- a/lib/api/marianumcloud/talk/create_room/create_room_params.dart +++ b/lib/api/marianumcloud/talk/create_room/create_room_params.dart @@ -19,9 +19,10 @@ class CreateRoomParams extends ApiParams { this.source, this.roomName, this.objectType, - this.objectId + this.objectId, }); - factory CreateRoomParams.fromJson(Map json) => _$CreateRoomParamsFromJson(json); + factory CreateRoomParams.fromJson(Map json) => + _$CreateRoomParamsFromJson(json); Map toJson() => _$CreateRoomParamsToJson(this); } diff --git a/lib/api/marianumcloud/talk/delete_react_message/delete_react_message.dart b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message.dart index 9dc886c..2b365a7 100644 --- a/lib/api/marianumcloud/talk/delete_react_message/delete_react_message.dart +++ b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message.dart @@ -8,17 +8,24 @@ import 'delete_react_message_params.dart'; class DeleteReactMessage extends TalkApi { String chatToken; int messageId; - DeleteReactMessage({required this.chatToken, required this.messageId, required DeleteReactMessageParams params}) : super('v1/reaction/$chatToken/$messageId', params); + DeleteReactMessage({ + required this.chatToken, + required this.messageId, + required DeleteReactMessageParams params, + }) : super('v1/reaction/$chatToken/$messageId', params); @override Null assemble(String raw) => null; @override - Future? request(Uri uri, ApiParams? body, Map? headers) { - if(body is DeleteReactMessageParams) { + Future? request( + Uri uri, + ApiParams? body, + Map? headers, + ) { + if (body is DeleteReactMessageParams) { return http.delete(uri, headers: headers, body: body.toJson()); } return null; } - } diff --git a/lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart index d17bebc..181bec6 100644 --- a/lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart +++ b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart @@ -10,6 +10,7 @@ class DeleteReactMessageParams extends ApiParams { DeleteReactMessageParams(this.reaction); - factory DeleteReactMessageParams.fromJson(Map json) => _$DeleteReactMessageParamsFromJson(json); + factory DeleteReactMessageParams.fromJson(Map json) => + _$DeleteReactMessageParamsFromJson(json); Map toJson() => _$DeleteReactMessageParamsToJson(this); } diff --git a/lib/api/marianumcloud/talk/get_participants/get_participants.dart b/lib/api/marianumcloud/talk/get_participants/get_participants.dart index 03b302a..c37d7ce 100644 --- a/lib/api/marianumcloud/talk/get_participants/get_participants.dart +++ b/lib/api/marianumcloud/talk/get_participants/get_participants.dart @@ -12,10 +12,15 @@ class GetParticipants extends TalkApi { @override GetParticipantsResponse assemble(String raw) { final decoded = jsonDecode(raw) as Map; - return GetParticipantsResponse.fromJson(decoded['ocs'] as Map); + return GetParticipantsResponse.fromJson( + decoded['ocs'] as Map, + ); } @override - Future request(Uri uri, Object? body, Map? headers) => http.get(uri, headers: headers); - + Future request( + Uri uri, + Object? body, + Map? headers, + ) => http.get(uri, headers: headers); } diff --git a/lib/api/marianumcloud/talk/get_participants/get_participants_cache.dart b/lib/api/marianumcloud/talk/get_participants/get_participants_cache.dart index f40b017..560ba70 100644 --- a/lib/api/marianumcloud/talk/get_participants/get_participants_cache.dart +++ b/lib/api/marianumcloud/talk/get_participants/get_participants_cache.dart @@ -7,11 +7,11 @@ class GetParticipantsCache extends SimpleCache { required void Function(GetParticipantsResponse) onUpdate, required String chatToken, }) : super( - cacheTime: RequestCache.cacheNothing, - loader: () => GetParticipants(chatToken).run(), - fromJson: GetParticipantsResponse.fromJson, - onUpdate: onUpdate, - ) { + cacheTime: RequestCache.cacheNothing, + loader: () => GetParticipants(chatToken).run(), + fromJson: GetParticipantsResponse.fromJson, + onUpdate: onUpdate, + ) { start('nc-chat-participants-$chatToken'); } } diff --git a/lib/api/marianumcloud/talk/get_participants/get_participants_response.dart b/lib/api/marianumcloud/talk/get_participants/get_participants_response.dart index 5f97086..12a362b 100644 --- a/lib/api/marianumcloud/talk/get_participants/get_participants_response.dart +++ b/lib/api/marianumcloud/talk/get_participants/get_participants_response.dart @@ -1,4 +1,3 @@ - import 'package:json_annotation/json_annotation.dart'; import '../../../api_response.dart'; @@ -11,7 +10,8 @@ class GetParticipantsResponse extends ApiResponse { GetParticipantsResponse(this.data); - factory GetParticipantsResponse.fromJson(Map json) => _$GetParticipantsResponseFromJson(json); + factory GetParticipantsResponse.fromJson(Map json) => + _$GetParticipantsResponseFromJson(json); Map toJson() => _$GetParticipantsResponseToJson(this); } @@ -34,42 +34,55 @@ class GetParticipantsResponseObject { String? roomToken; GetParticipantsResponseObject( - this.attendeeId, - this.actorType, - this.actorId, - this.displayName, - this.participantType, - this.lastPing, - this.inCall, - this.permissions, - this.attendeePermissions, - this.sessionId, - this.sessionIds, - this.status, - this.statusIcon, - this.statusMessage, - this.roomToken); + this.attendeeId, + this.actorType, + this.actorId, + this.displayName, + this.participantType, + this.lastPing, + this.inCall, + this.permissions, + this.attendeePermissions, + this.sessionId, + this.sessionIds, + this.status, + this.statusIcon, + this.statusMessage, + this.roomToken, + ); - factory GetParticipantsResponseObject.fromJson(Map json) => _$GetParticipantsResponseObjectFromJson(json); + factory GetParticipantsResponseObject.fromJson(Map json) => + _$GetParticipantsResponseObjectFromJson(json); Map toJson() => _$GetParticipantsResponseObjectToJson(this); } enum GetParticipantsResponseObjectParticipantType { - @JsonValue(1) owner('Besitzer'), - @JsonValue(2) moderator('Moderator'), - @JsonValue(3) user('Teilnehmer'), - @JsonValue(4) guest('Gast'), - @JsonValue(5) userFollowingPublicLink('Teilnehmer über Link'), - @JsonValue(6) guestWithModeratorPermissions('Gast Moderator'); + @JsonValue(1) + owner('Besitzer'), + @JsonValue(2) + moderator('Moderator'), + @JsonValue(3) + user('Teilnehmer'), + @JsonValue(4) + guest('Gast'), + @JsonValue(5) + userFollowingPublicLink('Teilnehmer über Link'), + @JsonValue(6) + guestWithModeratorPermissions('Gast Moderator'); const GetParticipantsResponseObjectParticipantType(this.prettyName); final String prettyName; } enum GetParticipantsResponseObjectParticipantsInCallFlags { - @JsonValue(0) disconnected, - @JsonValue(1) inCall, - @JsonValue(2) providesAudio, - @JsonValue(3) providesVideo, - @JsonValue(4) usesSipDialIn + @JsonValue(0) + disconnected, + @JsonValue(1) + inCall, + @JsonValue(2) + providesAudio, + @JsonValue(3) + providesVideo, + @JsonValue(4) + usesSipDialIn, } 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 c4c37b7..3b6ccd2 100644 --- a/lib/api/marianumcloud/talk/get_poll/get_poll_state.dart +++ b/lib/api/marianumcloud/talk/get_poll/get_poll_state.dart @@ -8,14 +8,21 @@ import 'get_poll_state_response.dart'; class GetPollState extends TalkApi { String token; int pollId; - GetPollState({required this.token, required this.pollId}) : super('v1/poll/$token/$pollId', null); + GetPollState({required this.token, required this.pollId}) + : super('v1/poll/$token/$pollId', null); @override GetPollStateResponse assemble(String raw) { final decoded = jsonDecode(raw) as Map; - return GetPollStateResponse.fromJson(decoded['ocs'] as Map); + return GetPollStateResponse.fromJson( + decoded['ocs'] as Map, + ); } @override - Future request(Uri uri, Object? body, Map? headers) => http.get(uri, headers: headers); + 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_response.dart b/lib/api/marianumcloud/talk/get_poll/get_poll_state_response.dart index 5c43a38..928dba0 100644 --- a/lib/api/marianumcloud/talk/get_poll/get_poll_state_response.dart +++ b/lib/api/marianumcloud/talk/get_poll/get_poll_state_response.dart @@ -10,7 +10,8 @@ class GetPollStateResponse extends ApiResponse { GetPollStateResponse(this.data); - factory GetPollStateResponse.fromJson(Map json) => _$GetPollStateResponseFromJson(json); + factory GetPollStateResponse.fromJson(Map json) => + _$GetPollStateResponseFromJson(json); Map toJson() => _$GetPollStateResponseToJson(this); } @@ -31,20 +32,22 @@ class GetPollStateResponseObject { List? details; GetPollStateResponseObject( - this.id, - this.question, - this.options, - this.votes, - this.actorType, - this.actorId, - this.actorDisplayName, - this.status, - this.resultMode, - this.maxVotes, - this.votedSelf, - this.numVoters, - this.details); + this.id, + this.question, + this.options, + this.votes, + this.actorType, + this.actorId, + this.actorDisplayName, + this.status, + this.resultMode, + this.maxVotes, + this.votedSelf, + this.numVoters, + this.details, + ); - factory GetPollStateResponseObject.fromJson(Map json) => _$GetPollStateResponseObjectFromJson(json); + factory GetPollStateResponseObject.fromJson(Map json) => + _$GetPollStateResponseObjectFromJson(json); Map toJson() => _$GetPollStateResponseObjectToJson(this); } diff --git a/lib/api/marianumcloud/talk/get_reactions/get_reactions.dart b/lib/api/marianumcloud/talk/get_reactions/get_reactions.dart index 549c788..882eb29 100644 --- a/lib/api/marianumcloud/talk/get_reactions/get_reactions.dart +++ b/lib/api/marianumcloud/talk/get_reactions/get_reactions.dart @@ -10,15 +10,21 @@ import 'get_reactions_response.dart'; class GetReactions extends TalkApi { String chatToken; int messageId; - GetReactions({required this.chatToken, required this.messageId}) : super('v1/reaction/$chatToken/$messageId', null); + GetReactions({required this.chatToken, required this.messageId}) + : super('v1/reaction/$chatToken/$messageId', null); @override GetReactionsResponse assemble(String raw) { final decoded = jsonDecode(raw) as Map; - return GetReactionsResponse.fromJson(decoded['ocs'] as Map); + return GetReactionsResponse.fromJson( + decoded['ocs'] as Map, + ); } @override - Future? request(Uri uri, ApiParams? body, Map? headers) => http.get(uri, headers: headers); - + Future? request( + Uri uri, + ApiParams? body, + Map? headers, + ) => http.get(uri, headers: headers); } diff --git a/lib/api/marianumcloud/talk/get_reactions/get_reactions_response.dart b/lib/api/marianumcloud/talk/get_reactions/get_reactions_response.dart index 052b03a..1f58dfc 100644 --- a/lib/api/marianumcloud/talk/get_reactions/get_reactions_response.dart +++ b/lib/api/marianumcloud/talk/get_reactions/get_reactions_response.dart @@ -10,7 +10,8 @@ class GetReactionsResponse extends ApiResponse { GetReactionsResponse(this.data); - factory GetReactionsResponse.fromJson(Map json) => _$GetReactionsResponseFromJson(json); + factory GetReactionsResponse.fromJson(Map json) => + _$GetReactionsResponseFromJson(json); Map toJson() => _$GetReactionsResponseToJson(this); } @@ -21,13 +22,21 @@ class GetReactionsResponseObject { String actorDisplayName; int timestamp; - GetReactionsResponseObject(this.actorType, this.actorId, this.actorDisplayName, this.timestamp); + GetReactionsResponseObject( + this.actorType, + this.actorId, + this.actorDisplayName, + this.timestamp, + ); - factory GetReactionsResponseObject.fromJson(Map json) => _$GetReactionsResponseObjectFromJson(json); + factory GetReactionsResponseObject.fromJson(Map json) => + _$GetReactionsResponseObjectFromJson(json); Map toJson() => _$GetReactionsResponseObjectToJson(this); } enum GetReactionsResponseObjectActorType { - @JsonValue('guests') guests, - @JsonValue('users') users, + @JsonValue('guests') + guests, + @JsonValue('users') + users, } diff --git a/lib/api/marianumcloud/talk/react_message/react_message.dart b/lib/api/marianumcloud/talk/react_message/react_message.dart index c1e93b1..01c78dd 100644 --- a/lib/api/marianumcloud/talk/react_message/react_message.dart +++ b/lib/api/marianumcloud/talk/react_message/react_message.dart @@ -8,17 +8,24 @@ import 'react_message_params.dart'; class ReactMessage extends TalkApi { String chatToken; int messageId; - ReactMessage({required this.chatToken, required this.messageId, required ReactMessageParams params}) : super('v1/reaction/$chatToken/$messageId', params); + ReactMessage({ + required this.chatToken, + required this.messageId, + required ReactMessageParams params, + }) : super('v1/reaction/$chatToken/$messageId', params); @override Null assemble(String raw) => null; @override - Future? request(Uri uri, ApiParams? body, Map? headers) { - if(body is ReactMessageParams) { + Future? request( + Uri uri, + ApiParams? body, + Map? headers, + ) { + if (body is ReactMessageParams) { return http.post(uri, headers: headers, body: body.toJson()); } return null; } - } diff --git a/lib/api/marianumcloud/talk/react_message/react_message_params.dart b/lib/api/marianumcloud/talk/react_message/react_message_params.dart index 22b8845..1315898 100644 --- a/lib/api/marianumcloud/talk/react_message/react_message_params.dart +++ b/lib/api/marianumcloud/talk/react_message/react_message_params.dart @@ -10,6 +10,7 @@ class ReactMessageParams extends ApiParams { ReactMessageParams(this.reaction); - factory ReactMessageParams.fromJson(Map json) => _$ReactMessageParamsFromJson(json); + factory ReactMessageParams.fromJson(Map json) => + _$ReactMessageParamsFromJson(json); Map toJson() => _$ReactMessageParamsToJson(this); } diff --git a/lib/api/marianumcloud/talk/room/get_room.dart b/lib/api/marianumcloud/talk/room/get_room.dart index bb7d68e..c2049ef 100644 --- a/lib/api/marianumcloud/talk/room/get_room.dart +++ b/lib/api/marianumcloud/talk/room/get_room.dart @@ -6,13 +6,10 @@ import '../talk_api.dart'; import 'get_room_params.dart'; import 'get_room_response.dart'; - class GetRoom extends TalkApi { GetRoomParams params; GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson()); - - @override GetRoomResponse assemble(String raw) { final decoded = jsonDecode(raw) as Map; @@ -20,6 +17,9 @@ class GetRoom extends TalkApi { } @override - Future request(Uri uri, Object? body, Map? headers) => http.get(uri, headers: headers); - + Future request( + Uri uri, + Object? body, + Map? headers, + ) => http.get(uri, headers: headers); } diff --git a/lib/api/marianumcloud/talk/room/get_room_cache.dart b/lib/api/marianumcloud/talk/room/get_room_cache.dart index 107a58b..8bcbd55 100644 --- a/lib/api/marianumcloud/talk/room/get_room_cache.dart +++ b/lib/api/marianumcloud/talk/room/get_room_cache.dart @@ -5,11 +5,11 @@ import 'get_room_response.dart'; class GetRoomCache extends SimpleCache { GetRoomCache({super.onUpdate, super.onError, super.renew}) - : super( - cacheTime: RequestCache.cacheMinute, - loader: () => GetRoom(GetRoomParams(includeStatus: true)).run(), - fromJson: GetRoomResponse.fromJson, - ) { + : super( + cacheTime: RequestCache.cacheMinute, + loader: () => GetRoom(GetRoomParams(includeStatus: true)).run(), + fromJson: GetRoomResponse.fromJson, + ) { start('nc-rooms'); } } diff --git a/lib/api/marianumcloud/talk/room/get_room_params.dart b/lib/api/marianumcloud/talk/room/get_room_params.dart index 09e397e..35dc4b8 100644 --- a/lib/api/marianumcloud/talk/room/get_room_params.dart +++ b/lib/api/marianumcloud/talk/room/get_room_params.dart @@ -1,4 +1,3 @@ - import 'package:json_annotation/json_annotation.dart'; import '../../../api_params.dart'; @@ -8,18 +7,22 @@ part 'get_room_params.g.dart'; @JsonSerializable(explicitToJson: true) class GetRoomParams extends ApiParams { GetRoomParamsStatusUpdate? noStatusUpdate; - @JsonKey(toJson: _format) bool? includeStatus; + @JsonKey(toJson: _format) + bool? includeStatus; int? modifiedSince; GetRoomParams({this.noStatusUpdate, this.includeStatus, this.modifiedSince}); - factory GetRoomParams.fromJson(Map json) => _$GetRoomParamsFromJson(json); + factory GetRoomParams.fromJson(Map json) => + _$GetRoomParamsFromJson(json); Map toJson() => _$GetRoomParamsToJson(this); - + static String _format(bool? v) => v.toString(); } enum GetRoomParamsStatusUpdate { - @JsonValue(0) defaults, - @JsonValue(1) keepAlive, + @JsonValue(0) + defaults, + @JsonValue(1) + keepAlive, } diff --git a/lib/api/marianumcloud/talk/room/get_room_response.dart b/lib/api/marianumcloud/talk/room/get_room_response.dart index da278d1..c18e668 100644 --- a/lib/api/marianumcloud/talk/room/get_room_response.dart +++ b/lib/api/marianumcloud/talk/room/get_room_response.dart @@ -11,17 +11,22 @@ class GetRoomResponse extends ApiResponse { GetRoomResponse(this.data); - factory GetRoomResponse.fromJson(Map json) => _$GetRoomResponseFromJson(json); + factory GetRoomResponse.fromJson(Map json) => + _$GetRoomResponseFromJson(json); Map toJson() => _$GetRoomResponseToJson(this); - List sortBy({bool lastActivity = true, required bool favoritesToTop, required bool unreadToTop}) { + List sortBy({ + bool lastActivity = true, + required bool favoritesToTop, + required bool unreadToTop, + }) { for (var chat in data) { final buffer = StringBuffer(); - if(favoritesToTop) { + if (favoritesToTop) { buffer.write(chat.isFavorite ? 'b' : 'a'); } - if(unreadToTop) { + if (unreadToTop) { buffer.write(chat.unreadMessages > 0 ? 'b' : 'a'); } @@ -69,69 +74,91 @@ class GetRoomResponseObject { String? sort; GetRoomResponseObject( - this.id, - this.token, - this.type, - this.name, - this.displayName, - this.description, - this.participantType, - this.participantFlags, - this.readOnly, - this.listable, - this.lastPing, - this.sessionId, - this.hasPassword, - this.hasCall, - this.callFlag, - this.canStartCall, - this.canDeleteConversation, - this.canLeaveConversation, - this.lastActivity, - this.isFavorite, - this.notificationLevel, - this.unreadMessages, - this.unreadMention, - this.unreadMentionDirect, - this.lastReadMessage, - this.lastCommonReadMessage, - this.lastMessage, - this.status, - this.statusIcon, - this.statusMessage); + this.id, + this.token, + this.type, + this.name, + this.displayName, + this.description, + this.participantType, + this.participantFlags, + this.readOnly, + this.listable, + this.lastPing, + this.sessionId, + this.hasPassword, + this.hasCall, + this.callFlag, + this.canStartCall, + this.canDeleteConversation, + this.canLeaveConversation, + this.lastActivity, + this.isFavorite, + this.notificationLevel, + this.unreadMessages, + this.unreadMention, + this.unreadMentionDirect, + this.lastReadMessage, + this.lastCommonReadMessage, + this.lastMessage, + this.status, + this.statusIcon, + this.statusMessage, + ); - factory GetRoomResponseObject.fromJson(Map json) => _$GetRoomResponseObjectFromJson(json); + factory GetRoomResponseObject.fromJson(Map json) => + _$GetRoomResponseObjectFromJson(json); Map toJson() => _$GetRoomResponseObjectToJson(this); } enum GetRoomResponseObjectConversationType { - @JsonValue(1) oneToOne, - @JsonValue(2) group, - @JsonValue(3) public, - @JsonValue(4) changelog, - @JsonValue(5) deleted, - @JsonValue(6) noteToSelf, + @JsonValue(1) + oneToOne, + @JsonValue(2) + group, + @JsonValue(3) + public, + @JsonValue(4) + changelog, + @JsonValue(5) + deleted, + @JsonValue(6) + noteToSelf, } enum GetRoomResponseObjectParticipantNotificationLevel { - @JsonValue(0) defaultLevel, - @JsonValue(1) alwaysNotify, - @JsonValue(2) notifyOnMention, - @JsonValue(3) neverNotify, + @JsonValue(0) + defaultLevel, + @JsonValue(1) + alwaysNotify, + @JsonValue(2) + notifyOnMention, + @JsonValue(3) + neverNotify, } enum GetRoomResponseObjectMessageActorType { - @JsonValue('deleted_users') deletedUsers, - @JsonValue('users') user, - @JsonValue('guests') guest, - @JsonValue('bots') bot, - @JsonValue('bridged') bridge, + @JsonValue('deleted_users') + deletedUsers, + @JsonValue('users') + user, + @JsonValue('guests') + guest, + @JsonValue('bots') + bot, + @JsonValue('bridged') + bridge, } enum GetRoomResponseObjectMessageType { - @JsonValue('comment') comment, - @JsonValue('voice-message') voiceMessage, - @JsonValue('comment_deleted') deletedComment, - @JsonValue('system') system, - @JsonValue('command') command, + @JsonValue('comment') + comment, + @JsonValue('voice-message') + voiceMessage, + @JsonValue('comment_deleted') + deletedComment, + @JsonValue('system') + system, + @JsonValue('command') + command, } diff --git a/lib/api/marianumcloud/talk/send_message/send_message.dart b/lib/api/marianumcloud/talk/send_message/send_message.dart index af3a012..b2849ad 100644 --- a/lib/api/marianumcloud/talk/send_message/send_message.dart +++ b/lib/api/marianumcloud/talk/send_message/send_message.dart @@ -7,17 +7,21 @@ import 'send_message_params.dart'; class SendMessage extends TalkApi { String chatToken; - SendMessage(this.chatToken, SendMessageParams params) : super('v1/chat/$chatToken', params); + SendMessage(this.chatToken, SendMessageParams params) + : super('v1/chat/$chatToken', params); @override Null assemble(String raw) => null; @override - Future? request(Uri uri, ApiParams? body, Map? headers) { - if(body is SendMessageParams) { + Future? request( + Uri uri, + ApiParams? body, + Map? headers, + ) { + if (body is SendMessageParams) { return http.post(uri, headers: headers, body: body.toJson()); } return null; } - } diff --git a/lib/api/marianumcloud/talk/send_message/send_message_params.dart b/lib/api/marianumcloud/talk/send_message/send_message_params.dart index 8ded2e2..a84adea 100644 --- a/lib/api/marianumcloud/talk/send_message/send_message_params.dart +++ b/lib/api/marianumcloud/talk/send_message/send_message_params.dart @@ -11,6 +11,7 @@ class SendMessageParams extends ApiParams { SendMessageParams(this.message, {this.replyTo}); - factory SendMessageParams.fromJson(Map json) => _$SendMessageParamsFromJson(json); + factory SendMessageParams.fromJson(Map json) => + _$SendMessageParamsFromJson(json); Map toJson() => _$SendMessageParamsToJson(this); } diff --git a/lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart index 24389ef..847a96d 100644 --- a/lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart +++ b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart @@ -1,4 +1,3 @@ - import 'package:http/http.dart' as http; import 'package:http/http.dart'; @@ -10,21 +9,28 @@ class SetReadMarker extends TalkApi { bool readState; SetReadMarkerParams? setReadMarkerParams; - SetReadMarker(this.chatToken, this.readState, {this.setReadMarkerParams}) : super('v1/chat/$chatToken/read', null, getParameters: setReadMarkerParams?.toJson()) { - if(readState) assert(setReadMarkerParams?.lastReadMessage != null); + SetReadMarker(this.chatToken, this.readState, {this.setReadMarkerParams}) + : super( + 'v1/chat/$chatToken/read', + null, + getParameters: setReadMarkerParams?.toJson(), + ) { + if (readState) assert(setReadMarkerParams?.lastReadMessage != null); } @override Null assemble(String raw) => null; @override - Future request(Uri uri, Object? body, Map? headers) { - if(readState) { - + Future request( + Uri uri, + Object? body, + Map? headers, + ) { + if (readState) { return http.post(uri, headers: headers); } else { return http.delete(uri, headers: headers); } } - } diff --git a/lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart index 50edee7..62c3278 100644 --- a/lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart +++ b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart @@ -8,10 +8,9 @@ part 'set_read_marker_params.g.dart'; class SetReadMarkerParams extends ApiParams { int? lastReadMessage; - SetReadMarkerParams({ - this.lastReadMessage - }); + SetReadMarkerParams({this.lastReadMessage}); - factory SetReadMarkerParams.fromJson(Map json) => _$SetReadMarkerParamsFromJson(json); + factory SetReadMarkerParams.fromJson(Map json) => + _$SetReadMarkerParamsFromJson(json); Map toJson() => _$SetReadMarkerParamsToJson(this); } diff --git a/lib/api/marianumcloud/talk/talk_api.dart b/lib/api/marianumcloud/talk/talk_api.dart index 9e63d35..b461221 100644 --- a/lib/api/marianumcloud/talk/talk_api.dart +++ b/lib/api/marianumcloud/talk/talk_api.dart @@ -14,12 +14,7 @@ import '../../errors/parse_exception.dart'; import '../../errors/server_exception.dart'; import '../nextcloud_ocs.dart'; -enum TalkApiMethod { - get, - post, - put, - delete, -} +enum TalkApiMethod { get, post, put, delete } abstract class TalkApi extends ApiRequest { String path; @@ -31,11 +26,18 @@ abstract class TalkApi extends ApiRequest { TalkApi(this.path, this.body, {this.headers, this.getParameters}); - Future? request(Uri uri, ApiParams? body, Map? headers); + Future? request( + Uri uri, + ApiParams? body, + Map? headers, + ); T assemble(String raw); Future run() async { - final endpoint = NextcloudOcs.uri('apps/spreed/api/$path', queryParameters: getParameters); + final endpoint = NextcloudOcs.uri( + 'apps/spreed/api/$path', + queryParameters: getParameters, + ); final mergedHeaders = {...NextcloudOcs.headers(), ...?headers}; final http.Response data; @@ -60,8 +62,12 @@ abstract class TalkApi extends ApiRequest { if (status < 200 || status >= 300) { final detail = 'Talk $endpoint -> HTTP $status'; log(detail); - if (status == 401) throw AuthException.unauthorized(technicalDetails: detail); - if (status == 403) throw AuthException.forbidden(technicalDetails: detail); + if (status == 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); } diff --git a/lib/api/marianumcloud/webdav/queries/download_file/download_file.dart b/lib/api/marianumcloud/webdav/queries/download_file/download_file.dart index 5a4f164..160b4a1 100644 --- a/lib/api/marianumcloud/webdav/queries/download_file/download_file.dart +++ b/lib/api/marianumcloud/webdav/queries/download_file/download_file.dart @@ -1,5 +1,3 @@ - - import '../../../../api_response.dart'; import '../../webdav_api.dart'; import 'download_file_params.dart'; diff --git a/lib/api/marianumcloud/webdav/queries/download_file/download_file_params.dart b/lib/api/marianumcloud/webdav/queries/download_file/download_file_params.dart index ba8b075..d7763b5 100644 --- a/lib/api/marianumcloud/webdav/queries/download_file/download_file_params.dart +++ b/lib/api/marianumcloud/webdav/queries/download_file/download_file_params.dart @@ -10,8 +10,13 @@ class DownloadFileParams extends ApiParams { String localTargetPath; String filename; - DownloadFileParams(this.webdavSourcePath, this.localTargetPath, this.filename); + DownloadFileParams( + this.webdavSourcePath, + this.localTargetPath, + this.filename, + ); - factory DownloadFileParams.fromJson(Map json) => _$DownloadFileParamsFromJson(json); + factory DownloadFileParams.fromJson(Map json) => + _$DownloadFileParamsFromJson(json); Map toJson() => _$DownloadFileParamsToJson(this); } diff --git a/lib/api/marianumcloud/webdav/queries/download_file/download_file_response.dart b/lib/api/marianumcloud/webdav/queries/download_file/download_file_response.dart index 76ff712..ca91def 100644 --- a/lib/api/marianumcloud/webdav/queries/download_file/download_file_response.dart +++ b/lib/api/marianumcloud/webdav/queries/download_file/download_file_response.dart @@ -1,4 +1,3 @@ - import 'package:json_annotation/json_annotation.dart'; part 'download_file_response.g.dart'; @@ -9,6 +8,7 @@ class DownloadFileResponse { DownloadFileResponse(this.path); - factory DownloadFileResponse.fromJson(Map json) => _$DownloadFileResponseFromJson(json); + factory DownloadFileResponse.fromJson(Map json) => + _$DownloadFileResponseFromJson(json); Map toJson() => _$DownloadFileResponseToJson(this); } diff --git a/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart index c716dfb..bf23d1e 100644 --- a/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart @@ -15,7 +15,16 @@ class CacheableFile { DateTime? modifiedAt; String? sort; - CacheableFile({required this.path, required this.isDirectory, required this.name, this.mimeType, this.size, this.eTag, this.createdAt, this.modifiedAt}); + CacheableFile({ + required this.path, + required this.isDirectory, + required this.name, + this.mimeType, + this.size, + this.eTag, + this.createdAt, + this.modifiedAt, + }); CacheableFile.fromDavFile(WebDavFile file) { path = file.path.path; @@ -28,6 +37,7 @@ class CacheableFile { modifiedAt = file.lastModified; } - factory CacheableFile.fromJson(Map json) => _$CacheableFileFromJson(json); + factory CacheableFile.fromJson(Map json) => + _$CacheableFileFromJson(json); Map toJson() => _$CacheableFileToJson(this); } diff --git a/lib/api/marianumcloud/webdav/queries/list_files/list_files.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files.dart index 6bebff9..d9b9745 100644 --- a/lib/api/marianumcloud/webdav/queries/list_files/list_files.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files.dart @@ -1,4 +1,3 @@ - import 'package:nextcloud/nextcloud.dart'; import '../../webdav_api.dart'; @@ -26,12 +25,15 @@ class ListFiles extends WebdavApi { Future run() async { final webdav = await WebdavApi.webdav; final timeout = _isRoot ? _rootTimeout : _subfolderTimeout; - final davFiles = (await webdav.propfind(PathUri.parse(params.path)).timeout(timeout)).toWebDavFiles(); + final davFiles = + (await webdav.propfind(PathUri.parse(params.path)).timeout(timeout)) + .toWebDavFiles(); final files = davFiles.map(CacheableFile.fromDavFile).toSet(); - // somehow the current working folder is also listed, it is filtered here. - files.removeWhere((element) => element.path == '/${params.path}/' || element.path == '/'); + files.removeWhere( + (element) => element.path == '/${params.path}/' || element.path == '/', + ); return ListFilesResponse(files); } diff --git a/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart index 4f17e6e..b9a4cd1 100644 --- a/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart @@ -17,16 +17,18 @@ class ListFilesCache extends SimpleCache { super.onError, required String path, }) : super( - cacheTime: RequestCache.cacheNothing, - loader: () => ListFiles(ListFilesParams(path)).run(), - fromJson: ListFilesResponse.fromJson, - onUpdate: onUpdate, - ) { + cacheTime: RequestCache.cacheNothing, + loader: () => ListFiles(ListFilesParams(path)).run(), + fromJson: ListFilesResponse.fromJson, + onUpdate: onUpdate, + ) { start(_documentId(path)); } static String _documentId(String path) { - final cacheName = md5.convert(utf8.encode('MarianumMobile-$path')).toString(); + final cacheName = md5 + .convert(utf8.encode('MarianumMobile-$path')) + .toString(); return 'wd-folder-$cacheName'; } @@ -35,7 +37,10 @@ class ListFilesCache extends SimpleCache { /// `_FilesView` for that path via [CacheInvalidationBus] so it refetches /// even while it is sitting in the background of the navigation stack. static Future invalidate(String path) async { - await Localstore.instance.collection(RequestCache.collection).doc(_documentId(path)).delete(); + await Localstore.instance + .collection(RequestCache.collection) + .doc(_documentId(path)) + .delete(); CacheInvalidationBus.notifyListFiles(path); } } diff --git a/lib/api/marianumcloud/webdav/queries/list_files/list_files_params.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_params.dart index c18a539..ccd6a05 100644 --- a/lib/api/marianumcloud/webdav/queries/list_files/list_files_params.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_params.dart @@ -10,6 +10,7 @@ class ListFilesParams extends ApiParams { ListFilesParams(this.path); - factory ListFilesParams.fromJson(Map json) => _$ListFilesParamsFromJson(json); + factory ListFilesParams.fromJson(Map json) => + _$ListFilesParamsFromJson(json); Map toJson() => _$ListFilesParamsToJson(this); } diff --git a/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart index 8614f3e..3c73cd8 100644 --- a/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart @@ -9,49 +9,73 @@ part 'list_files_response.g.dart'; @JsonSerializable(explicitToJson: true) class ListFilesResponse extends ApiResponse { - Set files; + Set files; - ListFilesResponse(this.files); + ListFilesResponse(this.files); - factory ListFilesResponse.fromJson(Map json) => _$ListFilesResponseFromJson(json); - Map toJson() => _$ListFilesResponseToJson(this); + factory ListFilesResponse.fromJson(Map json) => + _$ListFilesResponseFromJson(json); + Map toJson() => _$ListFilesResponseToJson(this); - List sortBy({bool foldersToTop = true, SortOption sortOption = SortOption.name, bool reversed = false}) { - var list = List.empty(growable: true); + List sortBy({ + bool foldersToTop = true, + SortOption sortOption = SortOption.name, + bool reversed = false, + }) { + var list = List.empty(growable: true); - if(foldersToTop) { - list.addAll(_sort(files.where((element) => element.isDirectory).toSet(), reversed: reversed, sortOption: sortOption)); - list.addAll(_sort(files.where((element) => !element.isDirectory).toSet(), reversed: reversed, sortOption: sortOption)); - } else { - list.addAll(_sort(files, reversed: reversed, sortOption: sortOption)); - } - - return list; + if (foldersToTop) { + list.addAll( + _sort( + files.where((element) => element.isDirectory).toSet(), + reversed: reversed, + sortOption: sortOption, + ), + ); + list.addAll( + _sort( + files.where((element) => !element.isDirectory).toSet(), + reversed: reversed, + sortOption: sortOption, + ), + ); + } else { + list.addAll(_sort(files, reversed: reversed, sortOption: sortOption)); } - List _sort(Set files, {SortOption sortOption = SortOption.name, bool reversed = false}) { - for (var file in files) { - final buffer = StringBuffer(); + return list; + } - switch(sortOption) { - case SortOption.date: - buffer.write(Jiffy.parseFromMillisecondsSinceEpoch(file.modifiedAt?.millisecondsSinceEpoch ?? 0).format(pattern: 'yyyyMMddhhmmss')); - break; + List _sort( + Set files, { + SortOption sortOption = SortOption.name, + bool reversed = false, + }) { + for (var file in files) { + final buffer = StringBuffer(); - case SortOption.name: - buffer.write(file.name.toLowerCase()); - break; + switch (sortOption) { + case SortOption.date: + buffer.write( + Jiffy.parseFromMillisecondsSinceEpoch( + file.modifiedAt?.millisecondsSinceEpoch ?? 0, + ).format(pattern: 'yyyyMMddhhmmss'), + ); + break; - case SortOption.size: - buffer.write(file.size); - break; - } + case SortOption.name: + buffer.write(file.name.toLowerCase()); + break; - file.sort = buffer.toString(); - } + case SortOption.size: + buffer.write(file.size); + break; + } - - var list = files.toList()..sort((a, b) => b.sort!.compareTo(a.sort!)); - return reversed ? list.reversed.toList() : list; + file.sort = buffer.toString(); } + + var list = files.toList()..sort((a, b) => b.sort!.compareTo(a.sort!)); + return reversed ? list.reversed.toList() : list; + } } diff --git a/lib/api/marianumcloud/webdav/webdav_api.dart b/lib/api/marianumcloud/webdav/webdav_api.dart index 3327b62..5916761 100644 --- a/lib/api/marianumcloud/webdav/webdav_api.dart +++ b/lib/api/marianumcloud/webdav/webdav_api.dart @@ -16,7 +16,12 @@ abstract class WebdavApi extends ApiRequest { static Future webdav = establishWebdavConnection(); - static Future establishWebdavConnection() async => NextcloudClient(Uri.parse('https://${EndpointData().nextcloud().full()}'), password: AccountData().getPassword(), loginName: AccountData().getUsername()).webdav; + static Future establishWebdavConnection() async => + NextcloudClient( + Uri.parse('https://${EndpointData().nextcloud().full()}'), + password: AccountData().getPassword(), + loginName: AccountData().getUsername(), + ).webdav; /// Builds the WebDAV download URL without embedded credentials. Callers must /// authenticate via the [AccountData.authHeaders] header instead. diff --git a/lib/api/mhsl/breaker/get_breakers/get_breakers.dart b/lib/api/mhsl/breaker/get_breakers/get_breakers.dart index b8f5c93..35d70b0 100644 --- a/lib/api/mhsl/breaker/get_breakers/get_breakers.dart +++ b/lib/api/mhsl/breaker/get_breakers/get_breakers.dart @@ -9,9 +9,9 @@ class GetBreakers extends MhslApi { GetBreakers() : super('breaker/'); @override - GetBreakersResponse assemble(String raw) => GetBreakersResponse.fromJson(jsonDecode(raw) as Map); + GetBreakersResponse assemble(String raw) => + GetBreakersResponse.fromJson(jsonDecode(raw) as Map); @override Future? request(Uri uri) => http.get(uri); - } diff --git a/lib/api/mhsl/breaker/get_breakers/get_breakers_cache.dart b/lib/api/mhsl/breaker/get_breakers/get_breakers_cache.dart index 8f3c180..1937df5 100644 --- a/lib/api/mhsl/breaker/get_breakers/get_breakers_cache.dart +++ b/lib/api/mhsl/breaker/get_breakers/get_breakers_cache.dart @@ -4,11 +4,11 @@ import 'get_breakers_response.dart'; class GetBreakersCache extends SimpleCache { GetBreakersCache({super.onUpdate, super.renew}) - : super( - cacheTime: RequestCache.cacheMinute, - loader: () => GetBreakers().run(), - fromJson: GetBreakersResponse.fromJson, - ) { + : super( + cacheTime: RequestCache.cacheMinute, + loader: () => GetBreakers().run(), + fromJson: GetBreakersResponse.fromJson, + ) { start('breakers'); } } diff --git a/lib/api/mhsl/breaker/get_breakers/get_breakers_response.dart b/lib/api/mhsl/breaker/get_breakers/get_breakers_response.dart index aa0f3b1..c0cb39f 100644 --- a/lib/api/mhsl/breaker/get_breakers/get_breakers_response.dart +++ b/lib/api/mhsl/breaker/get_breakers/get_breakers_response.dart @@ -1,4 +1,3 @@ - import 'package:json_annotation/json_annotation.dart'; import '../../../api_response.dart'; @@ -12,7 +11,8 @@ class GetBreakersResponse extends ApiResponse { GetBreakersResponse(this.global, this.regional); - factory GetBreakersResponse.fromJson(Map json) => _$GetBreakersResponseFromJson(json); + factory GetBreakersResponse.fromJson(Map json) => + _$GetBreakersResponseFromJson(json); Map toJson() => _$GetBreakersResponseToJson(this); } @@ -23,14 +23,20 @@ class GetBreakersReponseObject { GetBreakersReponseObject(this.areas, this.message); - factory GetBreakersReponseObject.fromJson(Map json) => _$GetBreakersReponseObjectFromJson(json); + factory GetBreakersReponseObject.fromJson(Map json) => + _$GetBreakersReponseObjectFromJson(json); Map toJson() => _$GetBreakersReponseObjectToJson(this); } enum BreakerArea { - @JsonValue('GLOBAL') global, - @JsonValue('TIMETABLE') timetable, - @JsonValue('TALK') talk, - @JsonValue('FILES') files, - @JsonValue('MORE') more, + @JsonValue('GLOBAL') + global, + @JsonValue('TIMETABLE') + timetable, + @JsonValue('TALK') + talk, + @JsonValue('FILES') + files, + @JsonValue('MORE') + more, } diff --git a/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart index c7fe3fc..abd6683 100644 --- a/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart +++ b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart @@ -8,7 +8,7 @@ import 'add_custom_timetable_event_params.dart'; class AddCustomTimetableEvent extends MhslApi { AddCustomTimetableEventParams params; - + AddCustomTimetableEvent(this.params) : super('server/timetable/customEvents'); @override diff --git a/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart index a1d3b74..fab722d 100644 --- a/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart +++ b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart @@ -11,6 +11,7 @@ class AddCustomTimetableEventParams { AddCustomTimetableEventParams(this.user, this.event); - factory AddCustomTimetableEventParams.fromJson(Map json) => _$AddCustomTimetableEventParamsFromJson(json); + factory AddCustomTimetableEventParams.fromJson(Map json) => + _$AddCustomTimetableEventParamsFromJson(json); Map toJson() => _$AddCustomTimetableEventParamsToJson(this); } diff --git a/lib/api/mhsl/custom_timetable_event/custom_timetable_event.dart b/lib/api/mhsl/custom_timetable_event/custom_timetable_event.dart index be9e4a6..eb8eab9 100644 --- a/lib/api/mhsl/custom_timetable_event/custom_timetable_event.dart +++ b/lib/api/mhsl/custom_timetable_event/custom_timetable_event.dart @@ -20,9 +20,19 @@ class CustomTimetableEvent { @JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson) DateTime updatedAt; - CustomTimetableEvent({required this.id, required this.title, required this.description, required this.startDate, - required this.endDate, required this.color, required this.rrule, required this.createdAt, required this.updatedAt}); + CustomTimetableEvent({ + required this.id, + required this.title, + required this.description, + required this.startDate, + required this.endDate, + required this.color, + required this.rrule, + required this.createdAt, + required this.updatedAt, + }); - factory CustomTimetableEvent.fromJson(Map json) => _$CustomTimetableEventFromJson(json); + factory CustomTimetableEvent.fromJson(Map json) => + _$CustomTimetableEventFromJson(json); Map toJson() => _$CustomTimetableEventToJson(this); } diff --git a/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart index dbdf476..b222705 100644 --- a/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart @@ -9,10 +9,12 @@ import 'get_custom_timetable_event_response.dart'; class GetCustomTimetableEvent extends MhslApi { GetCustomTimetableEventParams params; - GetCustomTimetableEvent(this.params) : super('server/timetable/customEvents?user=${params.user}'); + GetCustomTimetableEvent(this.params) + : super('server/timetable/customEvents?user=${params.user}'); @override - GetCustomTimetableEventResponse assemble(String raw) => GetCustomTimetableEventResponse.fromJson({'events': jsonDecode(raw)}); + GetCustomTimetableEventResponse assemble(String raw) => + GetCustomTimetableEventResponse.fromJson({'events': jsonDecode(raw)}); @override Future? request(Uri uri) => http.get(uri); diff --git a/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart index ba49152..d644acc 100644 --- a/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart @@ -3,17 +3,18 @@ import 'get_custom_timetable_event.dart'; import 'get_custom_timetable_event_params.dart'; import 'get_custom_timetable_event_response.dart'; -class GetCustomTimetableEventCache extends SimpleCache { +class GetCustomTimetableEventCache + extends SimpleCache { GetCustomTimetableEventCache( GetCustomTimetableEventParams params, { super.onUpdate, super.onError, super.renew, }) : super( - cacheTime: RequestCache.cacheMinute, - loader: () => GetCustomTimetableEvent(params).run(), - fromJson: GetCustomTimetableEventResponse.fromJson, - ) { + cacheTime: RequestCache.cacheMinute, + loader: () => GetCustomTimetableEvent(params).run(), + fromJson: GetCustomTimetableEventResponse.fromJson, + ) { start('customTimetableEvents'); } } diff --git a/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart index 58a9103..98d22ad 100644 --- a/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart @@ -8,6 +8,7 @@ class GetCustomTimetableEventParams { GetCustomTimetableEventParams(this.user); - factory GetCustomTimetableEventParams.fromJson(Map json) => _$GetCustomTimetableEventParamsFromJson(json); + factory GetCustomTimetableEventParams.fromJson(Map json) => + _$GetCustomTimetableEventParamsFromJson(json); Map toJson() => _$GetCustomTimetableEventParamsToJson(this); } diff --git a/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart index 99684a2..43d2dc0 100644 --- a/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart @@ -11,6 +11,8 @@ class GetCustomTimetableEventResponse extends ApiResponse { GetCustomTimetableEventResponse(this.events); - factory GetCustomTimetableEventResponse.fromJson(Map json) => _$GetCustomTimetableEventResponseFromJson(json); - Map toJson() => _$GetCustomTimetableEventResponseToJson(this); + factory GetCustomTimetableEventResponse.fromJson(Map json) => + _$GetCustomTimetableEventResponseFromJson(json); + Map toJson() => + _$GetCustomTimetableEventResponseToJson(this); } diff --git a/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart index 436395e..ca466f6 100644 --- a/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart +++ b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart @@ -9,11 +9,13 @@ import 'remove_custom_timetable_event_params.dart'; class RemoveCustomTimetableEvent extends MhslApi { RemoveCustomTimetableEventParams params; - RemoveCustomTimetableEvent(this.params) : super('server/timetable/customEvents'); + RemoveCustomTimetableEvent(this.params) + : super('server/timetable/customEvents'); @override void assemble(String raw) {} @override - Future? request(Uri uri) => http.delete(uri, body: jsonEncode(params.toJson())); + Future? request(Uri uri) => + http.delete(uri, body: jsonEncode(params.toJson())); } diff --git a/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart index a84ba07..2f99426 100644 --- a/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart +++ b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart @@ -8,6 +8,9 @@ class RemoveCustomTimetableEventParams { RemoveCustomTimetableEventParams(this.id); - factory RemoveCustomTimetableEventParams.fromJson(Map json) => _$RemoveCustomTimetableEventParamsFromJson(json); - Map toJson() => _$RemoveCustomTimetableEventParamsToJson(this); + factory RemoveCustomTimetableEventParams.fromJson( + Map json, + ) => _$RemoveCustomTimetableEventParamsFromJson(json); + Map toJson() => + _$RemoveCustomTimetableEventParamsToJson(this); } diff --git a/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart index 4ae91d4..ab537e6 100644 --- a/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart +++ b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart @@ -9,11 +9,13 @@ import 'update_custom_timetable_event_params.dart'; class UpdateCustomTimetableEvent extends MhslApi { UpdateCustomTimetableEventParams params; - UpdateCustomTimetableEvent(this.params) : super('server/timetable/customEvents'); + UpdateCustomTimetableEvent(this.params) + : super('server/timetable/customEvents'); @override void assemble(String raw) {} @override - Future? request(Uri uri) => http.patch(uri, body: jsonEncode(params.toJson())); + Future? request(Uri uri) => + http.patch(uri, body: jsonEncode(params.toJson())); } diff --git a/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart index 75f4dae..f4e16f4 100644 --- a/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart +++ b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart @@ -1,4 +1,3 @@ - import 'package:json_annotation/json_annotation.dart'; import '../custom_timetable_event.dart'; @@ -12,6 +11,9 @@ class UpdateCustomTimetableEventParams { UpdateCustomTimetableEventParams(this.id, this.event); - factory UpdateCustomTimetableEventParams.fromJson(Map json) => _$UpdateCustomTimetableEventParamsFromJson(json); - Map toJson() => _$UpdateCustomTimetableEventParamsToJson(this); + factory UpdateCustomTimetableEventParams.fromJson( + Map json, + ) => _$UpdateCustomTimetableEventParamsFromJson(json); + Map toJson() => + _$UpdateCustomTimetableEventParamsToJson(this); } diff --git a/lib/api/mhsl/mhsl_api.dart b/lib/api/mhsl/mhsl_api.dart index da380f3..80b79bb 100644 --- a/lib/api/mhsl/mhsl_api.dart +++ b/lib/api/mhsl/mhsl_api.dart @@ -20,7 +20,9 @@ abstract class MhslApi extends ApiRequest { T assemble(String raw); Future run() async { - final endpoint = Uri.parse('https://mhsl.eu/marianum/marianummobile/$subpath'); + final endpoint = Uri.parse( + 'https://mhsl.eu/marianum/marianummobile/$subpath', + ); final http.Response data; try { @@ -54,6 +56,7 @@ abstract class MhslApi extends ApiRequest { } } - static String dateTimeToJson(DateTime time) => Jiffy.parseFromDateTime(time).format(pattern: 'yyyy-MM-dd HH:mm:ss'); + static String dateTimeToJson(DateTime time) => + Jiffy.parseFromDateTime(time).format(pattern: 'yyyy-MM-dd HH:mm:ss'); static DateTime dateTimeFromJson(String time) => DateTime.parse(time); } diff --git a/lib/api/mhsl/notify/register/notify_register.dart b/lib/api/mhsl/notify/register/notify_register.dart index b28c3dc..2f9f4b0 100644 --- a/lib/api/mhsl/notify/register/notify_register.dart +++ b/lib/api/mhsl/notify/register/notify_register.dart @@ -1,4 +1,3 @@ - import 'dart:convert'; import 'dart:developer'; @@ -11,11 +10,8 @@ class NotifyRegister extends MhslApi { NotifyRegisterParams params; NotifyRegister(this.params) : super('notify/register/'); - @override - void assemble(String raw) { - - } + void assemble(String raw) {} @override Future request(Uri uri) { diff --git a/lib/api/mhsl/notify/register/notify_register_params.dart b/lib/api/mhsl/notify/register/notify_register_params.dart index 1c92c46..243904e 100644 --- a/lib/api/mhsl/notify/register/notify_register_params.dart +++ b/lib/api/mhsl/notify/register/notify_register_params.dart @@ -11,9 +11,10 @@ class NotifyRegisterParams { NotifyRegisterParams({ required this.username, required this.password, - required this.fcmToken + required this.fcmToken, }); - factory NotifyRegisterParams.fromJson(Map json) => _$NotifyRegisterParamsFromJson(json); + factory NotifyRegisterParams.fromJson(Map json) => + _$NotifyRegisterParamsFromJson(json); Map toJson() => _$NotifyRegisterParamsToJson(this); } diff --git a/lib/api/mhsl/server/feedback/add_feedback.dart b/lib/api/mhsl/server/feedback/add_feedback.dart index 7f69978..7b8e0ff 100644 --- a/lib/api/mhsl/server/feedback/add_feedback.dart +++ b/lib/api/mhsl/server/feedback/add_feedback.dart @@ -6,7 +6,6 @@ import 'package:http/http.dart' as http; import '../../mhsl_api.dart'; import 'add_feedback_params.dart'; - class AddFeedback extends MhslApi { AddFeedbackParams params; AddFeedback(this.params) : super('server/feedback'); @@ -15,5 +14,6 @@ class AddFeedback extends MhslApi { void assemble(String raw) {} @override - Future? request(Uri uri) => http.post(uri, body: jsonEncode(params.toJson())); + Future? request(Uri uri) => + http.post(uri, body: jsonEncode(params.toJson())); } diff --git a/lib/api/mhsl/server/feedback/add_feedback_params.dart b/lib/api/mhsl/server/feedback/add_feedback_params.dart index ecf9adb..6e7df0e 100644 --- a/lib/api/mhsl/server/feedback/add_feedback_params.dart +++ b/lib/api/mhsl/server/feedback/add_feedback_params.dart @@ -9,7 +9,6 @@ class AddFeedbackParams { String? screenshot; int appVersion; - AddFeedbackParams({ required this.user, required this.feedback, @@ -17,6 +16,7 @@ class AddFeedbackParams { required this.appVersion, }); - factory AddFeedbackParams.fromJson(Map json) => _$AddFeedbackParamsFromJson(json); + factory AddFeedbackParams.fromJson(Map json) => + _$AddFeedbackParamsFromJson(json); Map toJson() => _$AddFeedbackParamsToJson(this); } diff --git a/lib/api/mhsl/server/user_index/update/update_user_index_params.dart b/lib/api/mhsl/server/user_index/update/update_user_index_params.dart index 7fd07f4..9fead47 100644 --- a/lib/api/mhsl/server/user_index/update/update_user_index_params.dart +++ b/lib/api/mhsl/server/user_index/update/update_user_index_params.dart @@ -10,15 +10,15 @@ class UpdateUserIndexParams { int appVersion; String deviceInfo; - UpdateUserIndexParams({ required this.user, required this.username, required this.device, required this.appVersion, - required this.deviceInfo + required this.deviceInfo, }); - factory UpdateUserIndexParams.fromJson(Map json) => _$UpdateUserIndexParamsFromJson(json); + factory UpdateUserIndexParams.fromJson(Map json) => + _$UpdateUserIndexParamsFromJson(json); Map toJson() => _$UpdateUserIndexParamsToJson(this); } diff --git a/lib/api/mhsl/server/user_index/update/update_userindex.dart b/lib/api/mhsl/server/user_index/update/update_userindex.dart index 9b728b6..ed4776f 100644 --- a/lib/api/mhsl/server/user_index/update/update_userindex.dart +++ b/lib/api/mhsl/server/user_index/update/update_userindex.dart @@ -1,4 +1,3 @@ - import 'dart:async'; import 'dart:convert'; import 'dart:developer'; @@ -26,14 +25,18 @@ class UpdateUserIndex extends MhslApi { } static Future index() async { - unawaited(UpdateUserIndex( - UpdateUserIndexParams( - username: AccountData().getUsername(), - user: AccountData().getUserSecret(), - device: await AccountData().getDeviceId(), - appVersion: int.parse((await PackageInfo.fromPlatform()).buildNumber), - deviceInfo: jsonEncode((await DeviceInfoPlugin().deviceInfo).data).toString(), - ), - ).run()); + unawaited( + UpdateUserIndex( + UpdateUserIndexParams( + username: AccountData().getUsername(), + user: AccountData().getUserSecret(), + device: await AccountData().getDeviceId(), + appVersion: int.parse((await PackageInfo.fromPlatform()).buildNumber), + deviceInfo: jsonEncode( + (await DeviceInfoPlugin().deviceInfo).data, + ).toString(), + ), + ).run(), + ); } } diff --git a/lib/api/request_cache.dart b/lib/api/request_cache.dart index a5614e2..86d705b 100644 --- a/lib/api/request_cache.dart +++ b/lib/api/request_cache.dart @@ -49,7 +49,10 @@ abstract class RequestCache { Future start(String document) async { try { - final tableData = await Localstore.instance.collection(collection).doc(document).get(); + final tableData = await Localstore.instance + .collection(collection) + .doc(document) + .get(); if (tableData != null) { final cached = onLocalData(tableData['json'] as String); onUpdate?.call(cached); @@ -57,7 +60,8 @@ abstract class RequestCache { } final lastUpdate = (tableData?['lastupdate'] as num?) ?? 0; - if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < lastUpdate) { + if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < + lastUpdate) { if (renew == null || !renew!) return; } @@ -65,10 +69,12 @@ abstract class RequestCache { final newValue = await onLoad(); onUpdate?.call(newValue); onNetworkData?.call(newValue); - unawaited(Localstore.instance.collection(collection).doc(document).set({ - 'json': jsonEncode(newValue), - 'lastupdate': DateTime.now().millisecondsSinceEpoch, - })); + unawaited( + Localstore.instance.collection(collection).doc(document).set({ + 'json': jsonEncode(newValue), + 'lastupdate': DateTime.now().millisecondsSinceEpoch, + }), + ); } on Exception catch (e) { onError(e); } @@ -79,7 +85,6 @@ abstract class RequestCache { T onLocalData(String json); Future onLoad(); - } /// Concrete [RequestCache] that takes the two overrides as constructor @@ -97,22 +102,23 @@ class SimpleCache extends RequestCache { void Function(T)? onNetworkData, void Function(Exception)? onError, bool? renew, - }) : _loader = loader, - _fromJson = fromJson, - super( - cacheTime, - onUpdate, - onError: onError ?? RequestCache.ignore, - renew: renew, - onCacheData: onCacheData, - onNetworkData: onNetworkData, - ); + }) : _loader = loader, + _fromJson = fromJson, + super( + cacheTime, + onUpdate, + onError: onError ?? RequestCache.ignore, + renew: renew, + onCacheData: onCacheData, + onNetworkData: onNetworkData, + ); @override Future onLoad() => _loader(); @override - T onLocalData(String json) => _fromJson(jsonDecode(json) as Map); + T onLocalData(String json) => + _fromJson(jsonDecode(json) as Map); } /// Captures the latest cache payload (cached or network) and rethrows the @@ -120,24 +126,27 @@ class SimpleCache extends RequestCache { /// `latest`/`capturedError`/`await ready` boilerplate that DataProviders /// otherwise repeat per endpoint. Future resolveFromCache( - RequestCache Function(void Function(T) onUpdate, void Function(Exception) onError) build, { + RequestCache Function( + void Function(T) onUpdate, + void Function(Exception) onError, + ) + build, { void Function(Object)? onError, String? operationName, }) async { T? latest; Object? capturedError; - final cache = build( - (data) => latest = data, - (e) { - capturedError = e; - onError?.call(e); - }, - ); + final cache = build((data) => latest = data, (e) { + capturedError = e; + onError?.call(e); + }); await cache.ready; if (latest != null) return latest as T; final err = capturedError; if (err != null) throw err; throw ParseException( - technicalDetails: operationName != null ? 'No data and no error from $operationName' : null, + technicalDetails: operationName != null + ? 'No data and no error from $operationName' + : null, ); } diff --git a/lib/api/webuntis/queries/authenticate/authenticate.dart b/lib/api/webuntis/queries/authenticate/authenticate.dart index 551d815..183b513 100644 --- a/lib/api/webuntis/queries/authenticate/authenticate.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate.dart @@ -9,7 +9,8 @@ import 'authenticate_response.dart'; class Authenticate extends WebuntisApi { AuthenticateParams param; - Authenticate(this.param) : super('authenticate', param, authenticatedResponse: false); + Authenticate(this.param) + : super('authenticate', param, authenticatedResponse: false); @override Future run() async { @@ -17,7 +18,11 @@ class Authenticate extends WebuntisApi { try { final rawAnswer = await query(this); final decoded = jsonDecode(rawAnswer) as Map; - final response = finalize(AuthenticateResponse.fromJson(decoded['result'] as Map)); + final response = finalize( + AuthenticateResponse.fromJson( + decoded['result'] as Map, + ), + ); _lastResponse = response; if (!awaitedResponse.isCompleted) awaitedResponse.complete(); return response; @@ -40,23 +45,22 @@ class Authenticate extends WebuntisApi { static Future createSession() async { _lastResponse = await Authenticate( - AuthenticateParams( - user: AccountData().getUsername(), - password: AccountData().getPassword(), - ) + AuthenticateParams( + user: AccountData().getUsername(), + password: AccountData().getPassword(), + ), ).run(); } static Future getSession() async { - if(awaitingResponse) { + if (awaitingResponse) { await awaitedResponse.future; } - if(_lastResponse == null) { + if (_lastResponse == null) { awaitingResponse = true; await createSession(); } return _lastResponse!; - } } diff --git a/lib/api/webuntis/queries/authenticate/authenticate_params.dart b/lib/api/webuntis/queries/authenticate/authenticate_params.dart index bf3b23e..4af2cec 100644 --- a/lib/api/webuntis/queries/authenticate/authenticate_params.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate_params.dart @@ -6,12 +6,12 @@ part 'authenticate_params.g.dart'; @JsonSerializable() class AuthenticateParams extends ApiParams { - String user; String password; AuthenticateParams({required this.user, required this.password}); - factory AuthenticateParams.fromJson(Map json) => _$AuthenticateParamsFromJson(json); + factory AuthenticateParams.fromJson(Map json) => + _$AuthenticateParamsFromJson(json); Map toJson() => _$AuthenticateParamsToJson(this); } diff --git a/lib/api/webuntis/queries/authenticate/authenticate_response.dart b/lib/api/webuntis/queries/authenticate/authenticate_response.dart index 0ca87db..b9c661e 100644 --- a/lib/api/webuntis/queries/authenticate/authenticate_response.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate_response.dart @@ -6,14 +6,19 @@ part 'authenticate_response.g.dart'; @JsonSerializable() class AuthenticateResponse extends ApiResponse { - String sessionId; int personType; int personId; int klasseId; - AuthenticateResponse(this.sessionId, this.personType, this.personId, this.klasseId); + AuthenticateResponse( + this.sessionId, + this.personType, + this.personId, + this.klasseId, + ); - factory AuthenticateResponse.fromJson(Map json) => _$AuthenticateResponseFromJson(json); + factory AuthenticateResponse.fromJson(Map json) => + _$AuthenticateResponseFromJson(json); Map toJson() => _$AuthenticateResponseToJson(this); } diff --git a/lib/api/webuntis/queries/get_holidays/get_holidays.dart b/lib/api/webuntis/queries/get_holidays/get_holidays.dart index 68031ec..d314004 100644 --- a/lib/api/webuntis/queries/get_holidays/get_holidays.dart +++ b/lib/api/webuntis/queries/get_holidays/get_holidays.dart @@ -9,10 +9,17 @@ class GetHolidays extends WebuntisApi { @override Future run() async { final rawAnswer = await query(this); - return finalize(GetHolidaysResponse.fromJson(jsonDecode(rawAnswer) as Map)); + return finalize( + GetHolidaysResponse.fromJson( + jsonDecode(rawAnswer) as Map, + ), + ); } - static GetHolidaysResponseObject? find(GetHolidaysResponse holidaysResponse, {DateTime? time}) { + static GetHolidaysResponseObject? find( + GetHolidaysResponse holidaysResponse, { + DateTime? time, + }) { time ??= DateTime.now(); time = DateTime(time.year, time.month, time.day, 0, 0, 0, 0, 0); @@ -20,9 +27,8 @@ class GetHolidays extends WebuntisApi { var start = DateTime.parse(element.startDate.toString()); var end = DateTime.parse(element.endDate.toString()); - if(!start.isAfter(time) && !end.isBefore(time)) return element; + if (!start.isAfter(time) && !end.isBefore(time)) return element; } return null; } - } diff --git a/lib/api/webuntis/queries/get_holidays/get_holidays_cache.dart b/lib/api/webuntis/queries/get_holidays/get_holidays_cache.dart index a974eb1..1a9393d 100644 --- a/lib/api/webuntis/queries/get_holidays/get_holidays_cache.dart +++ b/lib/api/webuntis/queries/get_holidays/get_holidays_cache.dart @@ -4,11 +4,11 @@ import 'get_holidays_response.dart'; class GetHolidaysCache extends SimpleCache { GetHolidaysCache({super.onUpdate, super.onError, super.renew}) - : super( - cacheTime: RequestCache.cacheDay, - loader: () => GetHolidays().run(), - fromJson: GetHolidaysResponse.fromJson, - ) { + : super( + cacheTime: RequestCache.cacheDay, + loader: () => GetHolidays().run(), + fromJson: GetHolidaysResponse.fromJson, + ) { start('wu-holidays'); } } diff --git a/lib/api/webuntis/queries/get_holidays/get_holidays_response.dart b/lib/api/webuntis/queries/get_holidays/get_holidays_response.dart index 8fa2624..019603e 100644 --- a/lib/api/webuntis/queries/get_holidays/get_holidays_response.dart +++ b/lib/api/webuntis/queries/get_holidays/get_holidays_response.dart @@ -10,7 +10,8 @@ class GetHolidaysResponse extends ApiResponse { GetHolidaysResponse(this.result); - factory GetHolidaysResponse.fromJson(Map json) => _$GetHolidaysResponseFromJson(json); + factory GetHolidaysResponse.fromJson(Map json) => + _$GetHolidaysResponseFromJson(json); Map toJson() => _$GetHolidaysResponseToJson(this); } @@ -22,8 +23,15 @@ class GetHolidaysResponseObject { int startDate; int endDate; - GetHolidaysResponseObject(this.id, this.name, this.longName, this.startDate, this.endDate); + GetHolidaysResponseObject( + this.id, + this.name, + this.longName, + this.startDate, + this.endDate, + ); - factory GetHolidaysResponseObject.fromJson(Map json) => _$GetHolidaysResponseObjectFromJson(json); + factory GetHolidaysResponseObject.fromJson(Map json) => + _$GetHolidaysResponseObjectFromJson(json); Map toJson() => _$GetHolidaysResponseObjectToJson(this); } diff --git a/lib/api/webuntis/queries/get_rooms/get_rooms.dart b/lib/api/webuntis/queries/get_rooms/get_rooms.dart index 4b7bf86..d7d32b3 100644 --- a/lib/api/webuntis/queries/get_rooms/get_rooms.dart +++ b/lib/api/webuntis/queries/get_rooms/get_rooms.dart @@ -11,7 +11,11 @@ class GetRooms extends WebuntisApi { Future run() async { final rawAnswer = await query(this); try { - return finalize(GetRoomsResponse.fromJson(jsonDecode(rawAnswer) as Map)); + return finalize( + GetRoomsResponse.fromJson( + jsonDecode(rawAnswer) as Map, + ), + ); } catch (e, trace) { log(trace.toString()); log('Failed to parse getRoom data with server response: $rawAnswer'); @@ -19,5 +23,4 @@ class GetRooms extends WebuntisApi { throw Exception('Failed to parse getRoom server response: $rawAnswer'); } - } diff --git a/lib/api/webuntis/queries/get_rooms/get_rooms_cache.dart b/lib/api/webuntis/queries/get_rooms/get_rooms_cache.dart index a07a449..df62f87 100644 --- a/lib/api/webuntis/queries/get_rooms/get_rooms_cache.dart +++ b/lib/api/webuntis/queries/get_rooms/get_rooms_cache.dart @@ -4,11 +4,11 @@ import 'get_rooms_response.dart'; class GetRoomsCache extends SimpleCache { GetRoomsCache({super.onUpdate, super.onError, super.renew}) - : super( - cacheTime: RequestCache.cacheHour, - loader: () => GetRooms().run(), - fromJson: GetRoomsResponse.fromJson, - ) { + : super( + cacheTime: RequestCache.cacheHour, + loader: () => GetRooms().run(), + fromJson: GetRoomsResponse.fromJson, + ) { start('wu-rooms'); } } diff --git a/lib/api/webuntis/queries/get_rooms/get_rooms_response.dart b/lib/api/webuntis/queries/get_rooms/get_rooms_response.dart index 614406d..83bff1d 100644 --- a/lib/api/webuntis/queries/get_rooms/get_rooms_response.dart +++ b/lib/api/webuntis/queries/get_rooms/get_rooms_response.dart @@ -10,7 +10,8 @@ class GetRoomsResponse extends ApiResponse { GetRoomsResponse(this.result); - factory GetRoomsResponse.fromJson(Map json) => _$GetRoomsResponseFromJson(json); + factory GetRoomsResponse.fromJson(Map json) => + _$GetRoomsResponseFromJson(json); Map toJson() => _$GetRoomsResponseToJson(this); } @@ -22,8 +23,15 @@ class GetRoomsResponseObject { bool active; String building; - GetRoomsResponseObject(this.id, this.name, this.longName, this.active, this.building); + GetRoomsResponseObject( + this.id, + this.name, + this.longName, + this.active, + this.building, + ); - factory GetRoomsResponseObject.fromJson(Map json) => _$GetRoomsResponseObjectFromJson(json); + factory GetRoomsResponseObject.fromJson(Map json) => + _$GetRoomsResponseObjectFromJson(json); Map toJson() => _$GetRoomsResponseObjectToJson(this); } diff --git a/lib/api/webuntis/queries/get_subjects/get_subjects.dart b/lib/api/webuntis/queries/get_subjects/get_subjects.dart index 75a4f1b..736de80 100644 --- a/lib/api/webuntis/queries/get_subjects/get_subjects.dart +++ b/lib/api/webuntis/queries/get_subjects/get_subjects.dart @@ -9,6 +9,10 @@ class GetSubjects extends WebuntisApi { @override Future run() async { final rawAnswer = await query(this); - return finalize(GetSubjectsResponse.fromJson(jsonDecode(rawAnswer) as Map)); + return finalize( + GetSubjectsResponse.fromJson( + jsonDecode(rawAnswer) as Map, + ), + ); } } diff --git a/lib/api/webuntis/queries/get_subjects/get_subjects_cache.dart b/lib/api/webuntis/queries/get_subjects/get_subjects_cache.dart index c513054..0064607 100644 --- a/lib/api/webuntis/queries/get_subjects/get_subjects_cache.dart +++ b/lib/api/webuntis/queries/get_subjects/get_subjects_cache.dart @@ -4,11 +4,11 @@ import 'get_subjects_response.dart'; class GetSubjectsCache extends SimpleCache { GetSubjectsCache({super.onUpdate, super.onError, super.renew}) - : super( - cacheTime: RequestCache.cacheHour, - loader: () => GetSubjects().run(), - fromJson: GetSubjectsResponse.fromJson, - ) { + : super( + cacheTime: RequestCache.cacheHour, + loader: () => GetSubjects().run(), + fromJson: GetSubjectsResponse.fromJson, + ) { start('wu-subjects'); } } diff --git a/lib/api/webuntis/queries/get_subjects/get_subjects_response.dart b/lib/api/webuntis/queries/get_subjects/get_subjects_response.dart index 255b5ad..386933e 100644 --- a/lib/api/webuntis/queries/get_subjects/get_subjects_response.dart +++ b/lib/api/webuntis/queries/get_subjects/get_subjects_response.dart @@ -10,7 +10,8 @@ class GetSubjectsResponse extends ApiResponse { GetSubjectsResponse(this.result); - factory GetSubjectsResponse.fromJson(Map json) => _$GetSubjectsResponseFromJson(json); + factory GetSubjectsResponse.fromJson(Map json) => + _$GetSubjectsResponseFromJson(json); Map toJson() => _$GetSubjectsResponseToJson(this); } @@ -22,8 +23,15 @@ class GetSubjectsResponseObject { String alternateName; bool active; - GetSubjectsResponseObject(this.id, this.name, this.longName, this.alternateName, this.active); + GetSubjectsResponseObject( + this.id, + this.name, + this.longName, + this.alternateName, + this.active, + ); - factory GetSubjectsResponseObject.fromJson(Map json) => _$GetSubjectsResponseObjectFromJson(json); + factory GetSubjectsResponseObject.fromJson(Map json) => + _$GetSubjectsResponseObjectFromJson(json); Map toJson() => _$GetSubjectsResponseObjectToJson(this); } diff --git a/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart index 9f910e1..a872c5c 100644 --- a/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart +++ b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart @@ -11,12 +11,20 @@ class GetTimegridUnits extends WebuntisApi { Future run() async { final rawAnswer = await query(this); try { - return finalize(GetTimegridUnitsResponse.fromJson(jsonDecode(rawAnswer) as Map)); + return finalize( + GetTimegridUnitsResponse.fromJson( + jsonDecode(rawAnswer) as Map, + ), + ); } catch (e, trace) { log(trace.toString()); - log('Failed to parse getTimegridUnits data with server response: $rawAnswer'); + log( + 'Failed to parse getTimegridUnits data with server response: $rawAnswer', + ); } - throw Exception('Failed to parse getTimegridUnits server response: $rawAnswer'); + throw Exception( + 'Failed to parse getTimegridUnits server response: $rawAnswer', + ); } } diff --git a/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart index 811ed86..45ba202 100644 --- a/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart +++ b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart @@ -4,11 +4,11 @@ import 'get_timegrid_units_response.dart'; class GetTimegridUnitsCache extends SimpleCache { GetTimegridUnitsCache({super.onUpdate, super.renew}) - : super( - cacheTime: RequestCache.cacheDay, - loader: () => GetTimegridUnits().run(), - fromJson: GetTimegridUnitsResponse.fromJson, - ) { + : super( + cacheTime: RequestCache.cacheDay, + loader: () => GetTimegridUnits().run(), + fromJson: GetTimegridUnitsResponse.fromJson, + ) { start('wu-timegrid'); } } diff --git a/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart index 5b458aa..b2cfc43 100644 --- a/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart +++ b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart @@ -10,7 +10,8 @@ class GetTimegridUnitsResponse extends ApiResponse { GetTimegridUnitsResponse(this.result); - factory GetTimegridUnitsResponse.fromJson(Map json) => _$GetTimegridUnitsResponseFromJson(json); + factory GetTimegridUnitsResponse.fromJson(Map json) => + _$GetTimegridUnitsResponseFromJson(json); Map toJson() => _$GetTimegridUnitsResponseToJson(this); } @@ -21,7 +22,8 @@ class GetTimegridUnitsResponseDay { GetTimegridUnitsResponseDay(this.day, this.timeUnits); - factory GetTimegridUnitsResponseDay.fromJson(Map json) => _$GetTimegridUnitsResponseDayFromJson(json); + factory GetTimegridUnitsResponseDay.fromJson(Map json) => + _$GetTimegridUnitsResponseDayFromJson(json); Map toJson() => _$GetTimegridUnitsResponseDayToJson(this); } @@ -33,6 +35,7 @@ class GetTimegridUnitsResponseUnit { GetTimegridUnitsResponseUnit(this.name, this.startTime, this.endTime); - factory GetTimegridUnitsResponseUnit.fromJson(Map json) => _$GetTimegridUnitsResponseUnitFromJson(json); + factory GetTimegridUnitsResponseUnit.fromJson(Map json) => + _$GetTimegridUnitsResponseUnitFromJson(json); Map toJson() => _$GetTimegridUnitsResponseUnitToJson(this); } diff --git a/lib/api/webuntis/queries/get_timetable/get_timetable.dart b/lib/api/webuntis/queries/get_timetable/get_timetable.dart index d451d3c..0c60f88 100644 --- a/lib/api/webuntis/queries/get_timetable/get_timetable.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable.dart @@ -12,7 +12,10 @@ class GetTimetable extends WebuntisApi { @override Future run() async { final rawAnswer = await query(this); - return finalize(GetTimetableResponse.fromJson(jsonDecode(rawAnswer) as Map)); + return finalize( + GetTimetableResponse.fromJson( + jsonDecode(rawAnswer) as Map, + ), + ); } - } diff --git a/lib/api/webuntis/queries/get_timetable/get_timetable_cache.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_cache.dart index 56a73c9..440e1bb 100644 --- a/lib/api/webuntis/queries/get_timetable/get_timetable_cache.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable_cache.dart @@ -12,11 +12,11 @@ class GetTimetableCache extends SimpleCache { required int enddate, super.renew, }) : super( - cacheTime: RequestCache.cacheMinute, - loader: () => _load(startdate, enddate), - fromJson: GetTimetableResponse.fromJson, - onUpdate: onUpdate, - ) { + cacheTime: RequestCache.cacheMinute, + loader: () => _load(startdate, enddate), + fromJson: GetTimetableResponse.fromJson, + onUpdate: onUpdate, + ) { start('wu-timetable-$startdate-$enddate'); } diff --git a/lib/api/webuntis/queries/get_timetable/get_timetable_params.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_params.dart index 9286863..727ba9f 100644 --- a/lib/api/webuntis/queries/get_timetable/get_timetable_params.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable_params.dart @@ -10,11 +10,11 @@ class GetTimetableParams extends ApiParams { GetTimetableParams({required this.options}); - factory GetTimetableParams.fromJson(Map json) => _$GetTimetableParamsFromJson(json); + factory GetTimetableParams.fromJson(Map json) => + _$GetTimetableParamsFromJson(json); Map toJson() => _$GetTimetableParamsToJson(this); } - @JsonSerializable(explicitToJson: true) class GetTimetableParamsOptions { GetTimetableParamsOptionsElement element; @@ -59,20 +59,30 @@ class GetTimetableParamsOptions { this.klasseFields, this.roomFields, this.subjectFields, - this.teacherFields + this.teacherFields, }); - factory GetTimetableParamsOptions.fromJson(Map json) => _$GetTimetableParamsOptionsFromJson(json); + factory GetTimetableParamsOptions.fromJson(Map json) => + _$GetTimetableParamsOptionsFromJson(json); Map toJson() => _$GetTimetableParamsOptionsToJson(this); } enum GetTimetableParamsOptionsFields { - @JsonValue('id') id, - @JsonValue('name') name, - @JsonValue('longname') longname, - @JsonValue('externalkey') externalkey; + @JsonValue('id') + id, + @JsonValue('name') + name, + @JsonValue('longname') + longname, + @JsonValue('externalkey') + externalkey; - static List all = [id, name, longname, externalkey]; + static List all = [ + id, + name, + longname, + externalkey, + ]; } @JsonSerializable() @@ -82,13 +92,23 @@ class GetTimetableParamsOptionsElement { @JsonKey(includeIfNull: false) GetTimetableParamsOptionsElementKeyType? keyType; - GetTimetableParamsOptionsElement({required this.id, required this.type, this.keyType}); - factory GetTimetableParamsOptionsElement.fromJson(Map json) => _$GetTimetableParamsOptionsElementFromJson(json); - Map toJson() => _$GetTimetableParamsOptionsElementToJson(this); + GetTimetableParamsOptionsElement({ + required this.id, + required this.type, + this.keyType, + }); + factory GetTimetableParamsOptionsElement.fromJson( + Map json, + ) => _$GetTimetableParamsOptionsElementFromJson(json); + Map toJson() => + _$GetTimetableParamsOptionsElementToJson(this); } enum GetTimetableParamsOptionsElementKeyType { - @JsonValue('id') id, - @JsonValue('name') name, - @JsonValue('externalkey') externalkey + @JsonValue('id') + id, + @JsonValue('name') + name, + @JsonValue('externalkey') + externalkey, } diff --git a/lib/api/webuntis/queries/get_timetable/get_timetable_response.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_response.dart index 05e1ea1..76ff345 100644 --- a/lib/api/webuntis/queries/get_timetable/get_timetable_response.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable_response.dart @@ -10,9 +10,9 @@ class GetTimetableResponse extends ApiResponse { GetTimetableResponse(this.result); - factory GetTimetableResponse.fromJson(Map json) => _$GetTimetableResponseFromJson(json); + factory GetTimetableResponse.fromJson(Map json) => + _$GetTimetableResponseFromJson(json); Map toJson() => _$GetTimetableResponseToJson(this); - } @JsonSerializable(explicitToJson: true) @@ -55,10 +55,11 @@ class GetTimetableResponseObject { required this.kl, required this.te, required this.su, - required this.ro + required this.ro, }); - factory GetTimetableResponseObject.fromJson(Map json) => _$GetTimetableResponseObjectFromJson(json); + factory GetTimetableResponseObject.fromJson(Map json) => + _$GetTimetableResponseObjectFromJson(json); Map toJson() => _$GetTimetableResponseObjectToJson(this); } @@ -68,8 +69,11 @@ class GetTimetableResponseObjectFields { GetTimetableResponseObjectFields(this.te); - factory GetTimetableResponseObjectFields.fromJson(Map json) => _$GetTimetableResponseObjectFieldsFromJson(json); - Map toJson() => _$GetTimetableResponseObjectFieldsToJson(this); + factory GetTimetableResponseObjectFields.fromJson( + Map json, + ) => _$GetTimetableResponseObjectFieldsFromJson(json); + Map toJson() => + _$GetTimetableResponseObjectFieldsToJson(this); } @JsonSerializable() @@ -79,10 +83,18 @@ class GetTimetableResponseObjectFieldsObject { String? longname; String? externalkey; - GetTimetableResponseObjectFieldsObject({this.id, this.name, this.longname, this.externalkey}); + GetTimetableResponseObjectFieldsObject({ + this.id, + this.name, + this.longname, + this.externalkey, + }); - factory GetTimetableResponseObjectFieldsObject.fromJson(Map json) => _$GetTimetableResponseObjectFieldsObjectFromJson(json); - Map toJson() => _$GetTimetableResponseObjectFieldsObjectToJson(this); + factory GetTimetableResponseObjectFieldsObject.fromJson( + Map json, + ) => _$GetTimetableResponseObjectFieldsObjectFromJson(json); + Map toJson() => + _$GetTimetableResponseObjectFieldsObjectToJson(this); } @JsonSerializable() @@ -92,10 +104,17 @@ class GetTimetableResponseObjectClass { String longname; String? externalkey; - GetTimetableResponseObjectClass(this.id, this.name, this.longname, this.externalkey); + GetTimetableResponseObjectClass( + this.id, + this.name, + this.longname, + this.externalkey, + ); - factory GetTimetableResponseObjectClass.fromJson(Map json) => _$GetTimetableResponseObjectClassFromJson(json); - Map toJson() => _$GetTimetableResponseObjectClassToJson(this); + factory GetTimetableResponseObjectClass.fromJson(Map json) => + _$GetTimetableResponseObjectClassFromJson(json); + Map toJson() => + _$GetTimetableResponseObjectClassToJson(this); } @JsonSerializable() @@ -107,11 +126,20 @@ class GetTimetableResponseObjectTeacher { String? orgname; String? externalkey; + GetTimetableResponseObjectTeacher( + this.id, + this.name, + this.longname, + this.orgid, + this.orgname, + this.externalkey, + ); - GetTimetableResponseObjectTeacher(this.id, this.name, this.longname, this.orgid, this.orgname, this.externalkey); - - factory GetTimetableResponseObjectTeacher.fromJson(Map json) => _$GetTimetableResponseObjectTeacherFromJson(json); - Map toJson() => _$GetTimetableResponseObjectTeacherToJson(this); + factory GetTimetableResponseObjectTeacher.fromJson( + Map json, + ) => _$GetTimetableResponseObjectTeacherFromJson(json); + Map toJson() => + _$GetTimetableResponseObjectTeacherToJson(this); } @JsonSerializable() @@ -122,8 +150,11 @@ class GetTimetableResponseObjectSubject { GetTimetableResponseObjectSubject(this.id, this.name, this.longname); - factory GetTimetableResponseObjectSubject.fromJson(Map json) => _$GetTimetableResponseObjectSubjectFromJson(json); - Map toJson() => _$GetTimetableResponseObjectSubjectToJson(this); + factory GetTimetableResponseObjectSubject.fromJson( + Map json, + ) => _$GetTimetableResponseObjectSubjectFromJson(json); + Map toJson() => + _$GetTimetableResponseObjectSubjectToJson(this); } @JsonSerializable() @@ -134,6 +165,7 @@ class GetTimetableResponseObjectRoom { GetTimetableResponseObjectRoom(this.id, this.name, this.longname); - factory GetTimetableResponseObjectRoom.fromJson(Map json) => _$GetTimetableResponseObjectRoomFromJson(json); + factory GetTimetableResponseObjectRoom.fromJson(Map json) => + _$GetTimetableResponseObjectRoomFromJson(json); Map toJson() => _$GetTimetableResponseObjectRoomToJson(this); } diff --git a/lib/api/webuntis/services/lesson_resolver.dart b/lib/api/webuntis/services/lesson_resolver.dart index 77128e5..5403001 100644 --- a/lib/api/webuntis/services/lesson_resolver.dart +++ b/lib/api/webuntis/services/lesson_resolver.dart @@ -9,10 +9,14 @@ import '../queries/get_subjects/get_subjects_response.dart'; /// When a record is missing the resolver returns a placeholder fallback /// instead of `null` so call sites stay branch-free. class LessonResolver { - static GetSubjectsResponseObject resolveSubject(TimetableState state, int? id) { + static GetSubjectsResponseObject resolveSubject( + TimetableState state, + int? id, + ) { final fallback = GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true); if (id == null) return fallback; - return state.subjects?.result.firstWhereOrNull((s) => s.id == id) ?? fallback; + return state.subjects?.result.firstWhereOrNull((s) => s.id == id) ?? + fallback; } static GetRoomsResponseObject resolveRoom(TimetableState state, int? id) { @@ -61,9 +65,7 @@ class LessonFormatter { /// optional longname (rendered in parentheses if it differs from `name`), /// and optional extra info (joined with `·`). static String formatLine(String name, {String? longname, String? extra}) { - final parts = [ - if (name.isNotEmpty) name else '?', - ]; + final parts = [if (name.isNotEmpty) name else '?']; final ln = (longname ?? '').trim(); if (ln.isNotEmpty && ln != name) parts.add('($ln)'); final ex = (extra ?? '').trim(); diff --git a/lib/api/webuntis/webuntis_api.dart b/lib/api/webuntis/webuntis_api.dart index 690bf94..93d7d87 100644 --- a/lib/api/webuntis/webuntis_api.dart +++ b/lib/api/webuntis/webuntis_api.dart @@ -14,18 +14,24 @@ import 'queries/authenticate/authenticate.dart'; import 'webuntis_error.dart'; abstract class WebuntisApi extends ApiRequest { - Uri endpoint = Uri.parse('https://${EndpointData().webuntis().full()}/WebUntis/jsonrpc.do?school=marianum-fulda'); + Uri endpoint = Uri.parse( + 'https://${EndpointData().webuntis().full()}/WebUntis/jsonrpc.do?school=marianum-fulda', + ); String method; ApiParams? genericParam; http.Response? response; bool authenticatedResponse; - WebuntisApi(this.method, this.genericParam, {this.authenticatedResponse = true}); - + WebuntisApi( + this.method, + this.genericParam, { + this.authenticatedResponse = true, + }); Future query(WebuntisApi untis, {bool retry = false}) async { - final body = '{"id":"ID","method":"$method","params":${untis._body()},"jsonrpc":"2.0"}'; + final body = + '{"id":"ID","method":"$method","params":${untis._body()},"jsonrpc":"2.0"}'; var sessionId = '0'; if (authenticatedResponse) { @@ -38,13 +44,20 @@ abstract class WebuntisApi extends ApiRequest { try { jsonData = jsonDecode(data.body) as Map; } on FormatException catch (e) { - throw ParseException(technicalDetails: 'WebUntis JSON decode: ${e.message}'); + throw ParseException( + technicalDetails: 'WebUntis JSON decode: ${e.message}', + ); } final error = jsonData['error'] as Map?; if (error != null) { final code = error['code'] as int; if (code == -8520) { - if (retry) throw WebuntisError('Authentication was tried (probably session timeout), but was not successful!', -8520); + if (retry) { + throw WebuntisError( + 'Authentication was tried (probably session timeout), but was not successful!', + -8520, + ); + } await Authenticate.createSession(); return query(untis, retry: true); } else { @@ -65,14 +78,22 @@ abstract class WebuntisApi extends ApiRequest { Future post(String data, Map? headers) async { try { - return await http.post(endpoint, body: data, headers: headers).timeout( + return await http + .post(endpoint, body: data, headers: headers) + .timeout( const Duration(seconds: 10), - onTimeout: () => throw NetworkException.timeout(technicalDetails: 'WebUntis $method timed out after 10s'), + onTimeout: () => throw NetworkException.timeout( + technicalDetails: 'WebUntis $method timed out after 10s', + ), ); } on SocketException catch (e) { - throw NetworkException(technicalDetails: 'WebUntis $method: ${e.message}'); + throw NetworkException( + technicalDetails: 'WebUntis $method: ${e.message}', + ); } on http.ClientException catch (e) { - throw NetworkException(technicalDetails: 'WebUntis $method: ${e.message}'); + throw NetworkException( + technicalDetails: 'WebUntis $method: ${e.message}', + ); } } } diff --git a/lib/app.dart b/lib/app.dart index b33a5e5..6ca7924 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -95,7 +95,9 @@ class _AppState extends State with WidgetsBindingObserver { if (!mounted) return; NotificationController.onForegroundMessageHandler(message, context); }); - FirebaseMessaging.onBackgroundMessage(NotificationController.onBackgroundMessageHandler); + FirebaseMessaging.onBackgroundMessage( + NotificationController.onBackgroundMessageHandler, + ); FirebaseMessaging.onMessageOpenedApp.listen((message) { if (!mounted) return; @@ -119,83 +121,89 @@ class _AppState extends State with WidgetsBindingObserver { } @override - Widget build(BuildContext context) => BlocBuilder( - builder: (context, _) { - final bottomBarModules = AppModule.getBottomBarModules(context); - final totalTabs = bottomBarModules.length + 1; - final currentIndex = Main.bottomNavigator.index; + Widget build( + BuildContext context, + ) => BlocBuilder( + builder: (context, _) { + final bottomBarModules = AppModule.getBottomBarModules(context); + final totalTabs = bottomBarModules.length + 1; + final currentIndex = Main.bottomNavigator.index; - // The bottom-bar layout is identified by the ordered list of module - // names plus the trailing 'more' slot. Whenever this layout changes - // — slot count, reordering, or hiding a module — we recreate the - // entire PersistentTabView via the [layoutKey] below. The package - // caches per-tab navigator state by index in `_navigatorKeys`, and - // its internal `alignLength` only ever appends or trims at the end. - // So when the module sitting at e.g. index 3 changes, the navigator - // at that index still serves the old screen's route stack and the - // user sees stale content. Re-mounting clears those stacks; the - // trade-off (losing in-tab pushed routes on a settings change) is - // acceptable since the user explicitly re-shaped the bar. - final layoutKey = ValueKey('${bottomBarModules.map((m) => m.module.name).join('|')}|more'); - - if (totalTabs != _knownTotalTabs) { - var targetIndex = currentIndex; - if (_userOnLastTab) { - targetIndex = totalTabs - 1; - } else if (currentIndex >= totalTabs) { - targetIndex = totalTabs - 1; - } - // Re-mounting PTV with a new key constructs fresh internals from - // its controller's current index. If the controller still points - // past the new tab list, Style6BottomNavBar (and others) crash on - // out-of-range access during initState. Replace the controller - // atomically with one initialised at the safe target index so the - // new PTV mounts cleanly. - if (targetIndex != currentIndex) { - Main.bottomNavigator.removeListener(_onTabControllerChanged); - Main.bottomNavigator = PersistentTabController(initialIndex: targetIndex); - Main.bottomNavigator.addListener(_onTabControllerChanged); - _userOnLastTab = targetIndex == totalTabs - 1; - } - } - - _knownTotalTabs = totalTabs; - - return PersistentTabView( - key: layoutKey, - controller: Main.bottomNavigator, - navBarOverlap: const NavBarOverlap.none(), - backgroundColor: Theme.of(context).colorScheme.primary, - handleAndroidBackButtonPress: true, - screenTransitionAnimation: const ScreenTransitionAnimation( - curve: Curves.easeOutQuad, - duration: Duration(milliseconds: 200), - ), - tabs: [ - ...bottomBarModules.map((e) => e.toBottomTab(context)), - PersistentTabConfig( - screen: const Breaker(breaker: BreakerArea.more, child: Overhang()), - item: ItemConfig( - activeForegroundColor: Theme.of(context).primaryColor, - inactiveForegroundColor: Theme.of(context).colorScheme.secondary, - icon: const Icon(Icons.apps), - title: 'Mehr', - ), - ), - ], - navBarBuilder: (config) => Style6BottomNavBar( - // Style6BottomNavBar builds its internal animation controller list - // in initState and never grows it on didUpdateWidget. Keying by the - // item count forces a fresh State whenever the slot count changes, - // which avoids a RangeError when more tabs slide in. - key: ValueKey(config.items.length), - navBarConfig: config, - navBarDecoration: NavBarDecoration( - border: const Border(top: BorderSide(width: 1, color: Colors.grey)), - color: Theme.of(context).colorScheme.surface, - ), - ), - ); - }, + // The bottom-bar layout is identified by the ordered list of module + // names plus the trailing 'more' slot. Whenever this layout changes + // — slot count, reordering, or hiding a module — we recreate the + // entire PersistentTabView via the [layoutKey] below. The package + // caches per-tab navigator state by index in `_navigatorKeys`, and + // its internal `alignLength` only ever appends or trims at the end. + // So when the module sitting at e.g. index 3 changes, the navigator + // at that index still serves the old screen's route stack and the + // user sees stale content. Re-mounting clears those stacks; the + // trade-off (losing in-tab pushed routes on a settings change) is + // acceptable since the user explicitly re-shaped the bar. + final layoutKey = ValueKey( + '${bottomBarModules.map((m) => m.module.name).join('|')}|more', ); + + if (totalTabs != _knownTotalTabs) { + var targetIndex = currentIndex; + if (_userOnLastTab) { + targetIndex = totalTabs - 1; + } else if (currentIndex >= totalTabs) { + targetIndex = totalTabs - 1; + } + // Re-mounting PTV with a new key constructs fresh internals from + // its controller's current index. If the controller still points + // past the new tab list, Style6BottomNavBar (and others) crash on + // out-of-range access during initState. Replace the controller + // atomically with one initialised at the safe target index so the + // new PTV mounts cleanly. + if (targetIndex != currentIndex) { + Main.bottomNavigator.removeListener(_onTabControllerChanged); + Main.bottomNavigator = PersistentTabController( + initialIndex: targetIndex, + ); + Main.bottomNavigator.addListener(_onTabControllerChanged); + _userOnLastTab = targetIndex == totalTabs - 1; + } + } + + _knownTotalTabs = totalTabs; + + return PersistentTabView( + key: layoutKey, + controller: Main.bottomNavigator, + navBarOverlap: const NavBarOverlap.none(), + backgroundColor: Theme.of(context).colorScheme.primary, + handleAndroidBackButtonPress: true, + screenTransitionAnimation: const ScreenTransitionAnimation( + curve: Curves.easeOutQuad, + duration: Duration(milliseconds: 200), + ), + tabs: [ + ...bottomBarModules.map((e) => e.toBottomTab(context)), + PersistentTabConfig( + screen: const Breaker(breaker: BreakerArea.more, child: Overhang()), + item: ItemConfig( + activeForegroundColor: Theme.of(context).primaryColor, + inactiveForegroundColor: Theme.of(context).colorScheme.secondary, + icon: const Icon(Icons.apps), + title: 'Mehr', + ), + ), + ], + navBarBuilder: (config) => Style6BottomNavBar( + // Style6BottomNavBar builds its internal animation controller list + // in initState and never grows it on didUpdateWidget. Keying by the + // item count forces a fresh State whenever the slot count changes, + // which avoids a RangeError when more tabs slide in. + key: ValueKey(config.items.length), + navBarConfig: config, + navBarDecoration: NavBarDecoration( + border: const Border(top: BorderSide(width: 1, color: Colors.grey)), + color: Theme.of(context).colorScheme.surface, + ), + ), + ); + }, + ); } diff --git a/lib/extensions/date_time.dart b/lib/extensions/date_time.dart index 7dc0d52..830f2b7 100644 --- a/lib/extensions/date_time.dart +++ b/lib/extensions/date_time.dart @@ -2,11 +2,14 @@ import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; extension IsSameDay on DateTime { - bool isSameDay(DateTime other) => year == other.year && month == other.month && day == other.day; + bool isSameDay(DateTime other) => + year == other.year && month == other.month && day == other.day; - DateTime nextWeekday(int day) => add(Duration(days: (day - weekday) % DateTime.daysPerWeek)); + DateTime nextWeekday(int day) => + add(Duration(days: (day - weekday) % DateTime.daysPerWeek)); - DateTime withTime(TimeOfDay time) => copyWith(hour: time.hour, minute: time.minute); + DateTime withTime(TimeOfDay time) => + copyWith(hour: time.hour, minute: time.minute); TimeOfDay toTimeOfDay() => TimeOfDay(hour: hour, minute: minute); @@ -25,15 +28,20 @@ extension IsSameDay on DateTime { extension DateTimeFormatting on DateTime { String formatHm() => Jiffy.parseFromDateTime(this).format(pattern: 'HH:mm'); - String formatDate() => Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM.yyyy'); + String formatDate() => + Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM.yyyy'); - String formatDateTime() => Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM.yyyy HH:mm'); + String formatDateTime() => + Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM.yyyy HH:mm'); - String formatDateShort() => Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM.'); + String formatDateShort() => + Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM.'); - String formatDateShortHm() => Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM. HH:mm'); + String formatDateShortHm() => + Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM. HH:mm'); - String formatMonthYear() => Jiffy.parseFromDateTime(this).format(pattern: 'MMMM yyyy'); + String formatMonthYear() => + Jiffy.parseFromDateTime(this).format(pattern: 'MMMM yyyy'); String formatRelative() => Jiffy.parseFromDateTime(this).fromNow(); diff --git a/lib/extensions/render_not_null.dart b/lib/extensions/render_not_null.dart index 3d267a0..b7be60f 100644 --- a/lib/extensions/render_not_null.dart +++ b/lib/extensions/render_not_null.dart @@ -1,3 +1,4 @@ extension RenderNotNullExt on T? { - R? wrapNullable(R Function(T data) defaultValueCallback) => this != null ? defaultValueCallback(this as T) : null; + R? wrapNullable(R Function(T data) defaultValueCallback) => + this != null ? defaultValueCallback(this as T) : null; } diff --git a/lib/extensions/text.dart b/lib/extensions/text.dart index 735a860..879e7ec 100644 --- a/lib/extensions/text.dart +++ b/lib/extensions/text.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; extension TextExt on Text { Size get size { final textPainter = TextPainter( - text: TextSpan(text: data, style: style), - maxLines: 1, - textDirection: TextDirection.ltr + text: TextSpan(text: data, style: style), + maxLines: 1, + textDirection: TextDirection.ltr, )..layout(minWidth: 0, maxWidth: double.infinity); return textPainter.size; } diff --git a/lib/extensions/time_of_day.dart b/lib/extensions/time_of_day.dart index c99a47e..b2c39e0 100644 --- a/lib/extensions/time_of_day.dart +++ b/lib/extensions/time_of_day.dart @@ -5,5 +5,6 @@ extension TimeOfDayExt on TimeOfDay { bool isAfter(TimeOfDay other) => hour > other.hour && minute > other.minute; - TimeOfDay add({int hours = 0, int minutes = 0}) => replacing(hour: hour + hours, minute: minute + minutes); + TimeOfDay add({int hours = 0, int minutes = 0}) => + replacing(hour: hour + hours, minute: minute + minutes); } diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart index e389256..9521cbc 100644 --- a/lib/firebase_options.dart +++ b/lib/firebase_options.dart @@ -63,7 +63,8 @@ class DefaultFirebaseOptions { messagingSenderId: '522850592536', projectId: 'marmobile-33b10', storageBucket: 'marmobile-33b10.appspot.com', - iosClientId: '522850592536-edj90sbbnkjqe3aqui37j8enu93v4fk8.apps.googleusercontent.com', + iosClientId: + '522850592536-edj90sbbnkjqe3aqui37j8enu93v4fk8.apps.googleusercontent.com', iosBundleId: 'eu.mhsl.marianum.mobile.client', ); } diff --git a/lib/main.dart b/lib/main.dart index e392be5..3d02fec 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -40,19 +40,28 @@ Future main() async { log('MarianumMobile started'); WidgetsFlutterBinding.ensureInitialized(); - void addCertificateAsTrusted(ByteData certificate) => - SecurityContext.defaultContext.setTrustedCertificatesBytes(certificate.buffer.asUint8List()); + void addCertificateAsTrusted(ByteData certificate) => SecurityContext + .defaultContext + .setTrustedCertificatesBytes(certificate.buffer.asUint8List()); final initialisationTasks = [ Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform) .then((_) {}) .onError((error, _) => log('Error initializing Firebase: $error')), - PlatformAssetBundle().load('assets/ca/lets-encrypt-r3.pem').then(addCertificateAsTrusted), - PlatformAssetBundle().load('assets/ca/lets-encrypt-r10.pem').then(addCertificateAsTrusted), - PlatformAssetBundle().load('assets/ca/lets-encrypt-r13.pem').then(addCertificateAsTrusted), + PlatformAssetBundle() + .load('assets/ca/lets-encrypt-r3.pem') + .then(addCertificateAsTrusted), + PlatformAssetBundle() + .load('assets/ca/lets-encrypt-r10.pem') + .then(addCertificateAsTrusted), + PlatformAssetBundle() + .load('assets/ca/lets-encrypt-r13.pem') + .then(addCertificateAsTrusted), Future(() async { final storage = await HydratedStorage.build( - storageDirectory: HydratedStorageDirectory((await getTemporaryDirectory()).path), + storageDirectory: HydratedStorageDirectory( + (await getTemporaryDirectory()).path, + ), ); HydratedBloc.storage = storage; }), @@ -71,27 +80,30 @@ Future main() async { if (kReleaseMode) { ErrorWidget.builder = (error) => Material( - color: Colors.white, - child: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.phonelink_erase_rounded, size: 40), - const SizedBox(height: 12), - Text(error.toStringShort(), textAlign: TextAlign.center), - ], - ), - ), + color: Colors.white, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.phonelink_erase_rounded, size: 40), + const SizedBox(height: 12), + Text(error.toStringShort(), textAlign: TextAlign.center), + ], ), - ); + ), + ), + ); } // Capture uncaught Flutter and platform errors so they show up in logs // instead of being silently swallowed. FlutterError.onError = (details) { - log('Uncaught Flutter error: ${details.exception}', stackTrace: details.stack); + log( + 'Uncaught Flutter error: ${details.exception}', + stackTrace: details.stack, + ); FlutterError.presentError(details); }; PlatformDispatcher.instance.onError = (error, stack) { @@ -104,9 +116,13 @@ Future main() async { MultiBlocProvider( providers: [ BlocProvider(create: (_) => SettingsCubit()), - BlocProvider(create: (_) => AccountBloc( - initialStatus: AccountData().isPopulated() ? AccountStatus.loggedIn : AccountStatus.loggedOut, - )), + BlocProvider( + create: (_) => AccountBloc( + initialStatus: AccountData().isPopulated() + ? AccountStatus.loggedIn + : AccountStatus.loggedOut, + ), + ), BlocProvider(create: (_) => BreakerBloc()), BlocProvider(create: (_) => ChatListBloc()), BlocProvider(create: (_) => ChatBloc()), @@ -120,7 +136,9 @@ Future main() async { class Main extends StatefulWidget { const Main({super.key}); - static PersistentTabController bottomNavigator = PersistentTabController(initialIndex: 0); + static PersistentTabController bottomNavigator = PersistentTabController( + initialIndex: 0, + ); @override State
createState() => _MainState(); @@ -134,107 +152,116 @@ class _MainState extends State
{ AccountData().waitForPopulation().then((value) { if (!mounted) return; - context.read().setStatus(value ? AccountStatus.loggedIn : AccountStatus.loggedOut); + context.read().setStatus( + value ? AccountStatus.loggedIn : AccountStatus.loggedOut, + ); }); } @override Widget build(BuildContext context) => Directionality( - textDirection: TextDirection.ltr, - child: BlocBuilder( - builder: (context, settings) { - final devToolsSettings = settings.devToolsSettings; - return MaterialApp( - showPerformanceOverlay: devToolsSettings.showPerformanceOverlay, - checkerboardOffscreenLayers: devToolsSettings.checkerboardOffscreenLayers, - checkerboardRasterCacheImages: devToolsSettings.checkerboardRasterCacheImages, - debugShowCheckedModeBanner: false, - localizationsDelegates: const [ - ...GlobalMaterialLocalizations.delegates, - GlobalWidgetsLocalizations.delegate, - ], - supportedLocales: const [Locale('de'), Locale('en')], - locale: const Locale('de'), - title: 'Marianum Fulda', - themeMode: settings.appTheme, - theme: LightAppTheme.theme, - darkTheme: DarkAppTheme.theme, - // Brand-colored backdrop behind every route. During the logout - // home-swap and route pop animations the framework can briefly - // expose the layer below the topmost Scaffold; without this - // the dark Material default shows through and the user sees a - // black flash. - builder: (context, child) => ColoredBox( - color: LightAppTheme.marianumRed, - child: child ?? const SizedBox.shrink(), - ), - home: LoaderOverlay( - child: Breaker( - breaker: BreakerArea.global, - child: BlocConsumer( - listenWhen: (previous, current) => previous.status != current.status, - listener: (context, accountState) { - if (accountState.status != AccountStatus.loggedOut) return; - // Routes pushed via AppRoutes (e.g. Settings) live on the - // root navigator and survive the home swap below, so they - // would still cover the Login screen after logout. Pop - // them here so the user immediately sees Login. - final navigator = Navigator.of(context); - if (navigator.canPop()) { - navigator.popUntil((route) => route.isFirst); - } - // Capture bloc references before the post-frame callback - // — by the time it runs the dialog/Settings context is - // gone but this listener context is still valid. - final settingsCubit = context.read(); - final timetableBloc = context.read(); - final chatListBloc = context.read(); - final chatBloc = context.read(); - final breakerBloc = context.read(); - // Defer the actual wipe until after this frame so the - // App tree (TimetableBloc/ChatListBloc watchers etc.) - // is already torn down. Resetting blocs while App is - // still in front caused a black-frame race. - WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(_wipeUserState( - settingsCubit: settingsCubit, - timetableBloc: timetableBloc, - chatListBloc: chatListBloc, - chatBloc: chatBloc, - breakerBloc: breakerBloc, - )); - }); - }, - builder: (context, accountState) { - switch (accountState.status) { - case AccountStatus.loggedIn: - return const App(); - case AccountStatus.loggedOut: - return const Login(); - case AccountStatus.undefined: - return Scaffold( - backgroundColor: LightAppTheme.marianumRed, - body: const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AppProgressIndicator.large(color: Colors.white), - SizedBox(height: 16), - Text('Konto wird geladen…', - style: TextStyle(color: Colors.white)), - ], + textDirection: TextDirection.ltr, + child: BlocBuilder( + builder: (context, settings) { + final devToolsSettings = settings.devToolsSettings; + return MaterialApp( + showPerformanceOverlay: devToolsSettings.showPerformanceOverlay, + checkerboardOffscreenLayers: + devToolsSettings.checkerboardOffscreenLayers, + checkerboardRasterCacheImages: + devToolsSettings.checkerboardRasterCacheImages, + debugShowCheckedModeBanner: false, + localizationsDelegates: const [ + ...GlobalMaterialLocalizations.delegates, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: const [Locale('de'), Locale('en')], + locale: const Locale('de'), + title: 'Marianum Fulda', + themeMode: settings.appTheme, + theme: LightAppTheme.theme, + darkTheme: DarkAppTheme.theme, + // Brand-colored backdrop behind every route. During the logout + // home-swap and route pop animations the framework can briefly + // expose the layer below the topmost Scaffold; without this + // the dark Material default shows through and the user sees a + // black flash. + builder: (context, child) => ColoredBox( + color: LightAppTheme.marianumRed, + child: child ?? const SizedBox.shrink(), + ), + home: LoaderOverlay( + child: Breaker( + breaker: BreakerArea.global, + child: BlocConsumer( + listenWhen: (previous, current) => + previous.status != current.status, + listener: (context, accountState) { + if (accountState.status != AccountStatus.loggedOut) return; + // Routes pushed via AppRoutes (e.g. Settings) live on the + // root navigator and survive the home swap below, so they + // would still cover the Login screen after logout. Pop + // them here so the user immediately sees Login. + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.popUntil((route) => route.isFirst); + } + // Capture bloc references before the post-frame callback + // — by the time it runs the dialog/Settings context is + // gone but this listener context is still valid. + final settingsCubit = context.read(); + final timetableBloc = context.read(); + final chatListBloc = context.read(); + final chatBloc = context.read(); + final breakerBloc = context.read(); + // Defer the actual wipe until after this frame so the + // App tree (TimetableBloc/ChatListBloc watchers etc.) + // is already torn down. Resetting blocs while App is + // still in front caused a black-frame race. + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited( + _wipeUserState( + settingsCubit: settingsCubit, + timetableBloc: timetableBloc, + chatListBloc: chatListBloc, + chatBloc: chatBloc, + breakerBloc: breakerBloc, + ), + ); + }); + }, + builder: (context, accountState) { + switch (accountState.status) { + case AccountStatus.loggedIn: + return const App(); + case AccountStatus.loggedOut: + return const Login(); + case AccountStatus.undefined: + return Scaffold( + backgroundColor: LightAppTheme.marianumRed, + body: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AppProgressIndicator.large(color: Colors.white), + SizedBox(height: 16), + Text( + 'Konto wird geladen…', + style: TextStyle(color: Colors.white), ), - ), - ); - } - }, - ), - ), + ], + ), + ), + ); + } + }, ), - ); - }, - ), - ); + ), + ), + ); + }, + ), + ); } Future _wipeUserState({ diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart index 35680da..6ec006a 100644 --- a/lib/model/account_data.dart +++ b/lib/model/account_data.dart @@ -34,11 +34,16 @@ class AccountData { return _password!; } - String getUserSecret() => - sha512.convert(utf8.encode('${getUsername()}:${getPassword()}')).toString(); + String getUserSecret() => sha512 + .convert(utf8.encode('${getUsername()}:${getPassword()}')) + .toString(); Future getDeviceId() async => sha512 - .convert(utf8.encode('${getUserSecret()}@${await FirebaseMessaging.instance.getToken()}')) + .convert( + utf8.encode( + '${getUserSecret()}@${await FirebaseMessaging.instance.getToken()}', + ), + ) .toString(); Future setData(String username, String password) async { @@ -92,7 +97,11 @@ class AccountData { /// Prefer this over embedding credentials in URLs — error logs and crash /// reports often capture the URL but not headers. String getBasicAuthHeader() { - if (!isPopulated()) throw Exception('AccountData (e.g. username or password) is not initialized!'); + if (!isPopulated()) { + throw Exception( + 'AccountData (e.g. username or password) is not initialized!', + ); + } return 'Basic ${base64Encode(utf8.encode('$_username:$_password'))}'; } diff --git a/lib/model/data_cleaner.dart b/lib/model/data_cleaner.dart index 4094023..ed88004 100644 --- a/lib/model/data_cleaner.dart +++ b/lib/model/data_cleaner.dart @@ -4,11 +4,20 @@ import '../api/request_cache.dart'; class DataCleaner { static Future cleanOldCache() async { - final cacheData = await Localstore.instance.collection(RequestCache.collection).get(); + final cacheData = await Localstore.instance + .collection(RequestCache.collection) + .get(); cacheData?.forEach((key, value) async { - final lastUpdate = DateTime.fromMillisecondsSinceEpoch(value['lastupdate'] as int); - if (DateTime.now().subtract(const Duration(days: 200)).isAfter(lastUpdate)) { - await Localstore.instance.collection(RequestCache.collection).doc(key.split('/').last).delete(); + final lastUpdate = DateTime.fromMillisecondsSinceEpoch( + value['lastupdate'] as int, + ); + if (DateTime.now() + .subtract(const Duration(days: 200)) + .isAfter(lastUpdate)) { + await Localstore.instance + .collection(RequestCache.collection) + .doc(key.split('/').last) + .delete(); } }); } diff --git a/lib/model/endpoint_data.dart b/lib/model/endpoint_data.dart index 6bdd4f4..6bbe016 100644 --- a/lib/model/endpoint_data.dart +++ b/lib/model/endpoint_data.dart @@ -1,10 +1,6 @@ - import 'account_data.dart'; -enum EndpointMode { - live, - stage, -} +enum EndpointMode { live, stage } class EndpointOptions { Endpoint live; @@ -12,7 +8,7 @@ class EndpointOptions { EndpointOptions({required this.live, required this.staged}); Endpoint get(EndpointMode mode) { - if(staged == null || mode == EndpointMode.live) return live; + if (staged == null || mode == EndpointMode.live) return live; return staged!; } } @@ -36,27 +32,21 @@ class EndpointData { EndpointMode getEndpointMode() { late String existingName; existingName = AccountData().getUsername(); - return existingName.startsWith('google') ? EndpointMode.stage : EndpointMode.live; + return existingName.startsWith('google') + ? EndpointMode.stage + : EndpointMode.live; } Endpoint webuntis() => EndpointOptions( - live: Endpoint( - domain: 'marianum-fulda.webuntis.com', - ), - staged: Endpoint( - domain: 'mhsl.eu', - path: '/marianum/marianummobile/webuntis/public/index.php/api' - ), - ).get(getEndpointMode()); + live: Endpoint(domain: 'marianum-fulda.webuntis.com'), + staged: Endpoint( + domain: 'mhsl.eu', + path: '/marianum/marianummobile/webuntis/public/index.php/api', + ), + ).get(getEndpointMode()); Endpoint nextcloud() => EndpointOptions( - live: Endpoint( - domain: 'cloud.marianum-fulda.de', - ), - staged: Endpoint( - domain: 'mhsl.eu', - path: '/marianum/marianummobile/cloud', - ) - ).get(getEndpointMode()); - + live: Endpoint(domain: 'cloud.marianum-fulda.de'), + staged: Endpoint(domain: 'mhsl.eu', path: '/marianum/marianummobile/cloud'), + ).get(getEndpointMode()); } diff --git a/lib/notification/notification_controller.dart b/lib/notification/notification_controller.dart index 162ff28..88cf348 100644 --- a/lib/notification/notification_controller.dart +++ b/lib/notification/notification_controller.dart @@ -13,20 +13,29 @@ class NotificationController { NotificationTasks.updateBadgeCount(message); } - static Future onForegroundMessageHandler(RemoteMessage message, BuildContext context) async { + static Future onForegroundMessageHandler( + RemoteMessage message, + BuildContext context, + ) async { NotificationTasks.updateProviders(context); NotificationTasks.updateBadgeCount(message); } - static Future onAppOpenedByNotification(RemoteMessage message, BuildContext context) async { - NotificationTasks.navigateToTalk(context, chatToken: _extractChatToken(message)); + static Future onAppOpenedByNotification( + RemoteMessage message, + BuildContext context, + ) async { + NotificationTasks.navigateToTalk( + context, + chatToken: _extractChatToken(message), + ); NotificationTasks.updateProviders(context); DebugTile(context).run(() { InfoDialog.show( context, 'Dieser Bericht wird angezeigt, da du den Entwicklermodus aktiviert hast und die App über eine Benachrichtigung geöffnet wurde.\n\n' - '${JsonViewer.format(message.data)}', + '${JsonViewer.format(message.data)}', copyable: true, title: 'Notification report', ); diff --git a/lib/notification/notification_service.dart b/lib/notification/notification_service.dart index 5c5ae6c..7ee3d86 100644 --- a/lib/notification/notification_service.dart +++ b/lib/notification/notification_service.dart @@ -7,16 +7,15 @@ class NotificationService { NotificationService._internal(); - FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin(); Future initializeNotifications() async { const androidSettings = AndroidInitializationSettings( - '@mipmap/ic_launcher' - ); - - final iosSettings = DarwinInitializationSettings( + '@mipmap/ic_launcher', ); + final iosSettings = DarwinInitializationSettings(); final initializationSettings = InitializationSettings( android: androidSettings, @@ -24,13 +23,16 @@ class NotificationService { ); await flutterLocalNotificationsPlugin.initialize( - settings: initializationSettings + settings: initializationSettings, ); } - Future showNotification({required String title, required String body, required int badgeCount}) async { - const androidPlatformChannelSpecifics = - AndroidNotificationDetails( + Future showNotification({ + required String title, + required String body, + required int badgeCount, + }) async { + const androidPlatformChannelSpecifics = AndroidNotificationDetails( 'marmobile', 'Marianum Fulda', importance: Importance.defaultImportance, @@ -42,14 +44,14 @@ class NotificationService { const platformChannelSpecifics = NotificationDetails( android: androidPlatformChannelSpecifics, - iOS: iosPlatformChannelSpecifics + iOS: iosPlatformChannelSpecifics, ); await flutterLocalNotificationsPlugin.show( id: 0, title: title, body: body, - notificationDetails: platformChannelSpecifics + notificationDetails: platformChannelSpecifics, ); } } diff --git a/lib/notification/notification_tasks.dart b/lib/notification/notification_tasks.dart index 42c310e..9a2f167 100644 --- a/lib/notification/notification_tasks.dart +++ b/lib/notification/notification_tasks.dart @@ -9,7 +9,9 @@ import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; class NotificationTasks { static void updateBadgeCount(RemoteMessage notification) { - FlutterAppBadge.count(int.parse((notification.data['unreadCount'] as String?) ?? '0')); + FlutterAppBadge.count( + int.parse((notification.data['unreadCount'] as String?) ?? '0'), + ); } static void updateProviders(BuildContext context) { diff --git a/lib/notification/notify_updater.dart b/lib/notification/notify_updater.dart index dc3ae15..260c884 100644 --- a/lib/notification/notify_updater.dart +++ b/lib/notification/notify_updater.dart @@ -10,33 +10,42 @@ import '../state/app/modules/settings/bloc/settings_cubit.dart'; import '../widget/confirm_dialog.dart'; class NotifyUpdater { - static ConfirmDialog enableAfterDisclaimer(SettingsCubit settings) => ConfirmDialog( - title: 'Warnung', - icon: Icons.warning_amber, - content: '' - 'Die Push-Benachrichtigungen werden durch mhsl.eu versendet.\n\n' - 'Durch das aktivieren dieser Funktion wird dein Nutzername, dein Password und eine Geräte-ID von mhsl dauerhaft gespeichert und verarbeitet.\n\n' - 'Für mehr Informationen drücke lange auf die Einstellungsoption!', - confirmButton: 'Aktivieren', - onConfirm: () { - unawaited(FirebaseMessaging.instance.requestPermission(provisional: false)); - settings.val(write: true).notificationSettings.enabled = true; - unawaited(NotifyUpdater.registerToServer()); - }, + static ConfirmDialog enableAfterDisclaimer( + SettingsCubit settings, + ) => ConfirmDialog( + title: 'Warnung', + icon: Icons.warning_amber, + content: + '' + 'Die Push-Benachrichtigungen werden durch mhsl.eu versendet.\n\n' + 'Durch das aktivieren dieser Funktion wird dein Nutzername, dein Password und eine Geräte-ID von mhsl dauerhaft gespeichert und verarbeitet.\n\n' + 'Für mehr Informationen drücke lange auf die Einstellungsoption!', + confirmButton: 'Aktivieren', + onConfirm: () { + unawaited( + FirebaseMessaging.instance.requestPermission(provisional: false), ); + settings.val(write: true).notificationSettings.enabled = true; + unawaited(NotifyUpdater.registerToServer()); + }, + ); static Future registerToServer() async { final fcmToken = await FirebaseMessaging.instance.getToken(); if (fcmToken == null) { - throw Exception('Failed to register push notification because there is no FBC token!'); + throw Exception( + 'Failed to register push notification because there is no FBC token!', + ); } - unawaited(NotifyRegister( - NotifyRegisterParams( - username: AccountData().getUsername(), - password: AccountData().getPassword(), - fcmToken: fcmToken, - ), - ).run()); + unawaited( + NotifyRegister( + NotifyRegisterParams( + username: AccountData().getUsername(), + password: AccountData().getPassword(), + fcmToken: fcmToken, + ), + ).run(), + ); } } diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index dd7bb97..6f79929 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -38,7 +38,11 @@ class AppRoutes { pushScreen(context, withNavBar: false, screen: Files(path: path)); } - static void openFileViewer(BuildContext context, String localPath, {bool openExternal = false}) { + static void openFileViewer( + BuildContext context, + String localPath, { + bool openExternal = false, + }) { pushScreen( context, withNavBar: false, @@ -50,7 +54,11 @@ class AppRoutes { pushScreen(context, withNavBar: false, screen: const CustomEventsView()); } - static void openMarianumMessage(BuildContext context, String basePath, MarianumMessage message) { + static void openMarianumMessage( + BuildContext context, + String basePath, + MarianumMessage message, + ) { pushScreen( context, withNavBar: false, @@ -82,7 +90,11 @@ class AppRoutes { pushScreen(context, withNavBar: false, screen: const Roomplan()); } - static void openMessageReactions(BuildContext context, String token, int messageId) { + static void openMessageReactions( + BuildContext context, + String token, + int messageId, + ) { pushScreen( context, withNavBar: false, @@ -113,7 +125,9 @@ class AppRoutes { try { context.read().refresh(); } catch (e) { - if (kDebugMode) debugPrint('openChatByToken: ChatListBloc refresh failed: $e'); + if (kDebugMode) { + debugPrint('openChatByToken: ChatListBloc refresh failed: $e'); + } } } @@ -130,7 +144,10 @@ class AppRoutes { if (room == null) return null; final isGroup = room.type != GetRoomResponseObjectConversationType.oneToOne; - final avatar = UserAvatar(id: isGroup ? room.token : room.name, isGroup: isGroup); + final avatar = UserAvatar( + id: isGroup ? room.token : room.name, + isGroup: isGroup, + ); return ResolvedPendingChat( room: room, selfId: AccountData().getUsername(), @@ -138,7 +155,10 @@ class AppRoutes { ); } - static GetRoomResponseObject? _findRoomByToken(GetRoomResponse? rooms, String token) { + static GetRoomResponseObject? _findRoomByToken( + GetRoomResponse? rooms, + String token, + ) { if (rooms == null) return null; for (final room in rooms.data) { if (room.token == token) return room; @@ -155,10 +175,9 @@ class AppRoutes { /// Switches the bottom navigation to [module]. Returns false when the /// module is not currently in the bottom bar. static bool goToTab(BuildContext context, Modules module) { - final index = AppModule.getBottomBarModules(context) - .map((e) => e.module) - .toList() - .indexOf(module); + final index = AppModule.getBottomBarModules( + context, + ).map((e) => e.module).toList().indexOf(module); if (index == -1) return false; Main.bottomNavigator.jumpToTab(index); return true; diff --git a/lib/state/app/basis/dataloader/holiday_data_loader.dart b/lib/state/app/basis/dataloader/holiday_data_loader.dart index e384f00..b148ebe 100644 --- a/lib/state/app/basis/dataloader/holiday_data_loader.dart +++ b/lib/state/app/basis/dataloader/holiday_data_loader.dart @@ -3,7 +3,6 @@ import 'package:dio/dio.dart'; import '../../infrastructure/data_loader/data_loader.dart'; abstract class HolidayDataLoader extends DataLoader { - HolidayDataLoader() : super(Dio(BaseOptions( - baseUrl: 'https://ferien-api.de/api/v1/', - ))); + HolidayDataLoader() + : super(Dio(BaseOptions(baseUrl: 'https://ferien-api.de/api/v1/'))); } diff --git a/lib/state/app/basis/dataloader/mhsl_data_loader.dart b/lib/state/app/basis/dataloader/mhsl_data_loader.dart index fa29cf4..737dc7b 100644 --- a/lib/state/app/basis/dataloader/mhsl_data_loader.dart +++ b/lib/state/app/basis/dataloader/mhsl_data_loader.dart @@ -3,7 +3,8 @@ import 'package:dio/dio.dart'; import '../../infrastructure/data_loader/data_loader.dart'; abstract class MhslDataLoader extends DataLoader { - MhslDataLoader() : super(Dio(BaseOptions( - baseUrl: 'https://mhsl.eu/marianum/marianummobile/' - ))); + MhslDataLoader() + : super( + Dio(BaseOptions(baseUrl: 'https://mhsl.eu/marianum/marianummobile/')), + ); } diff --git a/lib/state/app/infrastructure/data_loader/data_loader.dart b/lib/state/app/infrastructure/data_loader/data_loader.dart index fabb054..8b1dc93 100644 --- a/lib/state/app/infrastructure/data_loader/data_loader.dart +++ b/lib/state/app/infrastructure/data_loader/data_loader.dart @@ -14,10 +14,14 @@ abstract class DataLoader { Future run() async { final response = await fetch(); try { - return assemble(DataLoaderResult( - json: jsonDecode(response.data!), - headers: response.headers.map.map((key, value) => MapEntry(key, value.join(';'))), - )); + return assemble( + DataLoaderResult( + json: jsonDecode(response.data!), + headers: response.headers.map.map( + (key, value) => MapEntry(key, value.join(';')), + ), + ), + ); } catch (e, stack) { log('DataLoader assemble failed', error: e, stackTrace: stack); rethrow; @@ -34,7 +38,8 @@ class DataLoaderResult { Map asMap() => json as Map; List asList() => json as List; - List> asListOfMaps() => asList().map((e) => e as Map).toList(); + List> asListOfMaps() => + asList().map((e) => e as Map).toList(); DataLoaderResult({required this.json, required this.headers}); } diff --git a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart index daaca67..a17c85f 100644 --- a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart +++ b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart @@ -21,16 +21,19 @@ class LoadableStateBloc extends Bloc LoadableStateBloc() : super(const LoadableStateState(connections: null)) { on((event, emit) { emit(event.state); - if(connectivityStatusKnown() && isConnected()) { - if(reFetch == null) return; + if (connectivityStatusKnown() && isConnected()) { + if (reFetch == null) return; reFetch!(); } }); - void emitConnectivity(List result) => add(ConnectivityChanged(LoadableStateState(connections: result))); + void emitConnectivity(List result) => + add(ConnectivityChanged(LoadableStateState(connections: result))); Connectivity().checkConnectivity().then(emitConnectivity); - _updateStream = Connectivity().onConnectivityChanged.listen(emitConnectivity); + _updateStream = Connectivity().onConnectivityChanged.listen( + emitConnectivity, + ); WidgetsBinding.instance.addObserver(this); } @@ -38,42 +41,51 @@ class LoadableStateBloc extends Bloc void didChangeAppLifecycleState(AppLifecycleState state) { if (state != AppLifecycleState.resumed) return; final now = DateTime.now(); - if (now.difference(_lastResumeRefetch) < const Duration(seconds: 10)) return; + if (now.difference(_lastResumeRefetch) < const Duration(seconds: 10)) { + return; + } _lastResumeRefetch = now; // Re-check connectivity. The resulting [ConnectivityChanged] event takes // it from there: its handler updates the offline/online indicator and // triggers [reFetch] when the device is connected, so a stale // "Verbindung fehlgeschlagen" bar from a suspend-time fetch clears as // soon as the network is reachable again. - unawaited(Connectivity().checkConnectivity().then( - (result) => add(ConnectivityChanged(LoadableStateState(connections: result))), - )); + unawaited( + Connectivity().checkConnectivity().then( + (result) => + add(ConnectivityChanged(LoadableStateState(connections: result))), + ), + ); } bool connectivityStatusKnown() => state.connections != null; - bool isConnected() => !(state.connections?.contains(ConnectivityResult.none) ?? true); + bool isConnected() => + !(state.connections?.contains(ConnectivityResult.none) ?? true); bool allowRetry() => reFetch != null; IconData connectionIcon() => connectivityStatusKnown() ? isConnected() - ? Icons.nearby_error - : Icons.signal_wifi_connected_no_internet_4 + ? Icons.nearby_error + : Icons.signal_wifi_connected_no_internet_4 : Icons.device_unknown; - Color connectionColor(BuildContext context) => connectivityStatusKnown() && !isConnected() + Color connectionColor(BuildContext context) => + connectivityStatusKnown() && !isConnected() ? Colors.grey.shade600 : Theme.of(context).primaryColor; - Color connectionForegroundColor(BuildContext context) => connectivityStatusKnown() && !isConnected() + Color connectionForegroundColor(BuildContext context) => + connectivityStatusKnown() && !isConnected() ? Colors.white - : ThemeData.estimateBrightnessForColor(Theme.of(context).primaryColor) == Brightness.dark - ? Colors.white - : Colors.black; + : ThemeData.estimateBrightnessForColor(Theme.of(context).primaryColor) == + Brightness.dark + ? Colors.white + : Colors.black; String connectionText({int? lastUpdated}) => connectivityStatusKnown() ? isConnected() - ? 'Verbindung fehlgeschlagen' - : 'Offline${lastUpdated == null ? '' : ' - Stand von ${DateTime.fromMillisecondsSinceEpoch(lastUpdated).formatRelative()}'}' + ? 'Verbindung fehlgeschlagen' + : 'Offline${lastUpdated == null ? '' : ' - Stand von ${DateTime.fromMillisecondsSinceEpoch(lastUpdated).formatRelative()}'}' : 'Unbekannte Fehlerursache'; @override diff --git a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart index 80f3791..40955b6 100644 --- a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart +++ b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart @@ -1,6 +1,7 @@ import 'loadable_state_state.dart'; sealed class LoadableStateEvent {} + final class ConnectivityChanged extends LoadableStateEvent { final LoadableStateState state; ConnectivityChanged(this.state); diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_background_loading.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_background_loading.dart index 5147e45..b4f6d49 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_background_loading.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_background_loading.dart @@ -8,14 +8,15 @@ class LoadableStateBackgroundLoading extends StatelessWidget { @override Widget build(BuildContext context) => AnimatedSwitcher( - duration: LoadableStateConsumer.animationDuration, - transitionBuilder: (Widget child, Animation animation) => SlideTransition( - position: Tween( - begin: const Offset(0.0, -1.0), - end: Offset.zero, - ).animate(animation), - child: child, - ), - child: visible ? const LinearProgressIndicator() : const SizedBox.shrink(), - ); + duration: LoadableStateConsumer.animationDuration, + transitionBuilder: (Widget child, Animation animation) => + SlideTransition( + position: Tween( + begin: const Offset(0.0, -1.0), + end: Offset.zero, + ).animate(animation), + child: child, + ), + child: visible ? const LinearProgressIndicator() : const SizedBox.shrink(), + ); } 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 8867571..16d3c7f 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 @@ -12,7 +12,12 @@ import 'loadable_state_error_bar.dart'; import 'loadable_state_error_screen.dart'; import 'loadable_state_primary_loading.dart'; -class LoadableStateConsumer, LoadableState>, TState> extends StatelessWidget { +class LoadableStateConsumer< + TController + extends Bloc, LoadableState>, + TState +> + extends StatelessWidget { final Widget Function(TState state, bool loading) child; final void Function(TState state)? onLoad; final bool wrapWithScrollView; @@ -39,12 +44,16 @@ class LoadableStateConsumer().state; final loadedData = loadableState.data; - if(!loadableState.isLoading && onLoad != null && loadedData is TState) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) => onLoad!(loadedData)); + if (!loadableState.isLoading && onLoad != null && loadedData is TState) { + WidgetsBinding.instance.addPostFrameCallback( + (timeStamp) => onLoad!(loadedData), + ); } final typedData = loadedData is TState ? loadedData : null; - final hasContent = typedData != null && (isReady?.call(typedData) ?? loadableState.showContent()); + final hasContent = + typedData != null && + (isReady?.call(typedData) ?? loadableState.showContent()); final hasError = loadableState.error != null; final isLoading = loadableState.isLoading; @@ -57,23 +66,23 @@ class LoadableStateConsumer RefreshIndicator( onRefresh: () { - if(loadableState.reFetch != null) loadableState.reFetch!(); + if (loadableState.reFetch != null) loadableState.reFetch!(); return Future.value(); }, child: ConditionalWrapper( condition: wrapWithScrollView, wrapper: (child) => SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), - child: child + child: child, ), child: child, - ) + ), ), child: SizedBox( height: MediaQuery.of(context).size.height, child: hasContent - ? child(typedData as TState, isLoading) - : const SizedBox.shrink(), + ? child(typedData as TState, isLoading) + : const SizedBox.shrink(), ), ); @@ -94,7 +103,9 @@ class LoadableStateConsumer(); - final isOfflineWithCache = hasContent && bloc.connectivityStatusKnown() && !bloc.isConnected(); + final isOfflineWithCache = + hasContent && bloc.connectivityStatusKnown() && !bloc.isConnected(); final shouldShow = visible || isOfflineWithCache; return AnimatedSize( duration: animationDuration, child: AnimatedSwitcher( - duration: animationDuration, - transitionBuilder: (Widget child, Animation animation) => SlideTransition( - position: Tween( - begin: const Offset(0.0, -1.0), - end: Offset.zero, - ).animate(animation), - child: child, - ), - child: Visibility( - 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), - ), + duration: animationDuration, + transitionBuilder: (Widget child, Animation animation) => + SlideTransition( + position: Tween( + begin: const Offset(0.0, -1.0), + end: Offset.zero, + ).animate(animation), + child: child, + ), + child: Visibility( + 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), + ), + ); + }, + ), + ), ), ); } @@ -78,14 +87,18 @@ class LoadableStateErrorBarText extends StatefulWidget { const LoadableStateErrorBarText({required this.lastUpdated, super.key}); @override - State createState() => _LoadableStateErrorBarTextState(); + State createState() => + _LoadableStateErrorBarTextState(); } class _LoadableStateErrorBarTextState extends State { late Timer _rebuildTimer; @override void initState() { - _rebuildTimer = Timer.periodic(const Duration(seconds: 10), (timer) => setState(() {})); + _rebuildTimer = Timer.periodic( + const Duration(seconds: 10), + (timer) => setState(() {}), + ); super.initState(); } diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart index dac7d3c..7f58eb5 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart @@ -20,52 +20,66 @@ class LoadableStateErrorScreen extends StatelessWidget { Widget build(BuildContext context) { final bloc = context.watch(); final isOffline = bloc.connectivityStatusKnown() && !bloc.isConnected(); - final headline = isOffline ? bloc.connectionText() : (message ?? bloc.connectionText()); + final headline = isOffline + ? bloc.connectionText() + : (message ?? bloc.connectionText()); return AnimatedOpacity( opacity: visible ? 1.0 : 0.0, duration: LoadableStateConsumer.animationDuration, curve: Curves.easeInOut, - child: !visible ? null : Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(bloc.connectionIcon(), size: 40), - const SizedBox(height: 12), - Text( - headline, - style: const TextStyle(fontSize: 20), - textAlign: TextAlign.center, + child: !visible + ? null + : Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(bloc.connectionIcon(), size: 40), + const SizedBox(height: 12), + Text( + headline, + style: const TextStyle(fontSize: 20), + textAlign: TextAlign.center, + ), + if (!isOffline && + message != null && + message != headline) ...[ + const SizedBox(height: 8), + Text( + message!, + style: TextStyle( + color: Theme.of(context).hintColor, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + ], + if (bloc.allowRetry()) ...[ + const SizedBox(height: 16), + TextButton( + onPressed: () => bloc.reFetch!(), + child: const Text('Erneut versuchen'), + ), + ], + if (technicalDetails != null) ...[ + const SizedBox(height: 4), + TextButton( + onPressed: () => InfoDialog.show( + context, + technicalDetails!, + copyable: true, + title: 'Fehlerdetails', + ), + child: const Text('Details anzeigen'), + ), + ], + ], + ), ), - if (!isOffline && message != null && message != headline) ...[ - const SizedBox(height: 8), - Text( - message!, - style: TextStyle(color: Theme.of(context).hintColor, fontSize: 14), - textAlign: TextAlign.center, - ), - ], - if (bloc.allowRetry()) ...[ - const SizedBox(height: 16), - TextButton( - onPressed: () => bloc.reFetch!(), - child: const Text('Erneut versuchen'), - ), - ], - if (technicalDetails != null) ...[ - const SizedBox(height: 4), - TextButton( - onPressed: () => InfoDialog.show(context, technicalDetails!, copyable: true, title: 'Fehlerdetails'), - child: const Text('Details anzeigen'), - ), - ], - ], - ), - ), - ), + ), ); } } diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart index 6e996a6..44d359b 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart @@ -9,9 +9,9 @@ class LoadableStatePrimaryLoading extends StatelessWidget { @override Widget build(BuildContext context) => AnimatedOpacity( - opacity: visible ? 1.0 : 0.0, - duration: LoadableStateConsumer.animationDuration, - curve: Curves.easeInOut, - child: const Center(child: AppProgressIndicator.large()), - ); + opacity: visible ? 1.0 : 0.0, + duration: LoadableStateConsumer.animationDuration, + curve: Curves.easeInOut, + child: const Center(child: AppProgressIndicator.large()), + ); } diff --git a/lib/state/app/infrastructure/utility_widgets/bloc_module.dart b/lib/state/app/infrastructure/utility_widgets/bloc_module.dart index d745e99..7b032d0 100644 --- a/lib/state/app/infrastructure/utility_widgets/bloc_module.dart +++ b/lib/state/app/infrastructure/utility_widgets/bloc_module.dart @@ -1,15 +1,24 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class BlocModule, TState> extends StatelessWidget { +class BlocModule, TState> + extends StatelessWidget { final TBloc Function(BuildContext context) create; final Widget Function(BuildContext context, TBloc bloc, TState state) child; final bool autoRebuild; final void Function(BuildContext context, TBloc bloc)? onInitialisation; - const BlocModule({required this.create, required this.child, this.autoRebuild = false, this.onInitialisation, super.key}); + const BlocModule({ + required this.create, + required this.child, + this.autoRebuild = false, + this.onInitialisation, + super.key, + }); - Widget rebuildChild(BuildContext context) => child(context, context.watch(), context.watch().state); - Widget staticChild(BuildContext context) => child(context, context.read(), context.read().state); + Widget rebuildChild(BuildContext context) => + child(context, context.watch(), context.watch().state); + Widget staticChild(BuildContext context) => + child(context, context.read(), context.read().state); @override Widget build(BuildContext context) => BlocProvider( @@ -19,9 +28,8 @@ class BlocModule, TState> extends St return bloc; }, child: Builder( - builder: (context) => autoRebuild - ? rebuildChild(context) - : staticChild(context) - ) + builder: (context) => + autoRebuild ? rebuildChild(context) : staticChild(context), + ), ); } 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 ff5aaa3..8f23f7c 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 @@ -13,60 +13,79 @@ abstract class LoadableHydratedBloc< TEvent extends LoadableHydratedBlocEvent, TState, TRepository extends Repository -> extends HydratedBloc< - LoadableHydratedBlocEvent, - LoadableState -> { +> + extends + HydratedBloc, LoadableState> { late TRepository _repository; - LoadableHydratedBloc() : super(const LoadableState( - error: null, - data: null, - isLoading: true, - lastFetch: null, - reFetch: null, - )) { - + LoadableHydratedBloc() + : super( + const LoadableState( + error: null, + data: null, + isLoading: true, + lastFetch: null, + reFetch: null, + ), + ) { on>((event, emit) { - emit(LoadableState( - isLoading: state.isLoading, - data: event.state(innerState ?? fromNothing()), - lastFetch: state.lastFetch, - reFetch: retry, - error: state.error, - )); + emit( + LoadableState( + isLoading: state.isLoading, + data: event.state(innerState ?? fromNothing()), + lastFetch: state.lastFetch, + reFetch: retry, + error: state.error, + ), + ); }); - on>((event, emit) => emit(LoadableState( - isLoading: false, - data: event.state(innerState ?? fromNothing()), - lastFetch: DateTime.now().millisecondsSinceEpoch, - reFetch: retry, - error: null, - ))); + on>( + (event, emit) => emit( + LoadableState( + isLoading: false, + data: event.state(innerState ?? fromNothing()), + lastFetch: DateTime.now().millisecondsSinceEpoch, + reFetch: retry, + error: null, + ), + ), + ); - on>((event, emit) => emit(LoadableState( - isLoading: true, - data: innerState, - lastFetch: state.lastFetch, - reFetch: null, - error: null, - ))); + on>( + (event, emit) => emit( + LoadableState( + isLoading: true, + data: innerState, + lastFetch: state.lastFetch, + reFetch: null, + error: null, + ), + ), + ); - on>((event, emit) => emit(LoadableState( - isLoading: false, - data: innerState, - lastFetch: state.lastFetch, - reFetch: retry, - error: event.error - ))); + on>( + (event, emit) => emit( + LoadableState( + isLoading: false, + data: innerState, + lastFetch: state.lastFetch, + reFetch: retry, + error: event.error, + ), + ), + ); - on>((event, emit) => emit(const LoadableState( - isLoading: false, - data: null, - lastFetch: null, - reFetch: null, - error: null, - ))); + on>( + (event, emit) => emit( + const LoadableState( + isLoading: false, + data: null, + lastFetch: null, + reFetch: null, + error: null, + ), + ), + ); _repository = repository(); fetch(); @@ -92,23 +111,27 @@ abstract class LoadableHydratedBloc< void fetch() { log('Fetching data for ${TState.toString()}'); - gatherData().catchError( - (e) { - log('Error while fetching ${TState.toString()}: ${e.toString()}'); - // The bloc may have been closed before this async error landed (e.g. - // when its scoping widget tree was disposed mid-fetch). Adding to a - // closed bloc throws "Cannot add new events after calling close", - // so swallow that case quietly. - if (isClosed) return; - add(Error(LoadingError( - message: errorToUserMessage(e), - technicalDetails: errorToTechnicalDetails(e), - allowRetry: errorAllowsRetry(e), - ))); - }, - ).then((value) { - log('Fetch for ${TState.toString()} completed!'); - }); + gatherData() + .catchError((e) { + log('Error while fetching ${TState.toString()}: ${e.toString()}'); + // The bloc may have been closed before this async error landed (e.g. + // when its scoping widget tree was disposed mid-fetch). Adding to a + // closed bloc throws "Cannot add new events after calling close", + // so swallow that case quietly. + if (isClosed) return; + add( + Error( + LoadingError( + message: errorToUserMessage(e), + technicalDetails: errorToTechnicalDetails(e), + allowRetry: errorAllowsRetry(e), + ), + ), + ); + }) + .then((value) { + log('Fetch for ${TState.toString()} completed!'); + }); } @override @@ -129,13 +152,13 @@ abstract class LoadableHydratedBloc< try { final stateData = state.data; data = stateData is TState ? toStorage(stateData) : null; - } catch(e) { + } catch (e) { log('Failed to save state ${TState.toString()}: ${e.toString()}'); } return LoadableSaveContext.wrap( data, - state.lastFetch ?? DateTime.now().millisecondsSinceEpoch + state.lastFetch ?? DateTime.now().millisecondsSinceEpoch, ); } diff --git a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart index c0b9934..8f3150e 100644 --- a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart @@ -1,17 +1,22 @@ import '../../loadable_state/loading_error.dart'; class LoadableHydratedBlocEvent {} + class Emit extends LoadableHydratedBlocEvent { final TState Function(TState state) state; Emit(this.state); } + class DataGathered extends LoadableHydratedBlocEvent { final TState Function(TState state) state; DataGathered(this.state); } + class Error extends LoadableHydratedBlocEvent { final LoadingError error; Error(this.error); } + class RefetchStarted extends LoadableHydratedBlocEvent {} + class Reset extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.dart index 8d7dc2d..240582c 100644 --- a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.dart @@ -6,18 +6,25 @@ part 'loadable_save_context.g.dart'; @freezed abstract class LoadableSaveContext with _$LoadableSaveContext { const LoadableSaveContext._(); - const factory LoadableSaveContext({ - required int timestamp, - }) = _LoadableSaveContext; + const factory LoadableSaveContext({required int timestamp}) = + _LoadableSaveContext; - factory LoadableSaveContext.fromJson(Map json) => _$LoadableSaveContextFromJson(json); + factory LoadableSaveContext.fromJson(Map json) => + _$LoadableSaveContextFromJson(json); static String dataKey = 'data'; static String metaKey = 'meta'; static Map wrap(Map? data, int lastFetch) => - {dataKey: data, metaKey: LoadableSaveContext(timestamp: lastFetch).toJson()}; + { + dataKey: data, + metaKey: LoadableSaveContext(timestamp: lastFetch).toJson(), + }; - static ({Map data, LoadableSaveContext meta}) unwrap(Map data) => - (data: data[dataKey] as Map, meta: LoadableSaveContext.fromJson(data[metaKey] as Map)); + static ({Map data, LoadableSaveContext meta}) unwrap( + Map data, + ) => ( + data: data[dataKey] as Map, + meta: LoadableSaveContext.fromJson(data[metaKey] as Map), + ); } diff --git a/lib/state/app/modules/account/bloc/account_bloc.dart b/lib/state/app/modules/account/bloc/account_bloc.dart index 4ad0f5d..f1d2b6a 100644 --- a/lib/state/app/modules/account/bloc/account_bloc.dart +++ b/lib/state/app/modules/account/bloc/account_bloc.dart @@ -4,8 +4,11 @@ import 'account_event.dart'; import 'account_state.dart'; class AccountBloc extends Bloc { - AccountBloc({AccountStatus initialStatus = AccountStatus.undefined}) : super(AccountState(status: initialStatus)) { - on((event, emit) => emit(state.copyWith(status: event.status))); + AccountBloc({AccountStatus initialStatus = AccountStatus.undefined}) + : super(AccountState(status: initialStatus)) { + on( + (event, emit) => emit(state.copyWith(status: event.status)), + ); } void setStatus(AccountStatus status) => add(AccountStatusChanged(status)); diff --git a/lib/state/app/modules/account/bloc/account_state.dart b/lib/state/app/modules/account/bloc/account_state.dart index d0ab496..878407d 100644 --- a/lib/state/app/modules/account/bloc/account_state.dart +++ b/lib/state/app/modules/account/bloc/account_state.dart @@ -4,5 +4,6 @@ class AccountState { final AccountStatus status; const AccountState({this.status = AccountStatus.undefined}); - AccountState copyWith({AccountStatus? status}) => AccountState(status: status ?? this.status); + AccountState copyWith({AccountStatus? status}) => + AccountState(status: status ?? this.status); } diff --git a/lib/state/app/modules/app_modules.dart b/lib/state/app/modules/app_modules.dart index 07b64bf..aa05517 100644 --- a/lib/state/app/modules/app_modules.dart +++ b/lib/state/app/modules/app_modules.dart @@ -27,9 +27,18 @@ class AppModule { BreakerArea breakerArea; Widget Function() create; - AppModule(this.module, {required this.name, required this.icon, this.breakerArea = BreakerArea.global, required this.create}); + AppModule( + this.module, { + required this.name, + required this.icon, + this.breakerArea = BreakerArea.global, + required this.create, + }); - static Map modules(BuildContext context, {bool showFiltered = false}) { + static Map modules( + BuildContext context, { + bool showFiltered = false, + }) { final settings = context.read(); var available = { Modules.timetable: AppModule( @@ -45,8 +54,12 @@ class AppModule { icon: () => BlocBuilder>( builder: (context, state) { final rooms = state.data?.rooms; - if (rooms == null || rooms.data.isEmpty) return const Icon(Icons.chat); - final messages = rooms.data.map((e) => e.unreadMessages).reduce((a, b) => a + b); + if (rooms == null || rooms.data.isEmpty) { + return const Icon(Icons.chat); + } + final messages = rooms.data + .map((e) => e.unreadMessages) + .reduce((a, b) => a + b); return badges.Badge( showBadge: messages > 0, position: badges.BadgePosition.topEnd(top: -3, end: -3), @@ -56,7 +69,14 @@ class AppModule { badgeColor: Theme.of(context).primaryColor, elevation: 1, ), - badgeContent: Text('$messages', style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)), + badgeContent: Text( + '$messages', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), child: const Icon(Icons.chat), ); }, @@ -108,9 +128,19 @@ class AppModule { ), }; - if (!showFiltered) available.removeWhere((key, value) => settings.val().modulesSettings.hiddenModules.contains(key)); + if (!showFiltered) { + available.removeWhere( + (key, value) => + settings.val().modulesSettings.hiddenModules.contains(key), + ); + } - return { for (var element in settings.val().modulesSettings.moduleOrder.where((element) => available.containsKey(element))) element : available[element]! }; + return { + for (var element in settings.val().modulesSettings.moduleOrder.where( + (element) => available.containsKey(element), + )) + element: available[element]!, + }; } static const int minBottomBarSlots = 3; @@ -150,26 +180,45 @@ class AppModule { return all.skip(slots).toList(); } - Widget toListTile(BuildContext context, {Key? key, bool isReorder = false, Function()? onVisibleChange, bool isVisible = true}) => ListTile( + Widget toListTile( + BuildContext context, { + Key? key, + bool isReorder = false, + Function()? onVisibleChange, + bool isVisible = true, + }) => ListTile( key: key, leading: CenteredLeading(icon()), title: Text(name), onTap: isReorder ? null : () => AppRoutes.openModule(context, this), trailing: isReorder - ? Row(mainAxisSize: MainAxisSize.min, children: [ - IconButton(onPressed: onVisibleChange, icon: Icon(isVisible ? Icons.visibility_outlined : Icons.visibility_off_outlined)), - Icon(Icons.drag_handle_outlined) - ]) + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: onVisibleChange, + icon: Icon( + isVisible + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), + Icon(Icons.drag_handle_outlined), + ], + ) : const Icon(Icons.arrow_right), ); - PersistentTabConfig toBottomTab(BuildContext context, {Widget Function(IconData icon)? iconBuilder}) => PersistentTabConfig( + PersistentTabConfig toBottomTab( + BuildContext context, { + Widget Function(IconData icon)? iconBuilder, + }) => PersistentTabConfig( screen: Breaker(breaker: breakerArea, child: create()), item: ItemConfig( - activeForegroundColor: Theme.of(context).primaryColor, - inactiveForegroundColor: Theme.of(context).colorScheme.secondary, - icon: icon(), - title: name + activeForegroundColor: Theme.of(context).primaryColor, + inactiveForegroundColor: Theme.of(context).colorScheme.secondary, + icon: icon(), + title: name, ), ); } diff --git a/lib/state/app/modules/breaker/bloc/breaker_bloc.dart b/lib/state/app/modules/breaker/bloc/breaker_bloc.dart index 86bb6fc..24af779 100644 --- a/lib/state/app/modules/breaker/bloc/breaker_bloc.dart +++ b/lib/state/app/modules/breaker/bloc/breaker_bloc.dart @@ -8,7 +8,9 @@ import '../repository/breaker_repository.dart'; import 'breaker_event.dart'; import 'breaker_state.dart'; -class BreakerBloc extends LoadableHydratedBloc { +class BreakerBloc + extends + LoadableHydratedBloc { PackageInfo? _packageInfo; @override @@ -18,7 +20,8 @@ class BreakerBloc extends LoadableHydratedBloc const BreakerState(); @override - BreakerState fromStorage(Map json) => BreakerState.fromJson(json); + BreakerState fromStorage(Map json) => + BreakerState.fromJson(json); @override Map? toStorage(BreakerState state) => state.toJson(); diff --git a/lib/state/app/modules/breaker/bloc/breaker_state.dart b/lib/state/app/modules/breaker/bloc/breaker_state.dart index 60c1685..367688f 100644 --- a/lib/state/app/modules/breaker/bloc/breaker_state.dart +++ b/lib/state/app/modules/breaker/bloc/breaker_state.dart @@ -7,9 +7,8 @@ part 'breaker_state.g.dart'; @freezed abstract class BreakerState with _$BreakerState { - const factory BreakerState({ - GetBreakersResponse? response, - }) = _BreakerState; + const factory BreakerState({GetBreakersResponse? response}) = _BreakerState; - factory BreakerState.fromJson(Map json) => _$BreakerStateFromJson(json); + factory BreakerState.fromJson(Map json) => + _$BreakerStateFromJson(json); } 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 d07fa83..1f8ed6b 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 @@ -6,9 +6,11 @@ import '../../../../../api/mhsl/breaker/get_breakers/get_breakers_response.dart' class BreakerDataProvider { Future getBreakers() { final completer = Completer(); - GetBreakersCache(onUpdate: (data) { - if (!completer.isCompleted) completer.complete(data); - }); + GetBreakersCache( + onUpdate: (data) { + if (!completer.isCompleted) completer.complete(data); + }, + ); return completer.future; } } diff --git a/lib/state/app/modules/breaker/repository/breaker_repository.dart b/lib/state/app/modules/breaker/repository/breaker_repository.dart index 7bc37ac..7a22aed 100644 --- a/lib/state/app/modules/breaker/repository/breaker_repository.dart +++ b/lib/state/app/modules/breaker/repository/breaker_repository.dart @@ -5,7 +5,8 @@ import '../data_provider/breaker_data_provider.dart'; class BreakerRepository extends Repository { final BreakerDataProvider _provider; - BreakerRepository([BreakerDataProvider? provider]) : _provider = provider ?? BreakerDataProvider(); + BreakerRepository([BreakerDataProvider? provider]) + : _provider = provider ?? BreakerDataProvider(); BreakerDataProvider get data => _provider; } diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index f02b80f..a79d169 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -6,7 +6,8 @@ import '../repository/chat_repository.dart'; import 'chat_event.dart'; import 'chat_state.dart'; -class ChatBloc extends LoadableHydratedBloc { +class ChatBloc + extends LoadableHydratedBloc { DateTime _lastTokenSet = DateTime.fromMillisecondsSinceEpoch(0); @override @@ -86,11 +87,15 @@ class ChatBloc extends LoadableHydratedBloc json) => _$ChatStateFromJson(json); + factory ChatState.fromJson(Map json) => + _$ChatStateFromJson(json); } diff --git a/lib/state/app/modules/chat/repository/chat_repository.dart b/lib/state/app/modules/chat/repository/chat_repository.dart index 38c3833..ba3edf8 100644 --- a/lib/state/app/modules/chat/repository/chat_repository.dart +++ b/lib/state/app/modules/chat/repository/chat_repository.dart @@ -5,7 +5,8 @@ import '../data_provider/chat_data_provider.dart'; class ChatRepository extends Repository { final ChatDataProvider _provider; - ChatRepository([ChatDataProvider? provider]) : _provider = provider ?? ChatDataProvider(); + ChatRepository([ChatDataProvider? provider]) + : _provider = provider ?? ChatDataProvider(); ChatDataProvider get data => _provider; } diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart index b1687c5..d05895d 100644 --- a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart @@ -11,7 +11,9 @@ import '../repository/chat_list_repository.dart'; import 'chat_list_event.dart'; import 'chat_list_state.dart'; -class ChatListBloc extends LoadableHydratedBloc { +class ChatListBloc + extends + LoadableHydratedBloc { bool _forceRenew = false; @override @@ -27,7 +29,8 @@ class ChatListBloc extends LoadableHydratedBloc const ChatListState(); @override - ChatListState fromStorage(Map json) => ChatListState.fromJson(json); + ChatListState fromStorage(Map json) => + ChatListState.fromJson(json); @override Map? toStorage(ChatListState state) => state.toJson(); @@ -62,11 +65,15 @@ class ChatListBloc extends LoadableHydratedBloc(0, (a, room) => a + room.unreadMessages); + final unread = rooms.data.fold( + 0, + (a, room) => a + room.unreadMessages, + ); FlutterAppBadge.count(unread); } on Object catch (e) { log('Failed to update app badge: $e'); diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_state.dart b/lib/state/app/modules/chat_list/bloc/chat_list_state.dart index 25210cf..adcfc62 100644 --- a/lib/state/app/modules/chat_list/bloc/chat_list_state.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_state.dart @@ -7,9 +7,8 @@ part 'chat_list_state.g.dart'; @freezed abstract class ChatListState with _$ChatListState { - const factory ChatListState({ - GetRoomResponse? rooms, - }) = _ChatListState; + const factory ChatListState({GetRoomResponse? rooms}) = _ChatListState; - factory ChatListState.fromJson(Map json) => _$ChatListStateFromJson(json); + factory ChatListState.fromJson(Map json) => + _$ChatListStateFromJson(json); } diff --git a/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart b/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart index 6fe28b0..8786baa 100644 --- a/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart +++ b/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart @@ -8,16 +8,12 @@ class ChatListDataProvider { Future getRooms({ void Function(Object)? onError, bool renew = false, - }) => - resolveFromCache( - (onUpdate, onError) => GetRoomCache( - renew: renew, - onUpdate: onUpdate, - onError: onError, - ), - onError: onError, - operationName: 'getRooms', - ); + }) => resolveFromCache( + (onUpdate, onError) => + GetRoomCache(renew: renew, onUpdate: onUpdate, onError: onError), + onError: onError, + operationName: 'getRooms', + ); Future createDirectRoom(String invite) => CreateRoom(CreateRoomParams(roomType: 1, invite: invite)).run(); diff --git a/lib/state/app/modules/chat_list/repository/chat_list_repository.dart b/lib/state/app/modules/chat_list/repository/chat_list_repository.dart index 9589cb3..880d15f 100644 --- a/lib/state/app/modules/chat_list/repository/chat_list_repository.dart +++ b/lib/state/app/modules/chat_list/repository/chat_list_repository.dart @@ -5,7 +5,8 @@ import '../data_provider/chat_list_data_provider.dart'; class ChatListRepository extends Repository { final ChatListDataProvider _provider; - ChatListRepository([ChatListDataProvider? provider]) : _provider = provider ?? ChatListDataProvider(); + ChatListRepository([ChatListDataProvider? provider]) + : _provider = provider ?? ChatListDataProvider(); ChatListDataProvider get data => _provider; } diff --git a/lib/state/app/modules/files/bloc/files_bloc.dart b/lib/state/app/modules/files/bloc/files_bloc.dart index fbe72e3..27e5f5e 100644 --- a/lib/state/app/modules/files/bloc/files_bloc.dart +++ b/lib/state/app/modules/files/bloc/files_bloc.dart @@ -7,7 +7,8 @@ import '../repository/files_repository.dart'; import 'files_event.dart'; import 'files_state.dart'; -class FilesBloc extends LoadableHydratedBloc { +class FilesBloc + extends LoadableHydratedBloc { final List initialPath; FilesBloc({this.initialPath = const []}); @@ -19,7 +20,8 @@ class FilesBloc extends LoadableHydratedBloc FilesState(currentPath: initialPath); @override - FilesState fromStorage(Map json) => FilesState.fromJson(json); + FilesState fromStorage(Map json) => + FilesState.fromJson(json); @override Map? toStorage(FilesState state) => null; @@ -60,7 +62,9 @@ class FilesBloc extends LoadableHydratedBloc file.name.isEmpty || file.name == path.lastOrNull); + cached.files.removeWhere( + (file) => file.name.isEmpty || file.name == path.lastOrNull, + ); add(Emit((s) => s.copyWith(listing: cached))); }, onError: (e) => capturedError = e, @@ -70,15 +74,21 @@ class FilesBloc extends LoadableHydratedBloc file.name.isEmpty || file.name == path.lastOrNull); + listing.files.removeWhere( + (file) => file.name.isEmpty || file.name == path.lastOrNull, + ); add(DataGathered((s) => s.copyWith(listing: listing))); } if (capturedError != null) { - add(Error(LoadingError( - message: errorToUserMessage(capturedError), - technicalDetails: errorToTechnicalDetails(capturedError), - allowRetry: errorAllowsRetry(capturedError), - ))); + add( + Error( + LoadingError( + message: errorToUserMessage(capturedError), + technicalDetails: errorToTechnicalDetails(capturedError), + allowRetry: errorAllowsRetry(capturedError), + ), + ), + ); } } } diff --git a/lib/state/app/modules/files/bloc/files_state.dart b/lib/state/app/modules/files/bloc/files_state.dart index 448241f..de1920a 100644 --- a/lib/state/app/modules/files/bloc/files_state.dart +++ b/lib/state/app/modules/files/bloc/files_state.dart @@ -12,5 +12,6 @@ abstract class FilesState with _$FilesState { ListFilesResponse? listing, }) = _FilesState; - factory FilesState.fromJson(Map json) => _$FilesStateFromJson(json); + factory FilesState.fromJson(Map json) => + _$FilesStateFromJson(json); } diff --git a/lib/state/app/modules/files/data_provider/files_data_provider.dart b/lib/state/app/modules/files/data_provider/files_data_provider.dart index 39913b2..e721fbb 100644 --- a/lib/state/app/modules/files/data_provider/files_data_provider.dart +++ b/lib/state/app/modules/files/data_provider/files_data_provider.dart @@ -15,17 +15,16 @@ class FilesDataProvider { String path, { void Function(ListFilesResponse)? onCacheData, void Function(Object)? onError, - }) => - resolveFromCache( - (onUpdate, onError) => ListFilesCache( - path: path, - onUpdate: onUpdate, - onCacheData: onCacheData, - onError: onError, - ), - onError: onError, - operationName: 'listFiles', - ); + }) => resolveFromCache( + (onUpdate, onError) => ListFilesCache( + path: path, + onUpdate: onUpdate, + onCacheData: onCacheData, + onError: onError, + ), + onError: onError, + operationName: 'listFiles', + ); Future createFolder(String fullPath) async { final webdav = await WebdavApi.webdav; diff --git a/lib/state/app/modules/files/repository/files_repository.dart b/lib/state/app/modules/files/repository/files_repository.dart index c7e129c..35f316c 100644 --- a/lib/state/app/modules/files/repository/files_repository.dart +++ b/lib/state/app/modules/files/repository/files_repository.dart @@ -5,7 +5,8 @@ import '../data_provider/files_data_provider.dart'; class FilesRepository extends Repository { final FilesDataProvider _provider; - FilesRepository([FilesDataProvider? provider]) : _provider = provider ?? FilesDataProvider(); + FilesRepository([FilesDataProvider? provider]) + : _provider = provider ?? FilesDataProvider(); FilesDataProvider get data => _provider; } diff --git a/lib/state/app/modules/grade_averages/bloc/grade_averages_bloc.dart b/lib/state/app/modules/grade_averages/bloc/grade_averages_bloc.dart index 6a084ca..2d3b89b 100644 --- a/lib/state/app/modules/grade_averages/bloc/grade_averages_bloc.dart +++ b/lib/state/app/modules/grade_averages/bloc/grade_averages_bloc.dart @@ -3,17 +3,23 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'grade_averages_event.dart'; import 'grade_averages_state.dart'; -class GradeAveragesBloc extends HydratedBloc { - GradeAveragesBloc() : super(const GradeAveragesState(gradingSystem: GradeAveragesGradingSystem.middleSchool, grades: [])) { - +class GradeAveragesBloc + extends HydratedBloc { + GradeAveragesBloc() + : super( + const GradeAveragesState( + gradingSystem: GradeAveragesGradingSystem.middleSchool, + grades: [], + ), + ) { on((event, emit) { add(ResetAll()); emit( state.copyWith( gradingSystem: event.isMiddleSchool - ? GradeAveragesGradingSystem.middleSchool - : GradeAveragesGradingSystem.highSchool - ) + ? GradeAveragesGradingSystem.middleSchool + : GradeAveragesGradingSystem.highSchool, + ), ); }); @@ -22,7 +28,12 @@ class GradeAveragesBloc extends HydratedBloc((event, emit) { - emit(state.copyWith(grades: [...state.grades]..removeWhere((grade) => grade == event.grade))); + emit( + state.copyWith( + grades: [...state.grades] + ..removeWhere((grade) => grade == event.grade), + ), + ); }); on((event, emit) { @@ -30,20 +41,26 @@ class GradeAveragesBloc extends HydratedBloc((event, emit) { - emit(state.copyWith(grades: List.from(state.grades)..remove(event.grade))); + emit( + state.copyWith(grades: List.from(state.grades)..remove(event.grade)), + ); }); - } - double average() => state.grades.isEmpty ? 0 : state.grades.reduce((a, b) => a + b) / state.grades.length; - bool isMiddleSchool() => state.gradingSystem == GradeAveragesGradingSystem.middleSchool; + double average() => state.grades.isEmpty + ? 0 + : state.grades.reduce((a, b) => a + b) / state.grades.length; + bool isMiddleSchool() => + state.gradingSystem == GradeAveragesGradingSystem.middleSchool; bool canDecrementOrDelete(int grade) => state.grades.contains(grade); int countOfGrade(int grade) => state.grades.where((g) => g == grade).length; - int gradesInGradingSystem() => state.gradingSystem == GradeAveragesGradingSystem.middleSchool ? 6 : 16; + int gradesInGradingSystem() => + state.gradingSystem == GradeAveragesGradingSystem.middleSchool ? 6 : 16; int getGradeFromIndex(int index) => isMiddleSchool() ? index + 1 : 15 - index; @override - GradeAveragesState? fromJson(Map json) => GradeAveragesState.fromJson(json); + GradeAveragesState? fromJson(Map json) => + GradeAveragesState.fromJson(json); @override Map? toJson(GradeAveragesState state) => state.toJson(); } diff --git a/lib/state/app/modules/grade_averages/bloc/grade_averages_event.dart b/lib/state/app/modules/grade_averages/bloc/grade_averages_event.dart index 0be46eb..cbf3ba4 100644 --- a/lib/state/app/modules/grade_averages/bloc/grade_averages_event.dart +++ b/lib/state/app/modules/grade_averages/bloc/grade_averages_event.dart @@ -1,19 +1,22 @@ - sealed class GradeAveragesEvent {} final class GradingSystemChanged extends GradeAveragesEvent { final bool isMiddleSchool; GradingSystemChanged(this.isMiddleSchool); } + final class ResetAll extends GradeAveragesEvent {} + final class ResetGrade extends GradeAveragesEvent { final int grade; ResetGrade(this.grade); } + final class IncrementGrade extends GradeAveragesEvent { final int grade; IncrementGrade(this.grade); } + final class DecrementGrade extends GradeAveragesEvent { final int grade; DecrementGrade(this.grade); diff --git a/lib/state/app/modules/grade_averages/bloc/grade_averages_state.dart b/lib/state/app/modules/grade_averages/bloc/grade_averages_state.dart index 44e7f8f..6abb004 100644 --- a/lib/state/app/modules/grade_averages/bloc/grade_averages_state.dart +++ b/lib/state/app/modules/grade_averages/bloc/grade_averages_state.dart @@ -10,10 +10,8 @@ abstract class GradeAveragesState with _$GradeAveragesState { required List grades, }) = _GradeAveragesState; - factory GradeAveragesState.fromJson(Map json) => _$GradeAveragesStateFromJson(json); + factory GradeAveragesState.fromJson(Map json) => + _$GradeAveragesStateFromJson(json); } -enum GradeAveragesGradingSystem { - highSchool, - middleSchool, -} +enum GradeAveragesGradingSystem { highSchool, middleSchool } diff --git a/lib/state/app/modules/holidays/bloc/holidays_bloc.dart b/lib/state/app/modules/holidays/bloc/holidays_bloc.dart index 2e3b96b..bf2de75 100644 --- a/lib/state/app/modules/holidays/bloc/holidays_bloc.dart +++ b/lib/state/app/modules/holidays/bloc/holidays_bloc.dart @@ -4,32 +4,51 @@ import '../repository/holidays_repository.dart'; import 'holidays_event.dart'; import 'holidays_state.dart'; -class HolidaysBloc extends LoadableHydratedBloc { +class HolidaysBloc + extends + LoadableHydratedBloc { HolidaysBloc() { on((event, emit) { - add(Emit((state) => state.copyWith(showPastHolidays: event.shouldBeVisible))); + add( + Emit( + (state) => state.copyWith(showPastHolidays: event.shouldBeVisible), + ), + ); }); - on((event, emit) => add( - Emit((state) => state.copyWith(showDisclaimer: false)) - )); + on( + (event, emit) => + add(Emit((state) => state.copyWith(showDisclaimer: false))), + ); } bool showPastHolidays() => innerState?.showPastHolidays ?? false; bool showDisclaimerOnEntry() => innerState?.showDisclaimer ?? false; - List? getHolidays() => innerState?.holidays - .where((element) => showPastHolidays() || DateTime.parse(element.end).isAfter(DateTime.now())) - .toList() ?? []; + List? getHolidays() => + innerState?.holidays + .where( + (element) => + showPastHolidays() || + DateTime.parse(element.end).isAfter(DateTime.now()), + ) + .toList() ?? + []; @override - HolidaysState fromNothing() => const HolidaysState(showPastHolidays: false, holidays: [], showDisclaimer: true); + HolidaysState fromNothing() => const HolidaysState( + showPastHolidays: false, + holidays: [], + showDisclaimer: true, + ); @override - HolidaysState fromStorage(Map json) => HolidaysState.fromJson(json); + HolidaysState fromStorage(Map json) => + HolidaysState.fromJson(json); @override Future gatherData() async { var holidays = await repo.getHolidays(); add(DataGathered((state) => state.copyWith(holidays: holidays))); } + @override HolidaysRepository repository() => HolidaysRepository(); @override diff --git a/lib/state/app/modules/holidays/bloc/holidays_event.dart b/lib/state/app/modules/holidays/bloc/holidays_event.dart index 4be4a68..99d2574 100644 --- a/lib/state/app/modules/holidays/bloc/holidays_event.dart +++ b/lib/state/app/modules/holidays/bloc/holidays_event.dart @@ -2,8 +2,10 @@ import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_ import 'holidays_state.dart'; sealed class HolidaysEvent extends LoadableHydratedBlocEvent {} + class SetPastHolidaysVisible extends HolidaysEvent { final bool shouldBeVisible; SetPastHolidaysVisible(this.shouldBeVisible); } + class DisclaimerDismissed extends HolidaysEvent {} diff --git a/lib/state/app/modules/holidays/bloc/holidays_state.dart b/lib/state/app/modules/holidays/bloc/holidays_state.dart index eec02b2..d2755e4 100644 --- a/lib/state/app/modules/holidays/bloc/holidays_state.dart +++ b/lib/state/app/modules/holidays/bloc/holidays_state.dart @@ -12,7 +12,8 @@ abstract class HolidaysState with _$HolidaysState { required List holidays, }) = _HolidaysState; - factory HolidaysState.fromJson(Map json) => _$HolidaysStateFromJson(json); + factory HolidaysState.fromJson(Map json) => + _$HolidaysStateFromJson(json); } @freezed @@ -26,5 +27,6 @@ abstract class Holiday with _$Holiday { required String slug, }) = _Holiday; - factory Holiday.fromJson(Map json) => _$HolidayFromJson(json); + factory Holiday.fromJson(Map json) => + _$HolidayFromJson(json); } diff --git a/lib/state/app/modules/holidays/data_provider/holidays_get_holidays.dart b/lib/state/app/modules/holidays/data_provider/holidays_get_holidays.dart index ad663da..79c9c60 100644 --- a/lib/state/app/modules/holidays/data_provider/holidays_get_holidays.dart +++ b/lib/state/app/modules/holidays/data_provider/holidays_get_holidays.dart @@ -6,7 +6,8 @@ import '../bloc/holidays_state.dart'; class HolidaysGetHolidays extends HolidayDataLoader> { @override - List assemble(DataLoaderResult data) => data.asListOfMaps().map(Holiday.fromJson).toList(); + List assemble(DataLoaderResult data) => + data.asListOfMaps().map(Holiday.fromJson).toList(); @override Future> fetch() => dio.get('/holidays/HE'); 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 241370d..a0ebeed 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 @@ -4,28 +4,41 @@ import '../repository/marianum_dates_repository.dart'; import 'marianum_dates_event.dart'; import 'marianum_dates_state.dart'; -class MarianumDatesBloc extends LoadableHydratedBloc { +class MarianumDatesBloc + extends + LoadableHydratedBloc< + MarianumDatesEvent, + MarianumDatesState, + MarianumDatesRepository + > { MarianumDatesBloc() { on((event, emit) { - add(Emit((state) => state.copyWith(showPastEvents: event.shouldBeVisible))); + 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() => + innerState?.events + .where((e) => showPastEvents() || e.end.isAfter(DateTime.now())) + .toList() ?? + []; @override - MarianumDatesState fromNothing() => const MarianumDatesState(showPastEvents: false, events: []); + MarianumDatesState fromNothing() => + const MarianumDatesState(showPastEvents: false, events: []); @override - MarianumDatesState fromStorage(Map json) => MarianumDatesState.fromJson(json); + MarianumDatesState fromStorage(Map json) => + MarianumDatesState.fromJson(json); @override Future gatherData() async { final events = await repo.getEvents(); add(DataGathered((state) => state.copyWith(events: events))); } + @override MarianumDatesRepository repository() => MarianumDatesRepository(); @override diff --git a/lib/state/app/modules/marianum_dates/bloc/marianum_dates_event.dart b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_event.dart index 1bfcb88..b62b9f9 100644 --- a/lib/state/app/modules/marianum_dates/bloc/marianum_dates_event.dart +++ b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_event.dart @@ -1,7 +1,8 @@ import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'marianum_dates_state.dart'; -sealed class MarianumDatesEvent extends LoadableHydratedBlocEvent {} +sealed class MarianumDatesEvent + extends LoadableHydratedBlocEvent {} class SetPastEventsVisible extends MarianumDatesEvent { final bool shouldBeVisible; diff --git a/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.dart b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.dart index 26eb18f..a45611e 100644 --- a/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.dart +++ b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.dart @@ -11,7 +11,8 @@ abstract class MarianumDatesState with _$MarianumDatesState { required List events, }) = _MarianumDatesState; - factory MarianumDatesState.fromJson(Map json) => _$MarianumDatesStateFromJson(json); + factory MarianumDatesState.fromJson(Map json) => + _$MarianumDatesStateFromJson(json); } @freezed @@ -25,5 +26,6 @@ abstract class MarianumDate with _$MarianumDate { required bool isAllDay, }) = _MarianumDate; - factory MarianumDate.fromJson(Map json) => _$MarianumDateFromJson(json); + factory MarianumDate.fromJson(Map json) => + _$MarianumDateFromJson(json); } diff --git a/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart b/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart index fc0c177..63bcbbb 100644 --- a/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart +++ b/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart @@ -4,12 +4,15 @@ import 'package:enough_icalendar/enough_icalendar.dart'; import '../bloc/marianum_dates_state.dart'; class MarianumDatesGetEvents { - static const String url = 'https://public-cal.marianumlan.de/cal_public/ad4c5da8-7466-9c72-89cb-8b8d9a5cf26c'; + static const String url = + 'https://public-cal.marianumlan.de/cal_public/ad4c5da8-7466-9c72-89cb-8b8d9a5cf26c'; - final Dio _dio = Dio(BaseOptions( - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 30), - )); + final Dio _dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + ), + ); Future> run() async { final response = await _dio.get(url); @@ -20,7 +23,11 @@ class MarianumDatesGetEvents { final calendar = root is VCalendar ? root : null; final source = calendar?.children ?? root.children; - final events = source.whereType().map(_toMarianumDate).whereType().toList(); + final events = source + .whereType() + .map(_toMarianumDate) + .whereType() + .toList(); events.sort((a, b) => a.start.compareTo(b.start)); return events; } @@ -41,8 +48,11 @@ class MarianumDatesGetEvents { } static bool _isAllDay(DateTime start, DateTime end) { - final startMidnight = start.hour == 0 && start.minute == 0 && start.second == 0; + final startMidnight = + start.hour == 0 && start.minute == 0 && start.second == 0; final endMidnight = end.hour == 0 && end.minute == 0 && end.second == 0; - return startMidnight && endMidnight && end.difference(start).inHours % 24 == 0; + return startMidnight && + endMidnight && + end.difference(start).inHours % 24 == 0; } } diff --git a/lib/state/app/modules/marianum_message/bloc/marianum_message_bloc.dart b/lib/state/app/modules/marianum_message/bloc/marianum_message_bloc.dart index daca62d..decc69f 100644 --- a/lib/state/app/modules/marianum_message/bloc/marianum_message_bloc.dart +++ b/lib/state/app/modules/marianum_message/bloc/marianum_message_bloc.dart @@ -4,7 +4,13 @@ import '../repository/marianum_message_repository.dart'; import 'marianum_message_event.dart'; import 'marianum_message_state.dart'; -class MarianumMessageBloc extends LoadableHydratedBloc { +class MarianumMessageBloc + extends + LoadableHydratedBloc< + MarianumMessageEvent, + MarianumMessageState, + MarianumMessageRepository + > { @override Future gatherData() async { var messages = await repo.getMessages(); @@ -15,10 +21,13 @@ class MarianumMessageBloc extends LoadableHydratedBloc MarianumMessageRepository(); @override - MarianumMessageState fromNothing() => const MarianumMessageState(messageList: MarianumMessageList(base: '', messages: [])); + MarianumMessageState fromNothing() => const MarianumMessageState( + messageList: MarianumMessageList(base: '', messages: []), + ); @override - MarianumMessageState fromStorage(Map json) => MarianumMessageState.fromJson(json); + MarianumMessageState fromStorage(Map json) => + MarianumMessageState.fromJson(json); @override Map? toStorage(MarianumMessageState state) => state.toJson(); } 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 f71d5c7..6ad6e1c 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 @@ -1,5 +1,7 @@ import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'marianum_message_state.dart'; -sealed class MarianumMessageEvent extends LoadableHydratedBlocEvent {} +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 a119bb6..9421618 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 @@ -3,14 +3,14 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'marianum_message_state.freezed.dart'; part 'marianum_message_state.g.dart'; - @freezed abstract class MarianumMessageState with _$MarianumMessageState { const factory MarianumMessageState({ required MarianumMessageList messageList, }) = _MarianumMessageState; - factory MarianumMessageState.fromJson(Map json) => _$MarianumMessageStateFromJson(json); + factory MarianumMessageState.fromJson(Map json) => + _$MarianumMessageStateFromJson(json); } @freezed @@ -20,7 +20,8 @@ abstract class MarianumMessageList with _$MarianumMessageList { required List messages, }) = _MarianumMessageList; - factory MarianumMessageList.fromJson(Map json) => _$MarianumMessageListFromJson(json); + factory MarianumMessageList.fromJson(Map json) => + _$MarianumMessageListFromJson(json); } @freezed @@ -31,11 +32,8 @@ abstract class MarianumMessage with _$MarianumMessage { required String url, }) = _MarianumMessage; - factory MarianumMessage.fromJson(Map json) => _$MarianumMessageFromJson(json); + factory MarianumMessage.fromJson(Map json) => + _$MarianumMessageFromJson(json); } - -enum GradeAveragesGradingSystem { - highSchool, - middleSchool, -} +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 a74dda4..a8eb24a 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 @@ -8,5 +8,6 @@ class MarianumMessageGetMessages extends MhslDataLoader { @override Future> fetch() async => dio.get('/message/messages.json'); @override - MarianumMessageList assemble(DataLoaderResult data) => MarianumMessageList.fromJson(data.asMap()); + MarianumMessageList assemble(DataLoaderResult data) => + MarianumMessageList.fromJson(data.asMap()); } diff --git a/lib/state/app/modules/marianum_message/repository/marianum_message_repository.dart b/lib/state/app/modules/marianum_message/repository/marianum_message_repository.dart index 9a6d9bc..3d26cb4 100644 --- a/lib/state/app/modules/marianum_message/repository/marianum_message_repository.dart +++ b/lib/state/app/modules/marianum_message/repository/marianum_message_repository.dart @@ -3,5 +3,6 @@ import '../bloc/marianum_message_state.dart'; import '../data_provider/marianum_message_get_messages.dart'; class MarianumMessageRepository extends Repository { - Future getMessages() => MarianumMessageGetMessages().run(); + Future getMessages() => + MarianumMessageGetMessages().run(); } diff --git a/lib/state/app/modules/settings/bloc/settings_cubit.dart b/lib/state/app/modules/settings/bloc/settings_cubit.dart index ad38792..a785e22 100644 --- a/lib/state/app/modules/settings/bloc/settings_cubit.dart +++ b/lib/state/app/modules/settings/bloc/settings_cubit.dart @@ -27,7 +27,11 @@ class SettingsCubit extends HydratedCubit { _emitFreshInstance(); }); } - Debouncer.debounce(_debounceTag, const Duration(milliseconds: 500), _emitFreshInstance); + Debouncer.debounce( + _debounceTag, + const Duration(milliseconds: 500), + _emitFreshInstance, + ); } return state; } @@ -50,7 +54,11 @@ class SettingsCubit extends HydratedCubit { return _appendNewModules(Settings.fromJson(json)); } catch (_) { try { - return _appendNewModules(Settings.fromJson(_mergeSettings(json, DefaultSettings.get().toJson()))); + return _appendNewModules( + Settings.fromJson( + _mergeSettings(json, DefaultSettings.get().toJson()), + ), + ); } catch (_) { return DefaultSettings.get(); } @@ -63,7 +71,9 @@ class SettingsCubit extends HydratedCubit { Settings _appendNewModules(Settings s) { final order = s.modulesSettings.moduleOrder; final hidden = s.modulesSettings.hiddenModules; - final missing = Modules.values.where((m) => !order.contains(m) && !hidden.contains(m)); + final missing = Modules.values.where( + (m) => !order.contains(m) && !hidden.contains(m), + ); if (missing.isEmpty) return s; s.modulesSettings.moduleOrder = [...order, ...missing]; return s; @@ -72,12 +82,19 @@ class SettingsCubit extends HydratedCubit { @override Map? toJson(Settings state) => state.toJson(); - Map _mergeSettings(Map oldMap, Map newMap) { + Map _mergeSettings( + Map oldMap, + Map newMap, + ) { final merged = Map.from(newMap); oldMap.forEach((key, value) { if (merged.containsKey(key)) { - if (value is Map && merged[key] is Map) { - merged[key] = _mergeSettings(value, merged[key] as Map); + if (value is Map && + merged[key] is Map) { + merged[key] = _mergeSettings( + value, + merged[key] as Map, + ); } else { merged[key] = value; } diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart index 1c7dc5b..ea0f985 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -8,7 +8,13 @@ import '../repository/timetable_repository.dart'; import 'timetable_event.dart'; import 'timetable_state.dart'; -class TimetableBloc extends LoadableHydratedBloc { +class TimetableBloc + extends + LoadableHydratedBloc< + TimetableEvent, + TimetableState, + TimetableRepository + > { static const Duration _weekSpan = Duration(days: 7); static final DateFormat _weekKeyFormat = DateFormat('yyyyMMdd'); @@ -37,7 +43,8 @@ class TimetableBloc extends LoadableHydratedBloc json) => TimetableState.fromJson(json); + TimetableState fromStorage(Map json) => + TimetableState.fromJson(json); @override Map? toStorage(TimetableState state) => state.toJson(); @@ -54,7 +61,12 @@ class TimetableBloc extends LoadableHydratedBloc s.copyWith( + add( + Emit( + (s) => s.copyWith( rooms: rooms, subjects: subjects, schoolHolidays: schoolHolidays, dataVersion: s.dataVersion + 1, - ))); + ), + ), + ); } catch (e) { onError?.call(e); } try { final timegrid = await repo.data.getTimegrid(renew: renew); - add(Emit((s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1))); + add( + Emit( + (s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1), + ), + ); } catch (_) { // Timegrid load failure falls back to a hardcoded schedule in the UI layer. } @@ -146,8 +171,16 @@ class TimetableBloc extends LoadableHydratedBloc s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1))); + final events = await repo.data.getCustomEvents( + renew: renew, + onError: onError, + ); + add( + Emit( + (s) => + s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1), + ), + ); } catch (e) { onError?.call(e); } @@ -155,7 +188,11 @@ class TimetableBloc extends LoadableHydratedBloc _refreshCustomEvents() async { final events = await repo.data.getCustomEvents(renew: true); - add(DataGathered((s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1))); + add( + DataGathered( + (s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1), + ), + ); } void _prefetchAdjacentWeeks(DateTime start, DateTime end) { @@ -164,16 +201,21 @@ class TimetableBloc extends LoadableHydratedBloc _writeWeekToCache(start, week)).catchError((_) {}); + repo.data + .getWeek(start, end) + .then((week) => _writeWeekToCache(start, week)) + .catchError((_) {}); } void _writeWeekToCache(DateTime weekStart, GetTimetableResponse week) { final key = _weekKeyFormat.format(weekStart); - add(Emit((s) { - final updated = Map.of(s.weekCache); - updated[key] = week; - return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1); - })); + add( + Emit((s) { + final updated = Map.of(s.weekCache); + updated[key] = week; + return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1); + }), + ); } static DateTime _startOfWeek(DateTime reference) { @@ -182,7 +224,9 @@ class TimetableBloc extends LoadableHydratedBloc{}) Map weekCache, + @Default({}) + Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, @@ -26,10 +27,15 @@ abstract class TimetableState with _$TimetableState { @Default(0) int dataVersion, }) = _TimetableState; - factory TimetableState.fromJson(Map json) => _$TimetableStateFromJson(json); + factory TimetableState.fromJson(Map json) => + _$TimetableStateFromJson(json); Iterable getAllKnownLessons() => weekCache.values.expand((response) => response.result); - bool get hasReferenceData => rooms != null && subjects != null && schoolHolidays != null && customEvents != null; + bool get hasReferenceData => + rooms != null && + subjects != null && + schoolHolidays != null && + customEvents != null; } 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 afd993f..2b029a2 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 @@ -31,90 +31,78 @@ class TimetableDataProvider { DateTime endDate, { void Function(Object)? onError, bool renew = false, - }) => - resolveFromCache( - (onUpdate, onError) => GetTimetableCache( - startdate: int.parse(_dateFormat.format(startDate)), - enddate: int.parse(_dateFormat.format(endDate)), - renew: renew, - onUpdate: onUpdate, - onError: onError, - ), - onError: onError, - operationName: 'getWeek', - ); + }) => resolveFromCache( + (onUpdate, onError) => GetTimetableCache( + startdate: int.parse(_dateFormat.format(startDate)), + enddate: int.parse(_dateFormat.format(endDate)), + renew: renew, + onUpdate: onUpdate, + onError: onError, + ), + onError: onError, + operationName: 'getWeek', + ); Future getRooms({ void Function(Object)? onError, bool renew = false, - }) => - resolveFromCache( - (onUpdate, onError) => GetRoomsCache( - renew: renew, - onUpdate: onUpdate, - onError: onError, - ), - onError: onError, - operationName: 'getRooms', - ); + }) => resolveFromCache( + (onUpdate, onError) => + GetRoomsCache(renew: renew, onUpdate: onUpdate, onError: onError), + onError: onError, + operationName: 'getRooms', + ); Future getSubjects({ void Function(Object)? onError, bool renew = false, - }) => - resolveFromCache( - (onUpdate, onError) => GetSubjectsCache( - renew: renew, - onUpdate: onUpdate, - onError: onError, - ), - onError: onError, - operationName: 'getSubjects', - ); + }) => resolveFromCache( + (onUpdate, onError) => + GetSubjectsCache(renew: renew, onUpdate: onUpdate, onError: onError), + onError: onError, + operationName: 'getSubjects', + ); Future getSchoolHolidays({ void Function(Object)? onError, bool renew = false, - }) => - resolveFromCache( - (onUpdate, onError) => GetHolidaysCache( - renew: renew, - onUpdate: onUpdate, - onError: onError, - ), - onError: onError, - operationName: 'getSchoolHolidays', - ); + }) => resolveFromCache( + (onUpdate, onError) => + GetHolidaysCache(renew: renew, onUpdate: onUpdate, onError: onError), + onError: onError, + operationName: 'getSchoolHolidays', + ); Future getTimegrid({bool renew = false}) => resolveFromCache( - (onUpdate, _) => GetTimegridUnitsCache( - renew: renew, - onUpdate: onUpdate, - ), + (onUpdate, _) => + GetTimegridUnitsCache(renew: renew, onUpdate: onUpdate), operationName: 'getTimegrid', ); Future getCustomEvents({ bool renew = false, void Function(Object)? onError, - }) => - resolveFromCache( - (onUpdate, onError) => GetCustomTimetableEventCache( - GetCustomTimetableEventParams(AccountData().getUserSecret()), - renew: renew, - onUpdate: onUpdate, - onError: onError, - ), - onError: onError, - operationName: 'getCustomEvents', - ); + }) => resolveFromCache( + (onUpdate, onError) => GetCustomTimetableEventCache( + GetCustomTimetableEventParams(AccountData().getUserSecret()), + renew: renew, + onUpdate: onUpdate, + onError: onError, + ), + onError: onError, + operationName: 'getCustomEvents', + ); Future addCustomEvent(CustomTimetableEvent event) => - AddCustomTimetableEvent(AddCustomTimetableEventParams(AccountData().getUserSecret(), event)).run(); + AddCustomTimetableEvent( + AddCustomTimetableEventParams(AccountData().getUserSecret(), event), + ).run(); Future updateCustomEvent(String id, CustomTimetableEvent event) => - UpdateCustomTimetableEvent(UpdateCustomTimetableEventParams(id, event)).run(); + UpdateCustomTimetableEvent( + UpdateCustomTimetableEventParams(id, event), + ).run(); Future removeCustomEvent(String id) => RemoveCustomTimetableEvent(RemoveCustomTimetableEventParams(id)).run(); diff --git a/lib/state/app/modules/timetable/repository/timetable_repository.dart b/lib/state/app/modules/timetable/repository/timetable_repository.dart index 36cb6e3..af88a9e 100644 --- a/lib/state/app/modules/timetable/repository/timetable_repository.dart +++ b/lib/state/app/modules/timetable/repository/timetable_repository.dart @@ -5,7 +5,8 @@ import '../data_provider/timetable_data_provider.dart'; class TimetableRepository extends Repository { final TimetableDataProvider _provider; - TimetableRepository([TimetableDataProvider? provider]) : _provider = provider ?? TimetableDataProvider(); + TimetableRepository([TimetableDataProvider? provider]) + : _provider = provider ?? TimetableDataProvider(); TimetableDataProvider get data => _provider; } diff --git a/lib/storage/dev_tools_settings.dart b/lib/storage/dev_tools_settings.dart index 03e2fac..d89ffe2 100644 --- a/lib/storage/dev_tools_settings.dart +++ b/lib/storage/dev_tools_settings.dart @@ -8,8 +8,13 @@ class DevToolsSettings { bool checkerboardOffscreenLayers; bool checkerboardRasterCacheImages; - DevToolsSettings({required this.showPerformanceOverlay, required this.checkerboardOffscreenLayers, required this.checkerboardRasterCacheImages}); + DevToolsSettings({ + required this.showPerformanceOverlay, + required this.checkerboardOffscreenLayers, + required this.checkerboardRasterCacheImages, + }); - factory DevToolsSettings.fromJson(Map json) => _$DevToolsSettingsFromJson(json); + factory DevToolsSettings.fromJson(Map json) => + _$DevToolsSettingsFromJson(json); Map toJson() => _$DevToolsSettingsToJson(this); } diff --git a/lib/storage/file_settings.dart b/lib/storage/file_settings.dart index 3b76ffa..c7dad77 100644 --- a/lib/storage/file_settings.dart +++ b/lib/storage/file_settings.dart @@ -11,8 +11,13 @@ class FileSettings { bool ascending; SortOption sortBy; - FileSettings({required this.sortFoldersToTop, required this.ascending, required this.sortBy}); + FileSettings({ + required this.sortFoldersToTop, + required this.ascending, + required this.sortBy, + }); - factory FileSettings.fromJson(Map json) => _$FileSettingsFromJson(json); + factory FileSettings.fromJson(Map json) => + _$FileSettingsFromJson(json); Map toJson() => _$FileSettingsToJson(this); } diff --git a/lib/storage/file_view_settings.dart b/lib/storage/file_view_settings.dart index 736377f..365f680 100644 --- a/lib/storage/file_view_settings.dart +++ b/lib/storage/file_view_settings.dart @@ -8,6 +8,7 @@ class FileViewSettings { FileViewSettings({required this.alwaysOpenExternally}); - factory FileViewSettings.fromJson(Map json) => _$FileViewSettingsFromJson(json); + factory FileViewSettings.fromJson(Map json) => + _$FileViewSettingsFromJson(json); Map toJson() => _$FileViewSettingsToJson(this); } diff --git a/lib/storage/holidays_settings.dart b/lib/storage/holidays_settings.dart index d4034e5..6f4709b 100644 --- a/lib/storage/holidays_settings.dart +++ b/lib/storage/holidays_settings.dart @@ -7,8 +7,12 @@ class HolidaysSettings { bool dismissedDisclaimer; bool showPastEvents; - HolidaysSettings({required this.dismissedDisclaimer, required this.showPastEvents}); + HolidaysSettings({ + required this.dismissedDisclaimer, + required this.showPastEvents, + }); - factory HolidaysSettings.fromJson(Map json) => _$HolidaysSettingsFromJson(json); + factory HolidaysSettings.fromJson(Map json) => + _$HolidaysSettingsFromJson(json); Map toJson() => _$HolidaysSettingsToJson(this); } diff --git a/lib/storage/modules_settings.dart b/lib/storage/modules_settings.dart index 117f354..0706b9c 100644 --- a/lib/storage/modules_settings.dart +++ b/lib/storage/modules_settings.dart @@ -18,6 +18,7 @@ class ModulesSettings { this.fixedBottomBarSlots = 3, }); - factory ModulesSettings.fromJson(Map json) => _$ModulesSettingsFromJson(json); + factory ModulesSettings.fromJson(Map json) => + _$ModulesSettingsFromJson(json); Map toJson() => _$ModulesSettingsToJson(this); } diff --git a/lib/storage/notification_settings.dart b/lib/storage/notification_settings.dart index ae08533..664cd2f 100644 --- a/lib/storage/notification_settings.dart +++ b/lib/storage/notification_settings.dart @@ -7,8 +7,12 @@ class NotificationSettings { bool askUsageDismissed; bool enabled; - NotificationSettings({required this.askUsageDismissed, required this.enabled}); + NotificationSettings({ + required this.askUsageDismissed, + required this.enabled, + }); - factory NotificationSettings.fromJson(Map json) => _$NotificationSettingsFromJson(json); + factory NotificationSettings.fromJson(Map json) => + _$NotificationSettingsFromJson(json); Map toJson() => _$NotificationSettingsToJson(this); } diff --git a/lib/storage/settings.dart b/lib/storage/settings.dart index 4e42830..e7fc579 100644 --- a/lib/storage/settings.dart +++ b/lib/storage/settings.dart @@ -14,10 +14,7 @@ part 'settings.g.dart'; @JsonSerializable(explicitToJson: true) class Settings { - @JsonKey( - toJson: _themeToJson, - fromJson: _themeFromJson, - ) + @JsonKey(toJson: _themeToJson, fromJson: _themeFromJson) ThemeMode appTheme; bool devToolsEnabled; @@ -44,8 +41,10 @@ class Settings { }); static String _themeToJson(ThemeMode m) => m.name; - static ThemeMode _themeFromJson(String m) => ThemeMode.values.firstWhere((element) => element.name == m); + static ThemeMode _themeFromJson(String m) => + ThemeMode.values.firstWhere((element) => element.name == m); - factory Settings.fromJson(Map json) => _$SettingsFromJson(json); + factory Settings.fromJson(Map json) => + _$SettingsFromJson(json); Map toJson() => _$SettingsToJson(this); } diff --git a/lib/storage/talk_settings.dart b/lib/storage/talk_settings.dart index 180dc45..77838bc 100644 --- a/lib/storage/talk_settings.dart +++ b/lib/storage/talk_settings.dart @@ -9,8 +9,14 @@ class TalkSettings { Map drafts; Map draftReplies; - TalkSettings({required this.sortFavoritesToTop, required this.sortUnreadToTop, required this.drafts, required this.draftReplies}); + TalkSettings({ + required this.sortFavoritesToTop, + required this.sortUnreadToTop, + required this.drafts, + required this.draftReplies, + }); - factory TalkSettings.fromJson(Map json) => _$TalkSettingsFromJson(json); + factory TalkSettings.fromJson(Map json) => + _$TalkSettingsFromJson(json); Map toJson() => _$TalkSettingsToJson(this); } diff --git a/lib/storage/timetable_settings.dart b/lib/storage/timetable_settings.dart index 75faf6a..feaa007 100644 --- a/lib/storage/timetable_settings.dart +++ b/lib/storage/timetable_settings.dart @@ -14,6 +14,7 @@ class TimetableSettings { required this.timetableNameMode, }); - factory TimetableSettings.fromJson(Map json) => _$TimetableSettingsFromJson(json); + factory TimetableSettings.fromJson(Map json) => + _$TimetableSettingsFromJson(json); Map toJson() => _$TimetableSettingsToJson(this); } diff --git a/lib/theming/app_theme.dart b/lib/theming/app_theme.dart index d39f459..4acb96f 100644 --- a/lib/theming/app_theme.dart +++ b/lib/theming/app_theme.dart @@ -15,18 +15,27 @@ TextStyle inputErrorStyle(BuildContext context) => class AppTheme { static DropdownDisplay getDisplayOptions(ThemeMode theme) { - switch(theme) { + switch (theme) { case ThemeMode.system: - return DropdownDisplay(icon: Icons.auto_fix_high_outlined, displayName: 'Systemvorgabe'); + return DropdownDisplay( + icon: Icons.auto_fix_high_outlined, + displayName: 'Systemvorgabe', + ); case ThemeMode.light: - return DropdownDisplay(icon: Icons.wb_sunny_outlined, displayName: 'Hell'); + return DropdownDisplay( + icon: Icons.wb_sunny_outlined, + displayName: 'Hell', + ); case ThemeMode.dark: - return DropdownDisplay(icon: Icons.dark_mode_outlined, displayName: 'Dunkel'); - + return DropdownDisplay( + icon: Icons.dark_mode_outlined, + displayName: 'Dunkel', + ); } } - static bool isDarkMode(BuildContext context) => Theme.of(context).brightness == Brightness.dark; + static bool isDarkMode(BuildContext context) => + Theme.of(context).brightness == Brightness.dark; } diff --git a/lib/theming/light_app_theme.dart b/lib/theming/light_app_theme.dart index 97e9130..5589bae 100644 --- a/lib/theming/light_app_theme.dart +++ b/lib/theming/light_app_theme.dart @@ -7,7 +7,7 @@ class LightAppTheme { brightness: Brightness.light, colorScheme: ColorScheme.fromSeed(seedColor: marianumRed), floatingActionButtonTheme: const FloatingActionButtonThemeData( - foregroundColor: Colors.white - ) + foregroundColor: Colors.white, + ), ); } diff --git a/lib/utils/cache_invalidation_bus.dart b/lib/utils/cache_invalidation_bus.dart index ee98c0d..9f4aa4e 100644 --- a/lib/utils/cache_invalidation_bus.dart +++ b/lib/utils/cache_invalidation_bus.dart @@ -8,7 +8,8 @@ import 'dart:async'; class CacheInvalidationBus { CacheInvalidationBus._(); - static final StreamController _listFiles = StreamController.broadcast(); + static final StreamController _listFiles = + StreamController.broadcast(); /// Emits the invalidated `pathString` (in `FilesBloc` format: relative, /// no leading or trailing slash; root is '/'). diff --git a/lib/utils/download_manager.dart b/lib/utils/download_manager.dart index ba9c0dd..8e68ab4 100644 --- a/lib/utils/download_manager.dart +++ b/lib/utils/download_manager.dart @@ -49,7 +49,9 @@ class DownloadJob { final String localPath; final FileDownloader _downloader; - final ValueNotifier status = ValueNotifier(const DownloadInProgress(0)); + final ValueNotifier status = ValueNotifier( + const DownloadInProgress(0), + ); bool _disposed = false; bool get isFinished => @@ -86,7 +88,10 @@ class DownloadManager { /// Returns the existing job if a download is in progress for [remotePath], /// otherwise starts a new one. Caller listens on [DownloadJob.status]. - Future start({required String remotePath, required String name}) async { + Future start({ + required String remotePath, + required String name, + }) async { final existing = _jobs[remotePath]; if (existing != null && !existing.isFinished) return existing; if (existing != null) { diff --git a/lib/utils/file_downloader.dart b/lib/utils/file_downloader.dart index d6b8152..34c1088 100644 --- a/lib/utils/file_downloader.dart +++ b/lib/utils/file_downloader.dart @@ -28,20 +28,24 @@ class FileDownloader { required void Function() onDone, required void Function(Object error) onError, }) { - client.download( - url, - savePath, - cancelToken: _cancelToken, - onReceiveProgress: (received, total) { - if (_cancelled || total <= 0) return; - onProgress((received / total) * 100); - }, - ).then((_) { - if (_cancelled) return; - onDone(); - }).catchError((Object error) { - if (_cancelled) return; - onError(error); - }).ignore(); + client + .download( + url, + savePath, + cancelToken: _cancelToken, + onReceiveProgress: (received, total) { + if (_cancelled || total <= 0) return; + onProgress((received / total) * 100); + }, + ) + .then((_) { + if (_cancelled) return; + onDone(); + }) + .catchError((Object error) { + if (_cancelled) return; + onError(error); + }) + .ignore(); } } diff --git a/lib/utils/url_opener.dart b/lib/utils/url_opener.dart index 450ed94..b88f8ab 100644 --- a/lib/utils/url_opener.dart +++ b/lib/utils/url_opener.dart @@ -3,7 +3,7 @@ import 'package:url_launcher/url_launcher_string.dart'; class UrlOpener { static Future onOpen(LinkableElement link) async { - if(await canLaunchUrlString(link.url)) { + if (await canLaunchUrlString(link.url)) { await launchUrlString(link.url); } } diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index 286f7e4..879cf0f 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -38,34 +38,37 @@ class _LoginState extends State { @override Widget build(BuildContext context) => Scaffold( - backgroundColor: _marianumRed, - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Center( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - maxWidth: 420, - ), - child: IntrinsicHeight( - child: Column( - children: [ - const LoginHeader(), - const SizedBox(height: 28), - LoginCard(controller: _controller, onSuccess: _onLoginSuccess), - const SizedBox(height: 18), - const LoginDisclaimer(), - const Spacer(), - const LoginFooter(), - ], + backgroundColor: _marianumRed, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + maxWidth: 420, + ), + child: IntrinsicHeight( + child: Column( + children: [ + const LoginHeader(), + const SizedBox(height: 28), + LoginCard( + controller: _controller, + onSuccess: _onLoginSuccess, ), - ), + const SizedBox(height: 18), + const LoginDisclaimer(), + const Spacer(), + const LoginFooter(), + ], ), ), ), ), ), - ); + ), + ), + ); } diff --git a/lib/view/login/widgets/login_branding.dart b/lib/view/login/widgets/login_branding.dart index 07475ce..04649c5 100644 --- a/lib/view/login/widgets/login_branding.dart +++ b/lib/view/login/widgets/login_branding.dart @@ -5,37 +5,37 @@ class LoginHeader extends StatelessWidget { @override Widget build(BuildContext context) => Column( - children: [ - const SizedBox(height: 40), - Image.asset( - 'assets/logo/icon.png', - height: 110, - fit: BoxFit.contain, - gaplessPlayback: true, - ), - const SizedBox(height: 20), - const Text( - 'Marianum Fulda', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white, - fontSize: 26, - fontWeight: FontWeight.w600, - letterSpacing: 0.3, - ), - ), - const SizedBox(height: 6), - Text( - 'Stundenplan, Talk & Dateien an einem Ort.', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.85), - fontSize: 14, - height: 1.3, - ), - ), - ], - ); + children: [ + const SizedBox(height: 40), + Image.asset( + 'assets/logo/icon.png', + height: 110, + fit: BoxFit.contain, + gaplessPlayback: true, + ), + const SizedBox(height: 20), + const Text( + 'Marianum Fulda', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + const SizedBox(height: 6), + Text( + 'Stundenplan, Talk & Dateien an einem Ort.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.85), + fontSize: 14, + height: 1.3, + ), + ), + ], + ); } class LoginDisclaimer extends StatelessWidget { @@ -43,17 +43,17 @@ class LoginDisclaimer extends StatelessWidget { @override Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - 'Inoffizieller Nextcloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.75), - fontSize: 11, - height: 1.4, - ), - ), - ); + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + 'Inoffizieller Nextcloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.75), + fontSize: 11, + height: 1.4, + ), + ), + ); } class LoginFooter extends StatelessWidget { @@ -61,15 +61,15 @@ class LoginFooter extends StatelessWidget { @override Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8), - child: Text( - 'Marianum Fulda. Die persönliche Schule.', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.7), - fontSize: 12, - fontStyle: FontStyle.italic, - ), - ), - ); + padding: const EdgeInsets.only(top: 16, bottom: 8), + child: Text( + 'Marianum Fulda. Die persönliche Schule.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 12, + fontStyle: FontStyle.italic, + ), + ), + ); } diff --git a/lib/view/login/widgets/login_card.dart b/lib/view/login/widgets/login_card.dart index 61d1129..2ca2990 100644 --- a/lib/view/login/widgets/login_card.dart +++ b/lib/view/login/widgets/login_card.dart @@ -10,7 +10,11 @@ class LoginCard extends StatefulWidget { final LoginController controller; final VoidCallback onSuccess; - const LoginCard({required this.controller, required this.onSuccess, super.key}); + const LoginCard({ + required this.controller, + required this.onSuccess, + super.key, + }); @override State createState() => _LoginCardState(); @@ -59,7 +63,9 @@ class _LoginCardState extends State { labelText: label, prefixIcon: Icon(icon), filled: true, - fillColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), + fillColor: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.4, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, @@ -92,7 +98,9 @@ class _LoginCardState extends State { children: [ Text( 'Anmelden', - style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600), + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: 6), Text( @@ -109,7 +117,11 @@ class _LoginCardState extends State { autocorrect: false, textInputAction: TextInputAction.next, onFieldSubmitted: (_) => _passwordFocus.requestFocus(), - decoration: _decoration(theme, 'Nutzername', Icons.person_outline), + decoration: _decoration( + theme, + 'Nutzername', + Icons.person_outline, + ), ), const SizedBox(height: 12), TextFormField( @@ -136,14 +148,22 @@ class _LoginCardState extends State { child: FilledButton( onPressed: loading ? null : _submit, style: FilledButton.styleFrom( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), ), child: loading ? const SizedBox( height: 22, width: 22, - child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white), + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.white, + ), ) : const Text('Anmelden'), ), diff --git a/lib/view/login/widgets/login_error_banner.dart b/lib/view/login/widgets/login_error_banner.dart index df6395f..87d2ff4 100644 --- a/lib/view/login/widgets/login_error_banner.dart +++ b/lib/view/login/widgets/login_error_banner.dart @@ -9,7 +9,11 @@ class LoginErrorBanner extends StatelessWidget { final String? message; final String? details; - const LoginErrorBanner({required this.message, required this.details, super.key}); + const LoginErrorBanner({ + required this.message, + required this.details, + super.key, + }); @override Widget build(BuildContext context) { @@ -26,14 +30,26 @@ class LoginErrorBanner extends StatelessWidget { borderRadius: BorderRadius.circular(12), child: InkWell( onTap: details != null - ? () => InfoDialog.show(context, details!, copyable: true, title: 'Fehlerdetails') + ? () => InfoDialog.show( + context, + details!, + copyable: true, + title: 'Fehlerdetails', + ) : null, borderRadius: BorderRadius.circular(12), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), child: Row( children: [ - Icon(Icons.error_outline, size: 20, color: theme.colorScheme.onErrorContainer), + Icon( + Icons.error_outline, + size: 20, + color: theme.colorScheme.onErrorContainer, + ), const SizedBox(width: 10), Expanded( child: Text( @@ -50,7 +66,8 @@ class LoginErrorBanner extends StatelessWidget { Icon( Icons.chevron_right, size: 20, - color: theme.colorScheme.onErrorContainer.withValues(alpha: 0.7), + color: theme.colorScheme.onErrorContainer + .withValues(alpha: 0.7), ), ], ], diff --git a/lib/view/pages/files/data/sort_options.dart b/lib/view/pages/files/data/sort_options.dart index 941e7d1..ab78fdf 100644 --- a/lib/view/pages/files/data/sort_options.dart +++ b/lib/view/pages/files/data/sort_options.dart @@ -9,7 +9,11 @@ class BetterSortOption { final int Function(CacheableFile, CacheableFile) compare; final IconData icon; - BetterSortOption({required this.displayName, required this.icon, required this.compare}); + BetterSortOption({ + required this.displayName, + required this.icon, + required this.compare, + }); } class SortOptions { diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index 73bed9d..b860b7e 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -25,7 +25,8 @@ class Files extends StatelessWidget { Files({List? path, super.key}) : path = path ?? []; @override - Widget build(BuildContext context) => BlocModule>( + Widget build(BuildContext context) => + BlocModule>( create: (_) => FilesBloc(initialPath: path), child: (context, _, _) => _FilesView(path: path), ); @@ -51,7 +52,8 @@ class _FilesViewState extends State<_FilesView> { // 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('/')}/'; + String get _currentFolderPath => + widget.path.isEmpty ? '' : '${widget.path.join('/')}/'; @override void initState() { @@ -59,7 +61,9 @@ class _FilesViewState extends State<_FilesView> { settings = context.read(); currentSort = settings.val().fileSettings.sortBy; currentSortDirection = settings.val().fileSettings.ascending; - _invalidationSub = CacheInvalidationBus.listFilesStream.listen(_onInvalidation); + _invalidationSub = CacheInvalidationBus.listFilesStream.listen( + _onInvalidation, + ); } void _onInvalidation(String invalidatedPath) { @@ -77,15 +81,17 @@ class _FilesViewState extends State<_FilesView> { Future _mediaUpload(List? paths) async { if (paths == null) return; final bloc = context.read(); - unawaited(pushScreen( - context, - withNavBar: false, - screen: FilesUploadDialog( - filePaths: paths, - remotePath: widget.path.join('/'), - onUploadFinished: (_) => bloc.refresh(), + unawaited( + pushScreen( + context, + withNavBar: false, + screen: FilesUploadDialog( + filePaths: paths, + remotePath: widget.path.join('/'), + onUploadFinished: (_) => bloc.refresh(), + ), ), - )); + ); } @override @@ -116,29 +122,41 @@ class _FilesViewState extends State<_FilesView> { floatingActionButton: FloatingActionButton( heroTag: 'uploadFile', backgroundColor: Theme.of(context).primaryColor, - onPressed: () => showAddFileSheet(context, bloc: bloc, onPickedFiles: _mediaUpload), + onPressed: () => + showAddFileSheet(context, bloc: bloc, onPickedFiles: _mediaUpload), child: const Icon(Icons.add), ), body: Column( children: [ - ClipboardBanner(currentFolder: _currentFolderPath, onPasteDone: bloc.refresh), + ClipboardBanner( + currentFolder: _currentFolderPath, + onPasteDone: bloc.refresh, + ), Expanded( child: LoadableStateConsumer( isReady: (state) => state.listing != null, child: (state, _) { final listing = state.listing!; if (listing.files.isEmpty) { - return const PlaceholderView(icon: Icons.folder_off_rounded, text: 'Der Ordner ist leer'); + return const PlaceholderView( + icon: Icons.folder_off_rounded, + text: 'Der Ordner ist leer', + ); } final files = listing.sortBy( sortOption: currentSort, - foldersToTop: context.watch().val().fileSettings.sortFoldersToTop, + foldersToTop: context + .watch() + .val() + .fileSettings + .sortFoldersToTop, reversed: currentSortDirection, ); return ListView.builder( padding: EdgeInsets.zero, itemCount: files.length, - itemBuilder: (context, index) => FileElement(files[index], widget.path, bloc.refresh), + itemBuilder: (context, index) => + FileElement(files[index], widget.path, bloc.refresh), ); }, ), diff --git a/lib/view/pages/files/files_upload_dialog.dart b/lib/view/pages/files/files_upload_dialog.dart index b7d72b1..273557c 100644 --- a/lib/view/pages/files/files_upload_dialog.dart +++ b/lib/view/pages/files/files_upload_dialog.dart @@ -15,7 +15,13 @@ class FilesUploadDialog extends StatefulWidget { final void Function(List uploadedFilePaths) onUploadFinished; final bool uniqueNames; - const FilesUploadDialog({super.key, required this.filePaths, required this.remotePath, required this.onUploadFinished, this.uniqueNames = false}); + const FilesUploadDialog({ + super.key, + required this.filePaths, + required this.remotePath, + required this.onUploadFinished, + this.uniqueNames = false, + }); @override State createState() => _FilesUploadDialogState(); @@ -31,7 +37,6 @@ class UploadableFile { UploadableFile(this.filePath, this.fileName); } - class _FilesUploadDialogState extends State { late List _uploadableFiles; bool _isUploading = false; @@ -63,7 +68,12 @@ class _FilesUploadDialogState extends State { _overallProgressValue = 0.0; _infoText = ''; }); - InfoDialog.show(context, message, title: 'Upload fehlgeschlagen', copyable: true); + InfoDialog.show( + context, + message, + title: 'Upload fehlgeschlagen', + copyable: true, + ); } Future uploadFiles({bool override = false}) async { @@ -80,7 +90,9 @@ class _FilesUploadDialogState extends State { if (!override) { List result; try { - result = (await webdavClient.propfind(PathUri.parse(widget.remotePath))).responses; + result = (await webdavClient.propfind( + PathUri.parse(widget.remotePath), + )).responses; } catch (e) { if (!mounted) return; _showUploadError('Verbindung fehlgeschlagen: $e'); @@ -88,7 +100,11 @@ class _FilesUploadDialogState extends State { } final conflictingFiles = _uploadableFiles.where((file) { final fileName = file.fileName; - return result.any((element) => Uri.decodeComponent((element as WebDavResponse).href!).endsWith('/$fileName')); + return result.any( + (element) => Uri.decodeComponent( + (element as WebDavResponse).href!, + ).endsWith('/$fileName'), + ); }).toList(); if (conflictingFiles.isNotEmpty) { @@ -97,46 +113,46 @@ class _FilesUploadDialogState extends State { context: context, barrierDismissible: false, builder: (context) => 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.', - textAlign: TextAlign.left, - ) : - SingleChildScrollView( - child: Text( - '${conflictingFiles.length} Dateien mit folgenden Namen existieren bereits: \n${conflictingFiles.map((e) => '\n - ${e.fileName}').join('')}', - textAlign: TextAlign.left, - ), + 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.', + textAlign: TextAlign.left, + ) + : SingleChildScrollView( + child: Text( + '${conflictingFiles.length} Dateien mit folgenden Namen existieren bereits: \n${conflictingFiles.map((e) => '\n - ${e.fileName}').join('')}', + textAlign: TextAlign.left, + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context, false); + }, + child: const Text('Bearbeiten', textAlign: TextAlign.center), ), - actions: [ - TextButton( - onPressed: () { - Navigator.pop(context, 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', - ), - ); - - }, - child: const Text('Überschreiben', 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', + ), + ); + }, + child: const Text('Überschreiben', textAlign: TextAlign.center), + ), + ], + ), ); if (replaceFiles != true) { @@ -160,13 +176,15 @@ class _FilesUploadDialogState extends State { if (widget.uniqueNames) { final unique = DateTime.now().microsecondsSinceEpoch.toRadixString(36); - fileName = '${fileName.split('.').first}-$unique.${fileName.split('.').last}'; + fileName = + '${fileName.split('.').first}-$unique.${fileName.split('.').last}'; } var fullRemotePath = '${widget.remotePath}/$fileName'; setState(() { - _infoText = '${_uploadableFiles.indexOf(file) + 1}/${_uploadableFiles.length}'; + _infoText = + '${_uploadableFiles.indexOf(file) + 1}/${_uploadableFiles.length}'; }); final HttpClientResponse uploadTask; @@ -178,7 +196,10 @@ class _FilesUploadDialogState extends State { onProgress: (progress) { setState(() { file._uploadProgress = progress; - _overallProgressValue = ((progress + _uploadableFiles.indexOf(file)) / _uploadableFiles.length).toDouble(); + _overallProgressValue = + ((progress + _uploadableFiles.indexOf(file)) / + _uploadableFiles.length) + .toDouble(); }); }, ); @@ -188,7 +209,7 @@ class _FilesUploadDialogState extends State { return; } - if(uploadTask.statusCode < 200 || uploadTask.statusCode > 299) { + if (uploadTask.statusCode < 200 || uploadTask.statusCode > 299) { setState(() { _isUploading = false; _overallProgressValue = 0.0; @@ -214,119 +235,133 @@ class _FilesUploadDialogState extends State { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Dateien hochladen'), - automaticallyImplyLeading: false, - ), - body: LoaderOverlay( - overlayWholeScreen: true, - child: Column( - children: [ - Expanded( - child: ListView.builder( - itemCount: _uploadableFiles.length, - itemBuilder: (context, index) { - final currentFile = _uploadableFiles[index]; - currentFile.fileNameController.text = currentFile.fileName; - return ListTile( - title: TextField( - readOnly: _isUploading, - controller: currentFile.fileNameController, - decoration: InputDecoration( - border: const UnderlineInputBorder(), - label: Text('Datei ${index+1}'), - errorText: currentFile.isConflicting ? 'existiert bereits' : null, - errorStyle: TextStyle(color: Theme.of(context).colorScheme.error), + appBar: AppBar( + title: const Text('Dateien hochladen'), + automaticallyImplyLeading: false, + ), + body: LoaderOverlay( + overlayWholeScreen: true, + child: Column( + children: [ + Expanded( + child: ListView.builder( + itemCount: _uploadableFiles.length, + itemBuilder: (context, index) { + final currentFile = _uploadableFiles[index]; + currentFile.fileNameController.text = currentFile.fileName; + return ListTile( + title: TextField( + readOnly: _isUploading, + controller: currentFile.fileNameController, + decoration: InputDecoration( + border: const UnderlineInputBorder(), + label: Text('Datei ${index + 1}'), + errorText: currentFile.isConflicting + ? 'existiert bereits' + : null, + errorStyle: TextStyle( + 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; - }); - } - }, ), - subtitle: _isUploading && (currentFile._uploadProgress ?? 0) < 1 ? LinearProgressIndicator( - value: currentFile._uploadProgress, - borderRadius: const BorderRadius.all(Radius.circular(2)), - ) : null, - trailing: Container( - width: 24, - height: 24, + 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; + }); + } + }, + ), + subtitle: + _isUploading && (currentFile._uploadProgress ?? 0) < 1 + ? LinearProgressIndicator( + value: currentFile._uploadProgress, + borderRadius: const BorderRadius.all( + Radius.circular(2), + ), + ) + : null, + trailing: Container( + width: 24, + height: 24, + padding: EdgeInsets.zero, + child: IconButton( + tooltip: 'Datei entfernen', padding: EdgeInsets.zero, - child: IconButton( - tooltip: 'Datei entfernen', - padding: EdgeInsets.zero, - onPressed: () { - if(!_isUploading) { - if(_uploadableFiles.length-1 <= 0) Navigator.of(context).pop(); - setState(() { - _uploadableFiles.removeAt(index); - }); + onPressed: () { + if (!_isUploading) { + if (_uploadableFiles.length - 1 <= 0) { + Navigator.of(context).pop(); } - }, - icon: const Icon(Icons.delete_outlined), - ), - ), - ); - }, - ), - ), - Padding( - padding: const EdgeInsets.only(left: 15, right: 15, bottom: 15, top: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Visibility( - visible: !_isUploading, - child: TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Abbrechen'), + setState(() { + _uploadableFiles.removeAt(index); + }); + } + }, + icon: const Icon(Icons.delete_outlined), ), ), - const Expanded(child: SizedBox.shrink()), - Visibility( - visible: _isUploading, - replacement: TextButton( - onPressed: () => uploadFiles(override: widget.uniqueNames), - child: const Text('Hochladen'), - ), - child: Visibility( - visible: _infoText.length < 5, - replacement: Row( - children: [ - Text(_infoText), - const SizedBox(width: 15), - CircularProgressIndicator(value: _overallProgressValue), - ], - ), - child: Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator(value: _overallProgressValue), - Center(child: Text(_infoText)), - ], - ), - ), - - - ), - ], - ), + ); + }, ), - ], - ), + ), + Padding( + padding: const EdgeInsets.only( + left: 15, + right: 15, + bottom: 15, + top: 5, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Visibility( + visible: !_isUploading, + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Abbrechen'), + ), + ), + const Expanded(child: SizedBox.shrink()), + Visibility( + visible: _isUploading, + replacement: TextButton( + onPressed: () => uploadFiles(override: widget.uniqueNames), + child: const Text('Hochladen'), + ), + child: Visibility( + visible: _infoText.length < 5, + replacement: Row( + children: [ + Text(_infoText), + const SizedBox(width: 15), + CircularProgressIndicator(value: _overallProgressValue), + ], + ), + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator(value: _overallProgressValue), + Center(child: Text(_infoText)), + ], + ), + ), + ), + ], + ), + ), + ], ), - ); + ), + ); } diff --git a/lib/view/pages/files/widgets/clipboard_banner.dart b/lib/view/pages/files/widgets/clipboard_banner.dart index ac73360..8b1a078 100644 --- a/lib/view/pages/files/widgets/clipboard_banner.dart +++ b/lib/view/pages/files/widgets/clipboard_banner.dart @@ -50,7 +50,11 @@ class _ClipboardBannerState extends State { final src = _normalised(f.path); if (dst == src || dst.startsWith(src)) return false; } - final destination = _joinPath(widget.currentFolder, f.name, isDirectory: f.isDirectory); + final destination = _joinPath( + widget.currentFolder, + f.name, + isDirectory: f.isDirectory, + ); if (destination != f.path) atLeastOneActionable = true; } return atLeastOneActionable; @@ -75,14 +79,24 @@ class _ClipboardBannerState extends State { try { final webdav = await WebdavApi.webdav; for (final file in cb.files) { - final destination = _joinPath(widget.currentFolder, file.name, isDirectory: file.isDirectory); + final destination = _joinPath( + widget.currentFolder, + file.name, + isDirectory: file.isDirectory, + ); if (destination == file.path) continue; try { if (operation == FileClipboardOperation.cut) { - await webdav.move(PathUri.parse(file.path), PathUri.parse(destination)); + await webdav.move( + PathUri.parse(file.path), + PathUri.parse(destination), + ); invalidatedSourceFolders.add(_parentCacheKey(file.path)); } else { - await webdav.copy(PathUri.parse(file.path), PathUri.parse(destination)); + await webdav.copy( + PathUri.parse(file.path), + PathUri.parse(destination), + ); } } on Object catch (e) { errors.add('${file.name}: $e'); @@ -111,42 +125,49 @@ class _ClipboardBannerState extends State { @override Widget build(BuildContext context) => ListenableBuilder( - listenable: FileClipboard.instance, - builder: (context, _) { - final cb = FileClipboard.instance; - if (cb.isEmpty) return const SizedBox.shrink(); - final cut = cb.operation == FileClipboardOperation.cut; - final count = cb.files.length; - final label = count == 1 ? '"${cb.files.first.name}"' : '$count Elemente'; - return Material( - color: Theme.of(context).colorScheme.secondaryContainer, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - children: [ - Icon(cut ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 20), - const SizedBox(width: 12), - Expanded( - child: Text( - cut ? '$label verschieben' : '$label kopieren', - overflow: TextOverflow.ellipsis, - ), - ), - TextButton( - onPressed: _busy || !_canPaste ? null : _paste, - child: _busy - ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Hier einfügen'), - ), - IconButton( - tooltip: 'Verwerfen', - icon: const Icon(Icons.close, size: 20), - onPressed: _busy ? null : cb.clear, - ), - ], + listenable: FileClipboard.instance, + builder: (context, _) { + final cb = FileClipboard.instance; + if (cb.isEmpty) return const SizedBox.shrink(); + final cut = cb.operation == FileClipboardOperation.cut; + final count = cb.files.length; + final label = count == 1 ? '"${cb.files.first.name}"' : '$count Elemente'; + return Material( + color: Theme.of(context).colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Icon( + cut ? Icons.drive_file_move_outline : Icons.copy_outlined, + size: 20, ), - ), - ); - }, + const SizedBox(width: 12), + Expanded( + child: Text( + cut ? '$label verschieben' : '$label kopieren', + overflow: TextOverflow.ellipsis, + ), + ), + TextButton( + onPressed: _busy || !_canPaste ? null : _paste, + child: _busy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Hier einfügen'), + ), + IconButton( + tooltip: 'Verwerfen', + icon: const Icon(Icons.close, size: 20), + onPressed: _busy ? null : cb.clear, + ), + ], + ), + ), ); + }, + ); } diff --git a/lib/view/pages/files/widgets/file_details_sheet.dart b/lib/view/pages/files/widgets/file_details_sheet.dart index 2e59564..e2dee4b 100644 --- a/lib/view/pages/files/widgets/file_details_sheet.dart +++ b/lib/view/pages/files/widgets/file_details_sheet.dart @@ -12,49 +12,67 @@ void showFileDetailsSheet(BuildContext context, CacheableFile file) { showDetailsBottomSheet( context, header: ListTile( - leading: Icon(file.isDirectory ? Icons.folder : Icons.description_outlined, size: 32), - title: Text(file.name, style: const TextStyle(fontWeight: FontWeight.bold)), + leading: Icon( + file.isDirectory ? Icons.folder : Icons.description_outlined, + size: 32, + ), + title: Text( + file.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), subtitle: Text(file.isDirectory ? 'Ordner' : (file.mimeType ?? '–')), ), children: (_) => [ _DetailRow(label: 'Pfad', value: file.path, copyable: true), - if (!file.isDirectory) _DetailRow(label: 'Größe', value: filesize(file.size)), + if (!file.isDirectory) + _DetailRow(label: 'Größe', value: filesize(file.size)), if (file.modifiedAt != null) _DetailRow( label: 'Geändert', - value: '${file.modifiedAt!.formatDateTime()} (${file.modifiedAt!.formatRelative()})', + value: + '${file.modifiedAt!.formatDateTime()} (${file.modifiedAt!.formatRelative()})', ), if (file.createdAt != null) _DetailRow(label: 'Erstellt', value: file.createdAt!.formatDateTime()), - if (file.eTag != null) _DetailRow(label: 'ETag', value: file.eTag!, copyable: true), + if (file.eTag != null) + _DetailRow(label: 'ETag', value: file.eTag!, copyable: true), ], ); } class _DetailRow extends StatelessWidget { - const _DetailRow({required this.label, required this.value, this.copyable = false}); + const _DetailRow({ + required this.label, + required this.value, + this.copyable = false, + }); final String label; final String value; final bool copyable; @override Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 90, - child: Text(label, style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 90, + child: Text( + label, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - Expanded(child: SelectableText(value)), - if (copyable) - IconButton( - tooltip: 'Kopieren', - icon: const Icon(Icons.copy, size: 18), - onPressed: () => copyToClipboard(context, value), - ), - ], + ), ), - ); + Expanded(child: SelectableText(value)), + if (copyable) + IconButton( + tooltip: 'Kopieren', + icon: const Icon(Icons.copy, size: 18), + onPressed: () => copyToClipboard(context, value), + ), + ], + ), + ); } diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart index 82a8dc1..0e64864 100644 --- a/lib/view/pages/files/widgets/file_element.dart +++ b/lib/view/pages/files/widgets/file_element.dart @@ -138,11 +138,17 @@ class _FileElementState extends State { void _onTap() { if (widget.file.isDirectory) { - AppRoutes.openFolder(context, widget.path.toList()..add(widget.file.name)); + AppRoutes.openFolder( + context, + widget.path.toList()..add(widget.file.name), + ); return; } if (EndpointData().getEndpointMode() == EndpointMode.stage) { - InfoDialog.show(context, 'Virtuelle Dateien im Staging Prozess können nicht heruntergeladen werden!'); + InfoDialog.show( + context, + 'Virtuelle Dateien im Staging Prozess können nicht heruntergeladen werden!', + ); return; } final status = _job?.status.value; @@ -178,21 +184,34 @@ class _FileElementState extends State { autofocus: true, ), actions: [ - TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Abbrechen')), TextButton( - onPressed: () => Navigator.of(dialogCtx).pop(controller.text.trim()), + onPressed: () => Navigator.of(dialogCtx).pop(), + child: const Text('Abbrechen'), + ), + TextButton( + onPressed: () => + Navigator.of(dialogCtx).pop(controller.text.trim()), child: const Text('Umbenennen'), ), ], ), ); - if (newName == null || newName.isEmpty || newName == widget.file.name) return; + if (newName == null || newName.isEmpty || newName == widget.file.name) { + return; + } final parent = _parentPathOf(widget.file.path); - final destination = _joinPath(parent, newName, isDirectory: widget.file.isDirectory); + final destination = _joinPath( + parent, + newName, + isDirectory: widget.file.isDirectory, + ); await _runWebdavOp(() async { final webdav = await WebdavApi.webdav; - await webdav.move(PathUri.parse(widget.file.path), PathUri.parse(destination)); + await webdav.move( + PathUri.parse(widget.file.path), + PathUri.parse(destination), + ); }, errorTitle: 'Umbenennen fehlgeschlagen'); } finally { controller.dispose(); @@ -205,10 +224,14 @@ class _FileElementState extends State { } else { FileClipboard.instance.cut([widget.file]); } - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('"${widget.file.name}" zum ${copy ? "Kopieren" : "Verschieben"} bereitgelegt'), - duration: const Duration(seconds: 2), - )); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '"${widget.file.name}" zum ${copy ? "Kopieren" : "Verschieben"} bereitgelegt', + ), + duration: const Duration(seconds: 2), + ), + ); } Future _delete() async { @@ -227,7 +250,10 @@ class _FileElementState extends State { ); } - Future _runWebdavOp(Future Function() action, {required String errorTitle}) async { + Future _runWebdavOp( + Future Function() action, { + required String errorTitle, + }) async { try { await action(); widget.refetch(); @@ -287,13 +313,13 @@ class _FileElementState extends State { @override Widget build(BuildContext context) => ListTile( - leading: CenteredLeading( - Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined), - ), - title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis), - subtitle: _subtitle(), - trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null), - onTap: _onTap, - onLongPress: _showActionSheet, - ); + leading: CenteredLeading( + Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined), + ), + title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis), + subtitle: _subtitle(), + trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null), + onTap: _onTap, + onLongPress: _showActionSheet, + ); } diff --git a/lib/view/pages/files/widgets/files_sort_actions.dart b/lib/view/pages/files/widgets/files_sort_actions.dart index 269f725..77a3175 100644 --- a/lib/view/pages/files/widgets/files_sort_actions.dart +++ b/lib/view/pages/files/widgets/files_sort_actions.dart @@ -23,37 +23,48 @@ class FilesSortActions extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ PopupMenuButton( - icon: Icon(ascending ? Icons.text_rotate_up : Icons.text_rotation_down), + icon: Icon( + ascending ? Icons.text_rotate_up : Icons.text_rotation_down, + ), itemBuilder: (context) => [true, false] - .map((e) => PopupMenuItem( - value: e, - enabled: e != ascending, - child: Row( - children: [ - Icon(e ? Icons.text_rotate_up : Icons.text_rotation_down, - color: theme.colorScheme.onSurface), - const SizedBox(width: 15), - Text(e ? 'Aufsteigend' : 'Absteigend'), - ], - ), - )) + .map( + (e) => PopupMenuItem( + value: e, + enabled: e != ascending, + child: Row( + children: [ + Icon( + e ? Icons.text_rotate_up : Icons.text_rotation_down, + color: theme.colorScheme.onSurface, + ), + const SizedBox(width: 15), + Text(e ? 'Aufsteigend' : 'Absteigend'), + ], + ), + ), + ) .toList(), onSelected: onDirectionChanged, ), PopupMenuButton( icon: const Icon(Icons.sort), itemBuilder: (context) => SortOptions.options.keys - .map((key) => PopupMenuItem( - value: key, - enabled: key != currentSort, - child: Row( - children: [ - Icon(SortOptions.getOption(key).icon, color: theme.colorScheme.onSurface), - const SizedBox(width: 15), - Text(SortOptions.getOption(key).displayName), - ], - ), - )) + .map( + (key) => PopupMenuItem( + value: key, + enabled: key != currentSort, + child: Row( + children: [ + Icon( + SortOptions.getOption(key).icon, + color: theme.colorScheme.onSurface, + ), + const SizedBox(width: 15), + Text(SortOptions.getOption(key).displayName), + ], + ), + ), + ) .toList(), onSelected: onSortChanged, ), diff --git a/lib/view/pages/grade_averages/grade_averages_list_view.dart b/lib/view/pages/grade_averages/grade_averages_list_view.dart index 48c0def..d87625e 100644 --- a/lib/view/pages/grade_averages/grade_averages_list_view.dart +++ b/lib/view/pages/grade_averages/grade_averages_list_view.dart @@ -12,7 +12,7 @@ class GradeAveragesListView extends StatelessWidget { var bloc = context.watch(); String getGradeDisplay(int grade) { - if(bloc.isMiddleSchool()) { + if (bloc.isMiddleSchool()) { return 'Note $grade'; } else { return "$grade Punkt${grade > 1 ? "e" : ""}"; @@ -25,7 +25,9 @@ class GradeAveragesListView extends StatelessWidget { var grade = bloc.getGradeFromIndex(index); return Material( child: ListTile( - tileColor: grade.isEven ? Colors.transparent : Colors.transparent.withAlpha(50), + tileColor: grade.isEven + ? Colors.transparent + : Colors.transparent.withAlpha(50), title: Center( child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -39,7 +41,13 @@ class GradeAveragesListView extends StatelessWidget { icon: const Icon(Icons.remove), color: Theme.of(context).colorScheme.onSurface, ), - Text('${bloc.countOfGrade(grade)}', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold)), + Text( + '${bloc.countOfGrade(grade)}', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), IconButton( onPressed: () { bloc.add(IncrementGrade(grade)); diff --git a/lib/view/pages/grade_averages/grade_averages_view.dart b/lib/view/pages/grade_averages/grade_averages_view.dart index 828536a..c7c5558 100644 --- a/lib/view/pages/grade_averages/grade_averages_view.dart +++ b/lib/view/pages/grade_averages/grade_averages_view.dart @@ -12,49 +12,56 @@ class GradeAveragesView extends StatelessWidget { @override Widget build(BuildContext context) => BlocProvider( - create: (context) => GradeAveragesBloc(), - child: BlocBuilder( - builder: (context, state) { - var bloc = context.watch(); + create: (context) => GradeAveragesBloc(), + child: BlocBuilder( + builder: (context, state) { + var bloc = context.watch(); - return Scaffold( + return Scaffold( appBar: AppBar( title: const Text('Notendurschnittsrechner'), actions: [ Visibility( visible: bloc.state.grades.isNotEmpty, child: IconButton( - onPressed: () { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Zurücksetzen?', - content: 'Alle Einträge werden entfernt.', - confirmButton: 'Zurücksetzen', - onConfirm: () { - bloc.add(ResetAll()); - }, - ), - ); - }, - icon: const Icon(Icons.delete_forever)), + onPressed: () { + showDialog( + context: context, + builder: (context) => ConfirmDialog( + title: 'Zurücksetzen?', + content: 'Alle Einträge werden entfernt.', + confirmButton: 'Zurücksetzen', + onConfirm: () { + bloc.add(ResetAll()); + }, + ), + ); + }, + icon: const Icon(Icons.delete_forever), + ), ), PopupMenuButton( initialValue: bloc.isMiddleSchool(), icon: const Icon(Icons.more_horiz), - itemBuilder: (context) => [true, false].map((isMiddleSchool) => PopupMenuItem( - value: isMiddleSchool, - child: Row( - children: [ - Icon( - isMiddleSchool ? Icons.calculate_outlined : Icons.school_outlined, - color: Theme.of(context).colorScheme.onSurface + itemBuilder: (context) => [true, false] + .map( + (isMiddleSchool) => PopupMenuItem( + value: isMiddleSchool, + child: Row( + children: [ + Icon( + isMiddleSchool + ? Icons.calculate_outlined + : Icons.school_outlined, + color: Theme.of(context).colorScheme.onSurface, + ), + const SizedBox(width: 15), + Text(isMiddleSchool ? 'Realschule' : 'Oberstufe'), + ], + ), ), - const SizedBox(width: 15), - Text(isMiddleSchool ? 'Realschule' : 'Oberstufe'), - ], - ), - )).toList(), + ) + .toList(), onSelected: (isMiddleSchool) { if (bloc.state.grades.isNotEmpty) { showDialog( @@ -62,9 +69,10 @@ class GradeAveragesView extends StatelessWidget { builder: (context) => ConfirmDialog( title: 'Notensystem wechseln', content: - 'Beim Wechsel des Notensystems werden alle Einträge zurückgesetzt.', + 'Beim Wechsel des Notensystems werden alle Einträge zurückgesetzt.', confirmButton: 'Fortfahren', - onConfirm: () => bloc.add(GradingSystemChanged(isMiddleSchool)), + onConfirm: () => + bloc.add(GradingSystemChanged(isMiddleSchool)), ), ); } else { @@ -84,23 +92,34 @@ class GradeAveragesView extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('Ø', style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold)), + Text( + 'Ø', + style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold), + ), SizedBox(width: 5), - Text(bloc.average().toStringAsFixed(2), style: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold)) + Text( + bloc.average().toStringAsFixed(2), + style: const TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + ), + ), ], ), const SizedBox(height: 10), const Divider(), const SizedBox(height: 10), - Text(bloc.isMiddleSchool() ? 'Wähle die Anzahl deiner jeweiligen Noten aus' : 'Wähle die Anzahl deiner jeweiligen Punkte aus'), - const SizedBox(height: 10), - const Expanded( - child: GradeAveragesListView() + Text( + bloc.isMiddleSchool() + ? 'Wähle die Anzahl deiner jeweiligen Noten aus' + : 'Wähle die Anzahl deiner jeweiligen Punkte aus', ), + const SizedBox(height: 10), + const Expanded(child: GradeAveragesListView()), ], ), ); - }, - ), - ); + }, + ), + ); } diff --git a/lib/view/pages/holidays/holidays_view.dart b/lib/view/pages/holidays/holidays_view.dart index 21241b7..2018d2d 100644 --- a/lib/view/pages/holidays/holidays_view.dart +++ b/lib/view/pages/holidays/holidays_view.dart @@ -19,17 +19,19 @@ class HolidaysView extends StatelessWidget { const HolidaysView({super.key}); @override - Widget build(BuildContext context) => BlocModule>( + Widget build( + BuildContext context, + ) => BlocModule>( create: (context) => HolidaysBloc(), autoRebuild: true, child: (context, bloc, state) { void showDisclaimer() => InfoDialog.show( - context, - 'Sämtliche Datumsangaben sind ohne Gewähr.\n' - 'Ich übernehme weder Verantwortung für die Richtigkeit der Daten noch hafte ich für wirtschaftliche Schäden die aus der Verwendung dieser Daten entstehen können.\n\n' - 'Die Daten stammen von https://ferien-api.de/', - title: 'Richtigkeit und Bereitstellung der Daten', - ); + context, + 'Sämtliche Datumsangaben sind ohne Gewähr.\n' + 'Ich übernehme weder Verantwortung für die Richtigkeit der Daten noch hafte ich für wirtschaftliche Schäden die aus der Verwendung dieser Daten entstehen können.\n\n' + 'Die Daten stammen von https://ferien-api.de/', + title: 'Richtigkeit und Bereitstellung der Daten', + ); return Scaffold( appBar: AppBar( @@ -42,79 +44,110 @@ class HolidaysView extends StatelessWidget { PopupMenuButton( initialValue: bloc.showPastHolidays(), icon: const Icon(Icons.history), - itemBuilder: (context) => [true, false].map((e) => PopupMenuItem( - value: e, - enabled: e != bloc.showPastHolidays(), - child: Row( - children: [ - Icon(e ? Icons.history_outlined : Icons.history_toggle_off_outlined, color: Theme.of(context).colorScheme.onSurface), - const SizedBox(width: 15), - Text(e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen') - ], + itemBuilder: (context) => [true, false] + .map( + (e) => PopupMenuItem( + value: e, + enabled: e != bloc.showPastHolidays(), + child: Row( + children: [ + Icon( + e + ? Icons.history_outlined + : Icons.history_toggle_off_outlined, + color: Theme.of(context).colorScheme.onSurface, + ), + const SizedBox(width: 15), + Text(e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen'), + ], + ), + ), ) - )).toList(), + .toList(), onSelected: (e) => bloc.add(SetPastHolidaysVisible(e)), ), ], ), body: LoadableStateConsumer( onLoad: (state) { - if(state.showDisclaimer) showDisclaimer(); + if (state.showDisclaimer) showDisclaimer(); bloc.add(DisclaimerDismissed()); }, - child: (state, loading) => ListViewUtil.fromList(bloc.getHolidays(), (holiday) { - var holidayType = holiday.name.split(' ').first.capitalize(); - String formatDate(String date) => Jiffy.parse(date).format(pattern: 'dd.MM.yyyy'); - String getYear(String date, {String format = 'yyyy'}) => Jiffy.parse(date).format(pattern: format); + child: (state, loading) => ListViewUtil.fromList( + bloc.getHolidays(), + (holiday) { + var holidayType = holiday.name.split(' ').first.capitalize(); + String formatDate(String date) => + Jiffy.parse(date).format(pattern: 'dd.MM.yyyy'); + String getYear(String date, {String format = 'yyyy'}) => + Jiffy.parse(date).format(pattern: format); - String getHolidayYear(String startDate, String endDate) => getYear(startDate) == getYear(endDate) + String getHolidayYear(String startDate, String endDate) => + getYear(startDate) == getYear(endDate) ? getYear(startDate) : '${getYear(startDate)}/${getYear(endDate, format: 'yy')}'; - return ListTile( - leading: const CenteredLeading(Icon(Icons.calendar_month)), - title: Text('$holidayType ${getHolidayYear(holiday.start, holiday.end)}'), - subtitle: Text('${formatDate(holiday.start)} - ${formatDate(holiday.end)}'), - onTap: () => showDetailsBottomSheet( - context, - header: Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), - child: Text( - '$holidayType ${holiday.year} in Hessen', - style: Theme.of(context).textTheme.titleLarge, - ), + return ListTile( + leading: const CenteredLeading(Icon(Icons.calendar_month)), + title: Text( + '$holidayType ${getHolidayYear(holiday.start, holiday.end)}', ), - children: (sheetCtx) => [ - ListTile( - leading: const CenteredLeading(Icon(Icons.signpost_outlined)), - title: Text(holiday.name.capitalize()), - subtitle: Text(holiday.slug.capitalize()), - ), - ListTile( - leading: const Icon(Icons.date_range_outlined), - title: Text('vom ${formatDate(holiday.start)}'), - ), - ListTile( - leading: const Icon(Icons.date_range_outlined), - title: Text('bis zum ${formatDate(holiday.end)}'), - ), - if (DateTime.parse(holiday.start).difference(DateTime.now()).isNegative) - ListTile( - leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)), - title: Text(Jiffy.parse(holiday.start).fromNow()), - ) - else - ListTile( - leading: const CenteredLeading(Icon(Icons.timer_outlined)), - title: AnimatedTime(callback: () => DateTime.parse(holiday.start).difference(DateTime.now())), - subtitle: Text(Jiffy.parse(holiday.start).fromNow()), + subtitle: Text( + '${formatDate(holiday.start)} - ${formatDate(holiday.end)}', + ), + onTap: () => showDetailsBottomSheet( + context, + header: Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Text( + '$holidayType ${holiday.year} in Hessen', + style: Theme.of(context).textTheme.titleLarge, ), - DebugTile(sheetCtx).jsonData(holiday.toJson()), - ], - ), - trailing: const Icon(Icons.arrow_right), - ); - }), + ), + children: (sheetCtx) => [ + ListTile( + leading: const CenteredLeading( + Icon(Icons.signpost_outlined), + ), + title: Text(holiday.name.capitalize()), + subtitle: Text(holiday.slug.capitalize()), + ), + ListTile( + leading: const Icon(Icons.date_range_outlined), + title: Text('vom ${formatDate(holiday.start)}'), + ), + ListTile( + leading: const Icon(Icons.date_range_outlined), + title: Text('bis zum ${formatDate(holiday.end)}'), + ), + if (DateTime.parse( + holiday.start, + ).difference(DateTime.now()).isNegative) + ListTile( + leading: const CenteredLeading( + Icon(Icons.content_paste_search_outlined), + ), + title: Text(Jiffy.parse(holiday.start).fromNow()), + ) + else + ListTile( + leading: const CenteredLeading( + Icon(Icons.timer_outlined), + ), + title: AnimatedTime( + callback: () => DateTime.parse( + holiday.start, + ).difference(DateTime.now()), + ), + subtitle: Text(Jiffy.parse(holiday.start).fromNow()), + ), + DebugTile(sheetCtx).jsonData(holiday.toJson()), + ], + ), + trailing: const Icon(Icons.arrow_right), + ); + }, + ), ), ); }, diff --git a/lib/view/pages/marianum_dates/marianum_dates_view.dart b/lib/view/pages/marianum_dates/marianum_dates_view.dart index 3574031..585457a 100644 --- a/lib/view/pages/marianum_dates/marianum_dates_view.dart +++ b/lib/view/pages/marianum_dates/marianum_dates_view.dart @@ -19,7 +19,8 @@ class MarianumDatesView extends StatelessWidget { static List<_MonthGroup> _groupByMonth(List events) { final byMonth = >{}; for (final e in events) { - final key = '${e.start.year.toString().padLeft(4, '0')}-${e.start.month.toString().padLeft(2, '0')}'; + final key = + '${e.start.year.toString().padLeft(4, '0')}-${e.start.month.toString().padLeft(2, '0')}'; byMonth.putIfAbsent(key, () => []).add(e); } final keys = byMonth.keys.toList()..sort(); @@ -31,7 +32,8 @@ class MarianumDatesView extends StatelessWidget { } @override - Widget build(BuildContext context) => BlocModule>( + Widget build(BuildContext context) => + BlocModule>( create: (context) => MarianumDatesBloc(), autoRebuild: true, child: (context, bloc, state) => Scaffold( @@ -42,18 +44,26 @@ class MarianumDatesView extends StatelessWidget { initialValue: bloc.showPastEvents(), icon: const Icon(Icons.history), itemBuilder: (context) => [true, false] - .map((e) => PopupMenuItem( - value: e, - enabled: e != bloc.showPastEvents(), - child: Row( - children: [ - Icon(e ? Icons.history_outlined : Icons.history_toggle_off_outlined, - color: Theme.of(context).colorScheme.onSurface), - const SizedBox(width: 15), - Text(e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen'), - ], - ), - )) + .map( + (e) => PopupMenuItem( + value: e, + enabled: e != bloc.showPastEvents(), + child: Row( + children: [ + Icon( + e + ? Icons.history_outlined + : Icons.history_toggle_off_outlined, + color: Theme.of(context).colorScheme.onSurface, + ), + const SizedBox(width: 15), + Text( + e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen', + ), + ], + ), + ), + ) .toList(), onSelected: (e) => bloc.add(SetPastEventsVisible(e)), ), @@ -61,7 +71,10 @@ class MarianumDatesView extends StatelessWidget { icon: const Icon(Icons.search), onPressed: () { final events = bloc.getEvents() ?? const []; - showSearch(context: context, delegate: SearchMarianumDates(events)); + showSearch( + context: context, + delegate: SearchMarianumDates(events), + ); }, ), ], @@ -89,7 +102,8 @@ class MarianumDatesView extends StatelessWidget { ), SliverList.builder( itemCount: group.events.length, - itemBuilder: (_, i) => MarianumDateRow(event: group.events[i]), + itemBuilder: (_, i) => + MarianumDateRow(event: group.events[i]), ), ], ), diff --git a/lib/view/pages/marianum_dates/search_marianum_dates.dart b/lib/view/pages/marianum_dates/search_marianum_dates.dart index 8a76c98..293bbf9 100644 --- a/lib/view/pages/marianum_dates/search_marianum_dates.dart +++ b/lib/view/pages/marianum_dates/search_marianum_dates.dart @@ -21,15 +21,15 @@ class SearchMarianumDates extends SearchDelegate { @override List? buildActions(BuildContext context) => [ - if (query.isNotEmpty) - IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)), - ]; + if (query.isNotEmpty) + IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)), + ]; @override Widget? buildLeading(BuildContext context) => IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => close(context, null), - ); + icon: const Icon(Icons.arrow_back), + onPressed: () => close(context, null), + ); @override Widget buildResults(BuildContext context) { diff --git a/lib/view/pages/marianum_dates/widgets/event_details_sheet.dart b/lib/view/pages/marianum_dates/widgets/event_details_sheet.dart index cf14e48..11e0564 100644 --- a/lib/view/pages/marianum_dates/widgets/event_details_sheet.dart +++ b/lib/view/pages/marianum_dates/widgets/event_details_sheet.dart @@ -32,12 +32,16 @@ void showEventDetailsSheet(BuildContext context, MarianumDate event) { if (isUpcoming) ListTile( leading: const CenteredLeading(Icon(Icons.timer_outlined)), - title: AnimatedTime(callback: () => event.start.difference(DateTime.now())), + title: AnimatedTime( + callback: () => event.start.difference(DateTime.now()), + ), subtitle: Text(event.start.formatRelative()), ) else ListTile( - leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)), + leading: const CenteredLeading( + Icon(Icons.content_paste_search_outlined), + ), title: Text(event.start.formatRelative()), ), DebugTile(sheetContext).jsonData(event.toJson()), diff --git a/lib/view/pages/marianum_dates/widgets/event_list_tile.dart b/lib/view/pages/marianum_dates/widgets/event_list_tile.dart index ba4b2f2..8fb09f9 100644 --- a/lib/view/pages/marianum_dates/widgets/event_list_tile.dart +++ b/lib/view/pages/marianum_dates/widgets/event_list_tile.dart @@ -63,9 +63,12 @@ class MarianumDateRow extends StatelessWidget { event.title.isEmpty ? '(ohne Titel)' : event.title, maxLines: 2, overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), ), - if (event.description != null && event.description!.trim().isNotEmpty) ...[ + if (event.description != null && + event.description!.trim().isNotEmpty) ...[ const SizedBox(height: 2), Text( event.description!.trim(), @@ -88,7 +91,9 @@ class MarianumDateRow extends StatelessWidget { ), const SizedBox(width: 4), IconButton( - icon: _CalendarPlusIcon(color: theme.colorScheme.onSurfaceVariant), + icon: _CalendarPlusIcon( + color: theme.colorScheme.onSurfaceVariant, + ), tooltip: 'In Stundenplan übernehmen', onPressed: () => showDialog( context: context, @@ -117,25 +122,25 @@ class _CalendarPlusIcon extends StatelessWidget { @override Widget build(BuildContext context) => SizedBox( - width: 22, - height: 22, - child: Stack( - clipBehavior: Clip.none, - children: [ - Icon(Icons.event_outlined, size: 22, color: color), - Positioned( - right: -2, - bottom: -2, - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - shape: BoxShape.circle, - ), - padding: const EdgeInsets.all(1), - child: Icon(Icons.add_circle, size: 12, color: color), - ), + width: 22, + height: 22, + child: Stack( + clipBehavior: Clip.none, + children: [ + Icon(Icons.event_outlined, size: 22, color: color), + Positioned( + right: -2, + bottom: -2, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + shape: BoxShape.circle, ), - ], + padding: const EdgeInsets.all(1), + child: Icon(Icons.add_circle, size: 12, color: color), + ), ), - ); + ], + ), + ); } diff --git a/lib/view/pages/marianum_dates/widgets/month_section_header.dart b/lib/view/pages/marianum_dates/widgets/month_section_header.dart index 944b44c..e274711 100644 --- a/lib/view/pages/marianum_dates/widgets/month_section_header.dart +++ b/lib/view/pages/marianum_dates/widgets/month_section_header.dart @@ -7,7 +7,11 @@ class MonthHeaderDelegate extends SliverPersistentHeaderDelegate { static const double _height = 38; @override - Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { final theme = Theme.of(context); return Container( height: _height, @@ -32,5 +36,6 @@ class MonthHeaderDelegate extends SliverPersistentHeaderDelegate { double get minExtent => _height; @override - bool shouldRebuild(covariant MonthHeaderDelegate oldDelegate) => oldDelegate.label != label; + bool shouldRebuild(covariant MonthHeaderDelegate oldDelegate) => + oldDelegate.label != label; } diff --git a/lib/view/pages/marianum_message/marianum_message_list_view.dart b/lib/view/pages/marianum_message/marianum_message_list_view.dart index 637e160..f49b2a4 100644 --- a/lib/view/pages/marianum_message/marianum_message_list_view.dart +++ b/lib/view/pages/marianum_message/marianum_message_list_view.dart @@ -11,32 +11,36 @@ class MarianumMessageListView extends StatelessWidget { const MarianumMessageListView({super.key}); @override - Widget build(BuildContext context) => BlocModule>( + Widget build( + BuildContext context, + ) => BlocModule>( create: (context) => MarianumMessageBloc(), child: (context, bloc, state) => Scaffold( - appBar: AppBar( - title: const Text('Marianum Message'), + appBar: AppBar(title: const Text('Marianum Message')), + body: LoadableStateConsumer( + child: (state, loading) => ListView.builder( + itemCount: state.messageList.messages.length, + itemBuilder: (context, index) { + var message = state.messageList.messages.toList()[index]; + return ListTile( + leading: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [Icon(Icons.newspaper)], + ), + title: Text(message.name, overflow: TextOverflow.ellipsis), + subtitle: Text('vom ${message.date}'), + trailing: const Icon(Icons.arrow_right), + onTap: () { + AppRoutes.openMarianumMessage( + context, + state.messageList.base, + message, + ); + }, + ); + }, ), - body: LoadableStateConsumer( - child: (state, loading) => ListView.builder( - itemCount: state.messageList.messages.length, - itemBuilder: (context, index) { - var message = state.messageList.messages.toList()[index]; - return ListTile( - leading: const Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [Icon(Icons.newspaper)], - ), - title: Text(message.name, overflow: TextOverflow.ellipsis), - subtitle: Text('vom ${message.date}'), - trailing: const Icon(Icons.arrow_right), - onTap: () { - AppRoutes.openMarianumMessage(context, state.messageList.base, message); - }, - ); - } - ), - ), - ) + ), + ), ); } diff --git a/lib/view/pages/marianum_message/marianum_message_view.dart b/lib/view/pages/marianum_message/marianum_message_view.dart index 6968e8c..1ce7b40 100644 --- a/lib/view/pages/marianum_message/marianum_message_view.dart +++ b/lib/view/pages/marianum_message/marianum_message_view.dart @@ -16,34 +16,34 @@ class MessageView extends StatefulWidget { } class _MessageViewState extends State { - @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: Text(widget.message.name), - ), - body: SfPdfViewer.network( - widget.basePath + widget.message.url, - enableHyperlinkNavigation: true, - onDocumentLoadFailed: (PdfDocumentLoadFailedDetails e) { - Navigator.of(context).pop(); - InfoDialog.show( - context, - "Dokument '${widget.message.name}' konnte nicht geladen werden:\n${e.description}", - title: 'Fehler beim öffnen', - ); - }, - onHyperlinkClicked: (PdfHyperlinkClickedDetails e) { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Link öffnen', - content: 'Möchtest du den folgenden Link öffnen?\n${e.uri}', - confirmButton: 'Öffnen', - onConfirm: () => launchUrl(Uri.parse(e.uri), mode: LaunchMode.externalApplication), + appBar: AppBar(title: Text(widget.message.name)), + body: SfPdfViewer.network( + widget.basePath + widget.message.url, + enableHyperlinkNavigation: true, + onDocumentLoadFailed: (PdfDocumentLoadFailedDetails e) { + Navigator.of(context).pop(); + InfoDialog.show( + context, + "Dokument '${widget.message.name}' konnte nicht geladen werden:\n${e.description}", + title: 'Fehler beim öffnen', + ); + }, + onHyperlinkClicked: (PdfHyperlinkClickedDetails e) { + showDialog( + context: context, + builder: (context) => ConfirmDialog( + title: 'Link öffnen', + content: 'Möchtest du den folgenden Link öffnen?\n${e.uri}', + confirmButton: 'Öffnen', + onConfirm: () => launchUrl( + Uri.parse(e.uri), + mode: LaunchMode.externalApplication, ), - ); - }, - ), - ); + ), + ); + }, + ), + ); } diff --git a/lib/view/pages/more/feedback/feedback_dialog.dart b/lib/view/pages/more/feedback/feedback_dialog.dart index 42d082d..3fcd528 100644 --- a/lib/view/pages/more/feedback/feedback_dialog.dart +++ b/lib/view/pages/more/feedback/feedback_dialog.dart @@ -1,4 +1,3 @@ - import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; @@ -46,40 +45,49 @@ class _FeedbackDialogState extends State { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Feedback'), - ), - body: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.max, - children: [ - const SizedBox(height: 5), - const Text('Feedback, Anregungen, Ideen, Fehler und Verbesserungen', textAlign: TextAlign.center), - const SizedBox(height: 15), - const Text('Bitte gib keine geheimen Daten wie z.B. Passwörter weiter.', textAlign: TextAlign.center, style: TextStyle(fontSize: 11)), - const SizedBox(height: 20), - Padding( - padding: const EdgeInsets.all(10), - child: TextField( - onChanged: (value) { - if(value.trim().toLowerCase() == 'ranzig') { - _feedbackInput.text = 'selber'; - } - }, - controller: _feedbackInput, - autofocus: true, - decoration: InputDecoration( - border: const OutlineInputBorder(), - label: const Text('Feedback und Verbesserungen'), - errorText: _textFieldEmpty ? 'Bitte gib eine Beschreibung an!' : null, - ), - minLines: 4, - maxLines: 7, - onTapOutside: (PointerDownEvent event) => FocusBehaviour.textFieldTapOutside(context), + appBar: AppBar(title: const Text('Feedback')), + body: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + const SizedBox(height: 5), + const Text( + 'Feedback, Anregungen, Ideen, Fehler und Verbesserungen', + textAlign: TextAlign.center, + ), + const SizedBox(height: 15), + const Text( + 'Bitte gib keine geheimen Daten wie z.B. Passwörter weiter.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 11), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.all(10), + child: TextField( + onChanged: (value) { + if (value.trim().toLowerCase() == 'ranzig') { + _feedbackInput.text = 'selber'; + } + }, + controller: _feedbackInput, + autofocus: true, + decoration: InputDecoration( + border: const OutlineInputBorder(), + label: const Text('Feedback und Verbesserungen'), + errorText: _textFieldEmpty + ? 'Bitte gib eine Beschreibung an!' + : null, ), + minLines: 4, + maxLines: 7, + onTapOutside: (PointerDownEvent event) => + FocusBehaviour.textFieldTapOutside(context), ), - const SizedBox(height: 10), - if(_image != null) Row( + ), + const SizedBox(height: 10), + if (_image != null) + Row( mainAxisAlignment: MainAxisAlignment.center, children: [ badges.Badge( @@ -109,76 +117,95 @@ class _FeedbackDialogState extends State { ), ], ), - Padding( - padding: const EdgeInsets.all(5), + Padding( + padding: const EdgeInsets.all(5), + child: Visibility( + visible: _error != null, child: Visibility( - visible: _error != null, - child: Visibility( - visible: context.read().val().devToolsEnabled, - replacement: const Text('Senden fehlgeschlagen, bitte überprüfe die Internetverbindung.', textAlign: TextAlign.center, style: TextStyle(color: Colors.red)), - child: Text('Senden fehlgeschlagen: \n $_error', textAlign: TextAlign.center, style: const TextStyle(color: Colors.red)), + visible: context.read().val().devToolsEnabled, + replacement: const Text( + 'Senden fehlgeschlagen, bitte überprüfe die Internetverbindung.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.red), + ), + child: Text( + 'Senden fehlgeschlagen: \n $_error', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), ), ), ), - Padding( - padding: const EdgeInsets.only(right: 20, left: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Visibility( - visible: _image == null, - child: IconButton( - onPressed: () async { - context.loaderOverlay.show(); - final picked = await FilePick.multipleGalleryPick(); - final imageData = await picked?.first.readAsBytes(); - if(context.mounted) context.loaderOverlay.hide(); - setState(() { - _image = imageData; - }); - }, - icon: const Icon(Icons.attach_file_outlined), - ), - ), - const Expanded(child: SizedBox.shrink()), - TextButton( - onPressed: () async { - if(_feedbackInput.text.isEmpty){ - setState(() { - _textFieldEmpty = true; - }); - return; - } - context.loaderOverlay.show(); - unawaited(AddFeedback( - AddFeedbackParams( - user: AccountData().getUserSecret(), - feedback: _feedbackInput.text, - screenshot: _image != null ? base64Encode(_image!) : null, - appVersion: int.parse((await PackageInfo.fromPlatform()).buildNumber), + ), + Padding( + padding: const EdgeInsets.only(right: 20, left: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Visibility( + visible: _image == null, + child: IconButton( + onPressed: () async { + context.loaderOverlay.show(); + final picked = await FilePick.multipleGalleryPick(); + final imageData = await picked?.first.readAsBytes(); + if (context.mounted) context.loaderOverlay.hide(); + setState(() { + _image = imageData; + }); + }, + icon: const Icon(Icons.attach_file_outlined), + ), + ), + const Expanded(child: SizedBox.shrink()), + TextButton( + onPressed: () async { + if (_feedbackInput.text.isEmpty) { + setState(() { + _textFieldEmpty = true; + }); + return; + } + context.loaderOverlay.show(); + unawaited( + AddFeedback( + AddFeedbackParams( + user: AccountData().getUserSecret(), + feedback: _feedbackInput.text, + screenshot: _image != null + ? base64Encode(_image!) + : null, + appVersion: int.parse( + (await PackageInfo.fromPlatform()).buildNumber, ), - ).run().then((value) { + ), + ) + .run() + .then((value) { if (!context.mounted) return; Navigator.of(context).pop(); - InfoDialog.show(context, 'Danke für dein Feedback!'); + InfoDialog.show( + context, + 'Danke für dein Feedback!', + ); context.loaderOverlay.hide(); - }).catchError((Object error, StackTrace trace) { + }) + .catchError((Object error, StackTrace trace) { if (!mounted) return; setState(() { _error = error.toString(); }); if (!context.mounted) return; context.loaderOverlay.hide(); - })); - }, - child: const Text('Senden'), - ) - ] - ) - ) - - ], - ), + }), + ); + }, + child: const Text('Senden'), + ), + ], + ), + ), + ], ), - ); + ), + ); } diff --git a/lib/view/pages/more/roomplan/roomplan.dart b/lib/view/pages/more/roomplan/roomplan.dart index 0b15a20..63f3d3e 100644 --- a/lib/view/pages/more/roomplan/roomplan.dart +++ b/lib/view/pages/more/roomplan/roomplan.dart @@ -6,14 +6,14 @@ class Roomplan extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Raumplan'), + appBar: AppBar(title: const Text('Raumplan')), + body: PhotoView( + imageProvider: Image.asset('assets/img/raumplan.png').image, + minScale: 0.5, + maxScale: 2.0, + backgroundDecoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, ), - body: PhotoView( - imageProvider: Image.asset('assets/img/raumplan.png').image, - minScale: 0.5, - maxScale: 2.0, - backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), - ), - ); + ), + ); } diff --git a/lib/view/pages/more/share/app_share_platform_view.dart b/lib/view/pages/more/share/app_share_platform_view.dart index b793d4e..fe3ed7f 100644 --- a/lib/view/pages/more/share/app_share_platform_view.dart +++ b/lib/view/pages/more/share/app_share_platform_view.dart @@ -14,7 +14,10 @@ class AppSharePlatformView extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text(title, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + Text( + title, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), const SizedBox(height: 30), Container( padding: const EdgeInsets.all(10), @@ -26,8 +29,8 @@ class AppSharePlatformView extends StatelessWidget { version: QrVersions.auto, size: 200, dataModuleStyle: QrDataModuleStyle( - color: foregroundColor, - dataModuleShape: QrDataModuleShape.square + color: foregroundColor, + dataModuleShape: QrDataModuleShape.square, ), eyeStyle: QrEyeStyle( color: foregroundColor, diff --git a/lib/view/pages/more/share/qr_share_view.dart b/lib/view/pages/more/share/qr_share_view.dart index ec66db1..d3482ca 100644 --- a/lib/view/pages/more/share/qr_share_view.dart +++ b/lib/view/pages/more/share/qr_share_view.dart @@ -25,23 +25,29 @@ class _QrShareViewState extends State { @override Widget build(BuildContext context) => DefaultTabController( - length: 2, - child: Scaffold( - appBar: AppBar( - title: const Text('Teile die App'), - bottom: const TabBar( - tabs: [ - Tab(icon: Icon(Icons.android_outlined), text: 'Android'), - Tab(icon: Icon(Icons.apple_outlined), text: 'iOS & iPadOS'), - ], - ), - ), - body: const TabBarView( - children: [ - AppSharePlatformView('Für Android', 'https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client'), - AppSharePlatformView('Für iOS & iPad', 'https://apps.apple.com/us/app/marianum-fulda/id6458789560'), + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('Teile die App'), + bottom: const TabBar( + tabs: [ + Tab(icon: Icon(Icons.android_outlined), text: 'Android'), + Tab(icon: Icon(Icons.apple_outlined), text: 'iOS & iPadOS'), ], ), ), - ); + body: const TabBarView( + children: [ + AppSharePlatformView( + 'Für Android', + 'https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client', + ), + AppSharePlatformView( + 'Für iOS & iPad', + 'https://apps.apple.com/us/app/marianum-fulda/id6458789560', + ), + ], + ), + ), + ); } diff --git a/lib/view/pages/more/share/select_share_type_dialog.dart b/lib/view/pages/more/share/select_share_type_dialog.dart index a1df4f4..61dc782 100644 --- a/lib/view/pages/more/share/select_share_type_dialog.dart +++ b/lib/view/pages/more/share/select_share_type_dialog.dart @@ -30,14 +30,17 @@ Future showSelectShareTypeSheet(BuildContext context) { trailing: const Icon(Icons.arrow_right), onTap: () { Navigator.of(sheetCtx).pop(); - SharePlus.instance.share(ShareParams( - sharePositionOrigin: SharePositionOrigin.get(sheetCtx), - subject: 'App Teilen', - text: 'Hol dir die für das Marianum maßgeschneiderte App:' - '\n\nAndroid: https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client ' - '\niOS: https://apps.apple.com/us/app/marianum-fulda/id6458789560 ' - '\n\nViel Spaß!', - )); + SharePlus.instance.share( + ShareParams( + sharePositionOrigin: SharePositionOrigin.get(sheetCtx), + subject: 'App Teilen', + text: + 'Hol dir die für das Marianum maßgeschneiderte App:' + '\n\nAndroid: https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client ' + '\niOS: https://apps.apple.com/us/app/marianum-fulda/id6458789560 ' + '\n\nViel Spaß!', + ), + ); }, ), ], diff --git a/lib/view/pages/overhang.dart b/lib/view/pages/overhang.dart index e89ee2a..889eeca 100644 --- a/lib/view/pages/overhang.dart +++ b/lib/view/pages/overhang.dart @@ -1,4 +1,3 @@ - import 'dart:io'; import 'package:flutter/material.dart'; @@ -24,67 +23,81 @@ class _OverhangState extends State { appBar: AppBar( title: const Text('Mehr'), actions: [ - IconButton(onPressed: () => AppRoutes.openSettings(context), icon: const Icon(Icons.settings)), + IconButton( + onPressed: () => AppRoutes.openSettings(context), + icon: const Icon(Icons.settings), + ), ], ), body: _overhang(), ); Widget _overhang() => ListView( - children: [ - ...AppModule.getOverhangModules(context).map((e) => e.toListTile(context)), + children: [ + ...AppModule.getOverhangModules( + context, + ).map((e) => e.toListTile(context)), - const Divider(), + const Divider(), - ListTile( - leading: const Icon(Icons.share_outlined), - title: const Text('Teile die App'), - subtitle: const Text('Mit Freunden und deiner Klasse teilen'), + ListTile( + leading: const Icon(Icons.share_outlined), + title: const Text('Teile die App'), + subtitle: const Text('Mit Freunden und deiner Klasse teilen'), + trailing: const Icon(Icons.arrow_right), + onTap: () async { + final result = await showSelectShareTypeSheet(context); + if (!mounted || result != ShareTargetType.qr) return; + if (context.mounted) AppRoutes.openQrShare(context); + }, + ), + FutureBuilder( + future: InAppReview.instance.isAvailable(), + builder: (context, snapshot) { + if (!snapshot.hasData) return const SizedBox.shrink(); + + String? getPlatformStoreName() { + if (Platform.isAndroid) return 'Play store'; + if (Platform.isIOS) return 'App store'; + return null; + } + + return ListTile( + leading: const CenteredLeading(Icon(Icons.star_rate_outlined)), + title: const Text('App bewerten'), + subtitle: getPlatformStoreName().wrapNullable( + (data) => Text('Im $data'), + ), trailing: const Icon(Icons.arrow_right), - onTap: () async { - final result = await showSelectShareTypeSheet(context); - if (!mounted || result != ShareTargetType.qr) return; - if (context.mounted) AppRoutes.openQrShare(context); + onTap: () { + InAppReview.instance + .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', + ); + }, + ); }, - ), - FutureBuilder( - future: InAppReview.instance.isAvailable(), - builder: (context, snapshot) { - if(!snapshot.hasData) return const SizedBox.shrink(); - - String? getPlatformStoreName() { - if(Platform.isAndroid) return 'Play store'; - if(Platform.isIOS) return 'App store'; - return null; - } - - return ListTile( - leading: const CenteredLeading(Icon(Icons.star_rate_outlined)), - title: const Text('App bewerten'), - subtitle: getPlatformStoreName().wrapNullable((data) => Text('Im $data')), - trailing: const Icon(Icons.arrow_right), - onTap: () { - InAppReview.instance.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'); - }, - ); - }, - ); - }, - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.feedback_outlined)), - title: const Text('Du hast eine Idee?'), - subtitle: const Text('Fehler und Verbessungsvorschläge'), - trailing: const Icon(Icons.arrow_right), - onTap: () => AppRoutes.openFeedback(context), - ), - ], - ); + ); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.feedback_outlined)), + title: const Text('Du hast eine Idee?'), + subtitle: const Text('Fehler und Verbessungsvorschläge'), + trailing: const Icon(Icons.arrow_right), + onTap: () => AppRoutes.openFeedback(context), + ), + ], + ); } diff --git a/lib/view/pages/settings/data/default_settings.dart b/lib/view/pages/settings/data/default_settings.dart index d893b77..84d888b 100644 --- a/lib/view/pages/settings/data/default_settings.dart +++ b/lib/view/pages/settings/data/default_settings.dart @@ -17,53 +17,51 @@ import '../../files/data/sort_options.dart'; class DefaultSettings { static Settings get() => Settings( - appTheme: ThemeMode.system, - devToolsEnabled: false, - modulesSettings: ModulesSettings( - moduleOrder: [ - Modules.timetable, - Modules.talk, - Modules.files, - Modules.marianumMessage, - Modules.roomPlan, - Modules.gradeAveragesCalculator, - Modules.holidays, - Modules.marianumDates, - ], - hiddenModules: [], - autoFillBottomBar: true, - fixedBottomBarSlots: 3, - ), - timetableSettings: TimetableSettings( - connectDoubleLessons: true, - timetableNameMode: TimetableNameMode.name, - ), - talkSettings: TalkSettings( - sortFavoritesToTop: true, - sortUnreadToTop: false, - drafts: {}, - draftReplies: {}, - ), - fileSettings: FileSettings( - sortFoldersToTop: true, - ascending: true, - sortBy: SortOption.name - ), - holidaysSettings: HolidaysSettings( - dismissedDisclaimer: false, - showPastEvents: false, - ), - fileViewSettings: FileViewSettings( - alwaysOpenExternally: Platform.isIOS, - ), - notificationSettings: NotificationSettings( - askUsageDismissed: false, - enabled: false, - ), - devToolsSettings: DevToolsSettings( - checkerboardOffscreenLayers: false, - checkerboardRasterCacheImages: false, - showPerformanceOverlay: false, - ), - ); + appTheme: ThemeMode.system, + devToolsEnabled: false, + modulesSettings: ModulesSettings( + moduleOrder: [ + Modules.timetable, + Modules.talk, + Modules.files, + Modules.marianumMessage, + Modules.roomPlan, + Modules.gradeAveragesCalculator, + Modules.holidays, + Modules.marianumDates, + ], + hiddenModules: [], + autoFillBottomBar: true, + fixedBottomBarSlots: 3, + ), + timetableSettings: TimetableSettings( + connectDoubleLessons: true, + timetableNameMode: TimetableNameMode.name, + ), + talkSettings: TalkSettings( + sortFavoritesToTop: true, + sortUnreadToTop: false, + drafts: {}, + draftReplies: {}, + ), + fileSettings: FileSettings( + sortFoldersToTop: true, + ascending: true, + sortBy: SortOption.name, + ), + holidaysSettings: HolidaysSettings( + dismissedDisclaimer: false, + showPastEvents: false, + ), + fileViewSettings: FileViewSettings(alwaysOpenExternally: Platform.isIOS), + notificationSettings: NotificationSettings( + askUsageDismissed: false, + enabled: false, + ), + devToolsSettings: DevToolsSettings( + checkerboardOffscreenLayers: false, + checkerboardRasterCacheImages: false, + showPerformanceOverlay: false, + ), + ); } diff --git a/lib/view/pages/settings/modules_settings_page.dart b/lib/view/pages/settings/modules_settings_page.dart index c414d20..4a84b86 100644 --- a/lib/view/pages/settings/modules_settings_page.dart +++ b/lib/view/pages/settings/modules_settings_page.dart @@ -14,103 +14,144 @@ class ModuleSortBody extends StatelessWidget { const ModuleSortBody({super.key}); @override - Widget build(BuildContext context) => BlocBuilder(builder: (context, _) { - final settings = context.read(); - final modulesSettings = settings.val().modulesSettings; + Widget build( + BuildContext context, + ) => BlocBuilder( + builder: (context, _) { + final settings = context.read(); + final modulesSettings = settings.val().modulesSettings; - void changeVisibility(Modules module) { - var hidden = settings.val(write: true).modulesSettings.hiddenModules; - if (hidden.contains(module)) { - hidden.remove(module); - } else if (hidden.length < 3) { - hidden.add(module); + void changeVisibility(Modules module) { + var hidden = settings.val(write: true).modulesSettings.hiddenModules; + if (hidden.contains(module)) { + hidden.remove(module); + } else if (hidden.length < 3) { + hidden.add(module); + } } - } - return ReorderableListView( - header: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 16), - child: Text( - 'Halte und ziehe einen Eintrag, um ihn zu verschieben.\nEs können 3 Bereiche ausgeblendet werden.', - textAlign: TextAlign.center, - ), - ), - SwitchListTile( - title: const Text('Modulleiste automatisch füllen'), - subtitle: const Text('Auf größeren Bildschirmen werden mehr Module direkt angezeigt'), - value: modulesSettings.autoFillBottomBar, - onChanged: (value) => settings.val(write: true).modulesSettings.autoFillBottomBar = value, - ), - if (!modulesSettings.autoFillBottomBar) - ListTile( - title: const Text('Anzahl Slots in der Modulleiste'), - subtitle: Text('${modulesSettings.fixedBottomBarSlots} Module (zzgl. „Mehr")'), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.remove_circle_outline), - onPressed: modulesSettings.fixedBottomBarSlots > AppModule.minBottomBarSlots - ? () => settings.val(write: true).modulesSettings.fixedBottomBarSlots -= 1 - : null, - ), - Text('${modulesSettings.fixedBottomBarSlots}'), - IconButton( - icon: const Icon(Icons.add_circle_outline), - onPressed: modulesSettings.fixedBottomBarSlots < AppModule.maxBottomBarSlots - ? () => settings.val(write: true).modulesSettings.fixedBottomBarSlots += 1 - : null, - ), - ], + return ReorderableListView( + header: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Text( + 'Halte und ziehe einen Eintrag, um ihn zu verschieben.\nEs können 3 Bereiche ausgeblendet werden.', + textAlign: TextAlign.center, ), ), - const Divider(), - ], - ), - children: AppModule.modules(context, showFiltered: true) - .map((key, value) => MapEntry(key, value.toListTile( - context, - key: Key(key.name), - isReorder: true, - onVisibleChange: () => changeVisibility(key), - isVisible: !settings.val().modulesSettings.hiddenModules.contains(key), - ))) - .values - .toList(), - onReorder: (oldIndex, newIndex) { - if (newIndex > oldIndex) newIndex -= 1; + SwitchListTile( + title: const Text('Modulleiste automatisch füllen'), + subtitle: const Text( + 'Auf größeren Bildschirmen werden mehr Module direkt angezeigt', + ), + value: modulesSettings.autoFillBottomBar, + onChanged: (value) => + settings.val(write: true).modulesSettings.autoFillBottomBar = + value, + ), + if (!modulesSettings.autoFillBottomBar) + ListTile( + title: const Text('Anzahl Slots in der Modulleiste'), + subtitle: Text( + '${modulesSettings.fixedBottomBarSlots} Module (zzgl. „Mehr")', + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.remove_circle_outline), + onPressed: + modulesSettings.fixedBottomBarSlots > + AppModule.minBottomBarSlots + ? () => + settings + .val(write: true) + .modulesSettings + .fixedBottomBarSlots -= + 1 + : null, + ), + Text('${modulesSettings.fixedBottomBarSlots}'), + IconButton( + icon: const Icon(Icons.add_circle_outline), + onPressed: + modulesSettings.fixedBottomBarSlots < + AppModule.maxBottomBarSlots + ? () => + settings + .val(write: true) + .modulesSettings + .fixedBottomBarSlots += + 1 + : null, + ), + ], + ), + ), + const Divider(), + ], + ), + children: AppModule.modules(context, showFiltered: true) + .map( + (key, value) => MapEntry( + key, + value.toListTile( + context, + key: Key(key.name), + isReorder: true, + onVisibleChange: () => changeVisibility(key), + isVisible: !settings + .val() + .modulesSettings + .hiddenModules + .contains(key), + ), + ), + ) + .values + .toList(), + onReorder: (oldIndex, newIndex) { + if (newIndex > oldIndex) newIndex -= 1; - var order = settings.val().modulesSettings.moduleOrder.toList(); - final movedModule = order.removeAt(oldIndex); - order.insert(newIndex, movedModule); - settings.val(write: true).modulesSettings.moduleOrder = order; - }, - ); - }); + var order = settings.val().modulesSettings.moduleOrder.toList(); + final movedModule = order.removeAt(oldIndex); + order.insert(newIndex, movedModule); + settings.val(write: true).modulesSettings.moduleOrder = order; + }, + ); + }, + ); } class ModulesSettingsPage extends StatelessWidget { const ModulesSettingsPage({super.key}); @override - Widget build(BuildContext context) => BlocBuilder(builder: (context, _) { - final settings = context.read(); - final isModified = settings.val().modulesSettings.toJson().toString() != DefaultSettings.get().modulesSettings.toJson().toString(); - return Scaffold( - appBar: AppBar( - title: const Text('Module'), - actions: [ - IconButton( - tooltip: 'Auf Standard zurücksetzen', - onPressed: isModified ? () => settings.val(write: true).modulesSettings = DefaultSettings.get().modulesSettings : null, - icon: const Icon(Icons.undo_outlined), - ), - ], - ), - body: const ModuleSortBody(), - ); - }); + Widget build(BuildContext context) => + BlocBuilder( + builder: (context, _) { + final settings = context.read(); + final isModified = + settings.val().modulesSettings.toJson().toString() != + DefaultSettings.get().modulesSettings.toJson().toString(); + return Scaffold( + appBar: AppBar( + title: const Text('Module'), + actions: [ + IconButton( + tooltip: 'Auf Standard zurücksetzen', + onPressed: isModified + ? () => settings.val(write: true).modulesSettings = + DefaultSettings.get().modulesSettings + : null, + icon: const Icon(Icons.undo_outlined), + ), + ], + ), + body: const ModuleSortBody(), + ); + }, + ); } diff --git a/lib/view/pages/settings/sections/about_section.dart b/lib/view/pages/settings/sections/about_section.dart index 4de6616..eeda08b 100644 --- a/lib/view/pages/settings/sections/about_section.dart +++ b/lib/view/pages/settings/sections/about_section.dart @@ -37,14 +37,18 @@ class AboutSection extends StatelessWidget { leading: const CenteredLeading(Icon(Icons.code)), title: const Text('Quellcode MarianumMobile/Client'), subtitle: const Text('GNU GPL v3'), - onTap: () => ConfirmDialog.openBrowser(context, 'https://mhsl.eu/gitea/MarianumMobile/Client'), + onTap: () => ConfirmDialog.openBrowser( + context, + 'https://mhsl.eu/gitea/MarianumMobile/Client', + ), ), ListTile( leading: const Icon(Icons.developer_mode_outlined), title: const Text('Entwicklermodus'), trailing: Checkbox( value: settings.val().devToolsEnabled, - onChanged: (state) => _toggleDeveloperMode(context, settings, state), + onChanged: (state) => + _toggleDeveloperMode(context, settings, state), ), ), Visibility( @@ -62,8 +66,10 @@ class AboutSection extends StatelessWidget { context: context, applicationIcon: const Icon(Icons.apps), applicationName: 'MarianumMobile', - applicationVersion: '${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}', - applicationLegalese: 'Dies ist ein Inoffizieller Nextcloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n' + applicationVersion: + '${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}', + applicationLegalese: + 'Dies ist ein Inoffizieller Nextcloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n' 'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n' "${kReleaseMode ? "Production" : "Development"} build\n" 'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller', @@ -71,49 +77,58 @@ class AboutSection extends StatelessWidget { } void _showPrivacyDialog(BuildContext context) => showDetailsBottomSheet( - context, - children: (sheetCtx) => [ - ListTile( - leading: const CenteredLeading(Icon(Icons.school_outlined)), - title: const Text('Infos zum Marianum Fulda'), - subtitle: const Text('Für Talk-Chats und Dateien'), - trailing: const Icon(Icons.arrow_right), - onTap: () => PrivacyInfo( - providerText: 'Marianum', - imprintUrl: 'https://www.marianum-fulda.de/impressum', - privacyUrl: 'https://www.marianum-fulda.de/datenschutz', - ).showPopup(sheetCtx), - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.date_range_outlined)), - title: const Text('Infos zu Web-/ Untis'), - subtitle: const Text('Für den Stundenplan'), - trailing: const Icon(Icons.arrow_right), - onTap: () => PrivacyInfo( - providerText: 'Untis', - imprintUrl: 'https://www.untis.at/impressum', - privacyUrl: 'https://www.untis.at/datenschutz-wu-apps', - ).showPopup(sheetCtx), - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.send_time_extension_outlined)), - title: const Text('Infos zu mhsl'), - subtitle: const Text('Für Countdowns, Marianum Message und mehr'), - trailing: const Icon(Icons.arrow_right), - onTap: () => PrivacyInfo( - providerText: 'mhsl', - imprintUrl: 'https://mhsl.eu/id.html', - privacyUrl: 'https://mhsl.eu/datenschutz.html', - ).showPopup(sheetCtx), - ), - ], - ); + context, + children: (sheetCtx) => [ + ListTile( + leading: const CenteredLeading(Icon(Icons.school_outlined)), + title: const Text('Infos zum Marianum Fulda'), + subtitle: const Text('Für Talk-Chats und Dateien'), + trailing: const Icon(Icons.arrow_right), + onTap: () => PrivacyInfo( + providerText: 'Marianum', + imprintUrl: 'https://www.marianum-fulda.de/impressum', + privacyUrl: 'https://www.marianum-fulda.de/datenschutz', + ).showPopup(sheetCtx), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.date_range_outlined)), + title: const Text('Infos zu Web-/ Untis'), + subtitle: const Text('Für den Stundenplan'), + trailing: const Icon(Icons.arrow_right), + onTap: () => PrivacyInfo( + providerText: 'Untis', + imprintUrl: 'https://www.untis.at/impressum', + privacyUrl: 'https://www.untis.at/datenschutz-wu-apps', + ).showPopup(sheetCtx), + ), + ListTile( + leading: const CenteredLeading( + Icon(Icons.send_time_extension_outlined), + ), + title: const Text('Infos zu mhsl'), + subtitle: const Text('Für Countdowns, Marianum Message und mehr'), + trailing: const Icon(Icons.arrow_right), + onTap: () => PrivacyInfo( + providerText: 'mhsl', + imprintUrl: 'https://mhsl.eu/id.html', + privacyUrl: 'https://mhsl.eu/datenschutz.html', + ).showPopup(sheetCtx), + ), + ], + ); - void _toggleDeveloperMode(BuildContext context, SettingsCubit settings, bool? state) { + void _toggleDeveloperMode( + BuildContext context, + SettingsCubit settings, + bool? state, + ) { void apply() { final enabled = state ?? false; settings.val(write: true).devToolsEnabled = enabled; - if (!enabled) settings.val(write: true).devToolsSettings = DefaultSettings.get().devToolsSettings; + if (!enabled) { + settings.val(write: true).devToolsSettings = + DefaultSettings.get().devToolsSettings; + } } if (!state!) { @@ -123,7 +138,8 @@ class AboutSection extends StatelessWidget { ConfirmDialog( title: 'Entwicklermodus', - content: 'Die Entwickleransicht bietet erweiterte Funktionen, die für den üblichen Gebrauch nicht benötigt werden.\n\n' + content: + 'Die Entwickleransicht bietet erweiterte Funktionen, die für den üblichen Gebrauch nicht benötigt werden.\n\n' 'Die Verwendung der Tools kann darüber hinaus bei falscher Verwendung zu Fehlern führen.\n\n' 'Aktivieren auf eigene Verantwortung.', confirmButton: 'Ja, ich verstehe das Risiko', diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index a6190cc..f7b37ea 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -12,11 +12,11 @@ class AccountSection extends StatelessWidget { @override Widget build(BuildContext context) => ListTile( - leading: const CenteredLeading(Icon(Icons.logout_outlined)), - title: const Text('Konto abmelden'), - subtitle: Text('Angemeldet als ${AccountData().getUsername()}'), - onTap: () => _showLogoutDialog(context), - ); + leading: const CenteredLeading(Icon(Icons.logout_outlined)), + title: const Text('Konto abmelden'), + subtitle: Text('Angemeldet als ${AccountData().getUsername()}'), + onTap: () => _showLogoutDialog(context), + ); Future _showLogoutDialog(BuildContext context) async { // Sequential logout flow: dialog wipes secure storage, dialog closes diff --git a/lib/view/pages/settings/sections/appearance_section.dart b/lib/view/pages/settings/sections/appearance_section.dart index 003441a..851c25f 100644 --- a/lib/view/pages/settings/sections/appearance_section.dart +++ b/lib/view/pages/settings/sections/appearance_section.dart @@ -17,17 +17,19 @@ class AppearanceSection extends StatelessWidget { value: settings.val().appTheme, icon: const Icon(Icons.arrow_drop_down), items: ThemeMode.values - .map((e) => DropdownMenuItem( - value: e, - enabled: e != settings.val().appTheme, - child: Row( - children: [ - Icon(AppTheme.getDisplayOptions(e).icon), - const SizedBox(width: 10), - Text(AppTheme.getDisplayOptions(e).displayName), - ], - ), - )) + .map( + (e) => DropdownMenuItem( + value: e, + enabled: e != settings.val().appTheme, + child: Row( + children: [ + Icon(AppTheme.getDisplayOptions(e).icon), + const SizedBox(width: 10), + Text(AppTheme.getDisplayOptions(e).displayName), + ], + ), + ), + ) .toList(), onChanged: (e) => settings.val(write: true).appTheme = e!, ), diff --git a/lib/view/pages/settings/sections/dev_tools_section.dart b/lib/view/pages/settings/sections/dev_tools_section.dart index 82d5019..f8978ab 100644 --- a/lib/view/pages/settings/sections/dev_tools_section.dart +++ b/lib/view/pages/settings/sections/dev_tools_section.dart @@ -1,4 +1,3 @@ - import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -24,117 +23,150 @@ class DevToolsSection extends StatefulWidget { class _DevToolsSectionState extends State { @override Widget build(BuildContext context) => Column( - children: [ - ListTile( - leading: const CenteredLeading(Icon(Icons.speed_outlined)), - title: const Text('Performance overlays'), - trailing: const Icon(Icons.arrow_right), - onTap: () { - showDetailsBottomSheet( - context, - children: (sheetCtx) => [ - BlocBuilder( - bloc: widget.settings, - builder: (_, _) { - final dev = widget.settings.val().devToolsSettings; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.auto_graph_outlined), - title: const Text('Performance graph'), - trailing: Checkbox( - value: dev.showPerformanceOverlay, - onChanged: (e) => widget.settings.val(write: true).devToolsSettings.showPerformanceOverlay = e!, - ), + children: [ + ListTile( + leading: const CenteredLeading(Icon(Icons.speed_outlined)), + title: const Text('Performance overlays'), + trailing: const Icon(Icons.arrow_right), + onTap: () { + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + BlocBuilder( + bloc: widget.settings, + builder: (_, _) { + final dev = widget.settings.val().devToolsSettings; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.auto_graph_outlined), + title: const Text('Performance graph'), + trailing: Checkbox( + value: dev.showPerformanceOverlay, + onChanged: (e) => + widget.settings + .val(write: true) + .devToolsSettings + .showPerformanceOverlay = + e!, ), - ListTile( - leading: const Icon(Icons.screen_search_desktop_outlined), - title: const Text('Indicate offscreen layers'), - trailing: Checkbox( - value: dev.checkerboardOffscreenLayers, - onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardOffscreenLayers = e!, - ), + ), + ListTile( + leading: const Icon( + Icons.screen_search_desktop_outlined, ), - ListTile( - leading: const Icon(Icons.imagesearch_roller_outlined), - title: const Text('Indicate raster cache images'), - trailing: Checkbox( - value: dev.checkerboardRasterCacheImages, - onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardRasterCacheImages = e!, - ), + title: const Text('Indicate offscreen layers'), + trailing: Checkbox( + value: dev.checkerboardOffscreenLayers, + onChanged: (e) => + widget.settings + .val(write: true) + .devToolsSettings + .checkerboardOffscreenLayers = + e!, ), - ], - ); - }, - ), - ], - ); - }, + ), + ListTile( + leading: const Icon(Icons.imagesearch_roller_outlined), + title: const Text('Indicate raster cache images'), + trailing: Checkbox( + value: dev.checkerboardRasterCacheImages, + onChanged: (e) => + widget.settings + .val(write: true) + .devToolsSettings + .checkerboardRasterCacheImages = + e!, + ), + ), + ], + ); + }, + ), + ], + ); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.image_outlined)), + title: const Text('Thumb-storage'), + subtitle: Text( + 'etwa ${filesize(PaintingBinding.instance.imageCache.currentSizeBytes)}\nLange tippen um zu löschen', ), - ListTile( - leading: const CenteredLeading(Icon(Icons.image_outlined)), - title: const Text('Thumb-storage'), - subtitle: Text('etwa ${filesize(PaintingBinding.instance.imageCache.currentSizeBytes)}\nLange tippen um zu löschen'), - onLongPress: () { - ConfirmDialog( - title: 'Thumbs cache löschen', - content: 'Alle zwischengespeicherten Bilder werden gelöscht.', - confirmButton: 'Unwiederruflich löschen', - onConfirm: () => PaintingBinding.instance.imageCache.clear(), - ).asDialog(context); - }, + onLongPress: () { + ConfirmDialog( + title: 'Thumbs cache löschen', + content: 'Alle zwischengespeicherten Bilder werden gelöscht.', + confirmButton: 'Unwiederruflich löschen', + onConfirm: () => PaintingBinding.instance.imageCache.clear(), + ).asDialog(context); + }, + ), + ListTile( + leading: const CenteredLeading( + Icon(Icons.settings_applications_outlined), ), - ListTile( - leading: const CenteredLeading(Icon(Icons.settings_applications_outlined)), - title: const Text('Settings-storage JSON dump'), - subtitle: Text('etwa ${filesize(widget.settings.val().toJson().toString().length * 8)}\nLange tippen um zu löschen'), - onTap: () { - JsonViewer.asDialog(context, widget.settings.val().toJson()); - }, - onLongPress: () { - ConfirmDialog( - title: 'Einstellungen löschen', - content: 'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten sind nicht betroffen.', - confirmButton: 'Unwiederruflich Löschen', - onConfirm: () { - context.read().reset(); - }, - ).asDialog(context); - }, - trailing: const Icon(Icons.arrow_right), + title: const Text('Settings-storage JSON dump'), + subtitle: Text( + 'etwa ${filesize(widget.settings.val().toJson().toString().length * 8)}\nLange tippen um zu löschen', ), - ListTile( - leading: const CenteredLeading(Icon(Icons.data_object)), - title: const Text('Cache-storage JSON dump'), - subtitle: FutureBuilder( - future: const CacheView().totalSize(), - builder: (context, snapshot) => Text("etwa ${snapshot.hasError ? "?" : snapshot.hasData ? filesize(snapshot.data) : "..."}\nLange tippen um zu löschen"), + onTap: () { + JsonViewer.asDialog(context, widget.settings.val().toJson()); + }, + onLongPress: () { + ConfirmDialog( + title: 'Einstellungen löschen', + content: + 'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten sind nicht betroffen.', + confirmButton: 'Unwiederruflich Löschen', + onConfirm: () { + context.read().reset(); + }, + ).asDialog(context); + }, + trailing: const Icon(Icons.arrow_right), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.data_object)), + title: const Text('Cache-storage JSON dump'), + subtitle: FutureBuilder( + future: const CacheView().totalSize(), + builder: (context, snapshot) => Text( + "etwa ${snapshot.hasError + ? "?" + : snapshot.hasData + ? filesize(snapshot.data) + : "..."}\nLange tippen um zu löschen", ), - onTap: () => AppRoutes.openCacheView(context), - onLongPress: () { - ConfirmDialog( - title: 'App-Cache löschen', - content: 'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut', - confirmButton: 'Unwiederruflich löschen', - onConfirm: () => const CacheView().clear().then((value) => setState((){})), - ).asDialog(context); - }, - trailing: const Icon(Icons.arrow_right), ), - ListTile( - leading: const CenteredLeading(Icon(Icons.data_object)), - title: const Text('BLOC-storage state cache'), - subtitle: const Text('Lange tippen um zu löschen'), - onLongPress: () { - ConfirmDialog( - title: 'BLOC-Cache löschen', - content: 'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut', - confirmButton: 'Unwiederruflich löschen', - onConfirm: () => HydratedBloc.storage.clear(), - ).asDialog(context); - }, - ), - ], - ); + onTap: () => AppRoutes.openCacheView(context), + onLongPress: () { + ConfirmDialog( + title: 'App-Cache löschen', + content: + 'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut', + confirmButton: 'Unwiederruflich löschen', + onConfirm: () => + const CacheView().clear().then((value) => setState(() {})), + ).asDialog(context); + }, + trailing: const Icon(Icons.arrow_right), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.data_object)), + title: const Text('BLOC-storage state cache'), + subtitle: const Text('Lange tippen um zu löschen'), + onLongPress: () { + ConfirmDialog( + title: 'BLOC-Cache löschen', + content: + 'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut', + confirmButton: 'Unwiederruflich löschen', + onConfirm: () => HydratedBloc.storage.clear(), + ).asDialog(context); + }, + ), + ], + ); } diff --git a/lib/view/pages/settings/sections/files_section.dart b/lib/view/pages/settings/sections/files_section.dart index 982a464..1c4a7d1 100644 --- a/lib/view/pages/settings/sections/files_section.dart +++ b/lib/view/pages/settings/sections/files_section.dart @@ -16,7 +16,8 @@ class FilesSection extends StatelessWidget { title: const Text('Ordner in Dateien nach oben sortieren'), trailing: Checkbox( value: settings.val().fileSettings.sortFoldersToTop, - onChanged: (e) => settings.val(write: true).fileSettings.sortFoldersToTop = e!, + onChanged: (e) => + settings.val(write: true).fileSettings.sortFoldersToTop = e!, ), ), ListTile( @@ -24,7 +25,12 @@ class FilesSection extends StatelessWidget { title: const Text('Dateien immer mit Systemdialog öffnen'), trailing: Checkbox( value: settings.val().fileViewSettings.alwaysOpenExternally, - onChanged: (e) => settings.val(write: true).fileViewSettings.alwaysOpenExternally = e!, + onChanged: (e) => + settings + .val(write: true) + .fileViewSettings + .alwaysOpenExternally = + e!, ), ), ], diff --git a/lib/view/pages/settings/sections/modules_section.dart b/lib/view/pages/settings/sections/modules_section.dart index b3fe499..94eb221 100644 --- a/lib/view/pages/settings/sections/modules_section.dart +++ b/lib/view/pages/settings/sections/modules_section.dart @@ -7,10 +7,10 @@ class ModulesSection extends StatelessWidget { @override Widget build(BuildContext context) => ListTile( - leading: const Icon(Icons.apps_outlined), - title: const Text('Module'), - subtitle: const Text('Reihenfolge, Sichtbarkeit und Modulleiste anpassen'), - trailing: const Icon(Icons.arrow_right), - onTap: () => AppRoutes.openModulesSettings(context), - ); + leading: const Icon(Icons.apps_outlined), + title: const Text('Module'), + subtitle: const Text('Reihenfolge, Sichtbarkeit und Modulleiste anpassen'), + trailing: const Icon(Icons.arrow_right), + onTap: () => AppRoutes.openModulesSettings(context), + ); } diff --git a/lib/view/pages/settings/sections/talk_section.dart b/lib/view/pages/settings/sections/talk_section.dart index 0b808fb..575f4cc 100644 --- a/lib/view/pages/settings/sections/talk_section.dart +++ b/lib/view/pages/settings/sections/talk_section.dart @@ -21,7 +21,8 @@ class TalkSection extends StatelessWidget { title: const Text('Favoriten im Talk nach oben sortieren'), trailing: Checkbox( value: talkSettings.sortFavoritesToTop, - onChanged: (e) => settings.val(write: true).talkSettings.sortFavoritesToTop = e!, + onChanged: (e) => + settings.val(write: true).talkSettings.sortFavoritesToTop = e!, ), ), ListTile( @@ -29,11 +30,14 @@ class TalkSection extends StatelessWidget { title: const Text('Ungelesene Chats nach oben sortieren'), trailing: Checkbox( value: talkSettings.sortUnreadToTop, - onChanged: (e) => settings.val(write: true).talkSettings.sortUnreadToTop = e!, + onChanged: (e) => + settings.val(write: true).talkSettings.sortUnreadToTop = e!, ), ), ListTile( - leading: const CenteredLeading(Icon(Icons.notifications_active_outlined)), + leading: const CenteredLeading( + Icon(Icons.notifications_active_outlined), + ), title: const Text('Push-Benachrichtigungen aktivieren'), subtitle: const Text('Lange tippen für mehr Informationen'), trailing: Checkbox( @@ -53,12 +57,12 @@ class TalkSection extends StatelessWidget { } void _showInfoDialog(BuildContext context) => InfoDialog.show( - context, - "Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n" - 'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n' - 'Der extene Server verwendet die Zugangsdaten um sich maschinell in Nextcloud Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n' - 'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n' - 'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!', - title: 'Info über Push', - ); + context, + "Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n" + 'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n' + 'Der extene Server verwendet die Zugangsdaten um sich maschinell in Nextcloud Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n' + 'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n' + 'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!', + title: 'Info über Push', + ); } diff --git a/lib/view/pages/settings/sections/timetable_section.dart b/lib/view/pages/settings/sections/timetable_section.dart index 4879c18..044cca0 100644 --- a/lib/view/pages/settings/sections/timetable_section.dart +++ b/lib/view/pages/settings/sections/timetable_section.dart @@ -20,20 +20,25 @@ class TimetableSection extends StatelessWidget { value: timetableSettings.timetableNameMode, icon: const Icon(Icons.arrow_drop_down), items: TimetableNameMode.values - .map((e) => DropdownMenuItem( - value: e, - enabled: e != timetableSettings.timetableNameMode, - child: Row( - children: [ - Icon(TimetableNameModes.getDisplayOptions(e).icon), - const SizedBox(width: 10), - Text(TimetableNameModes.getDisplayOptions(e).displayName), - ], - ), - )) + .map( + (e) => DropdownMenuItem( + value: e, + enabled: e != timetableSettings.timetableNameMode, + child: Row( + children: [ + Icon(TimetableNameModes.getDisplayOptions(e).icon), + const SizedBox(width: 10), + Text( + TimetableNameModes.getDisplayOptions(e).displayName, + ), + ], + ), + ), + ) .toList(), onChanged: (value) => - settings.val(write: true).timetableSettings.timetableNameMode = value!, + settings.val(write: true).timetableSettings.timetableNameMode = + value!, ), ), ListTile( @@ -42,7 +47,11 @@ class TimetableSection extends StatelessWidget { trailing: Checkbox( value: timetableSettings.connectDoubleLessons, onChanged: (e) => - settings.val(write: true).timetableSettings.connectDoubleLessons = e!, + settings + .val(write: true) + .timetableSettings + .connectDoubleLessons = + e!, ), ), ], diff --git a/lib/view/pages/settings/settings.dart b/lib/view/pages/settings/settings.dart index 040d6eb..6c6b970 100644 --- a/lib/view/pages/settings/settings.dart +++ b/lib/view/pages/settings/settings.dart @@ -13,23 +13,23 @@ class Settings extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('Einstellungen')), - body: ListView( - children: const [ - AccountSection(), - Divider(), - AppearanceSection(), - Divider(), - ModulesSection(), - Divider(), - TimetableSection(), - Divider(), - TalkSection(), - Divider(), - FilesSection(), - Divider(), - AboutSection(), - ], - ), - ); + appBar: AppBar(title: const Text('Einstellungen')), + body: ListView( + children: const [ + AccountSection(), + Divider(), + AppearanceSection(), + Divider(), + ModulesSection(), + Divider(), + TimetableSection(), + Divider(), + TalkSection(), + Divider(), + FilesSection(), + Divider(), + AboutSection(), + ], + ), + ); } diff --git a/lib/view/pages/settings/widgets/privacy_info.dart b/lib/view/pages/settings/widgets/privacy_info.dart index ec96588..8e15c67 100644 --- a/lib/view/pages/settings/widgets/privacy_info.dart +++ b/lib/view/pages/settings/widgets/privacy_info.dart @@ -9,7 +9,11 @@ class PrivacyInfo { String privacyUrl; String imprintUrl; - PrivacyInfo({required this.providerText, required this.imprintUrl, required this.privacyUrl}); + PrivacyInfo({ + required this.providerText, + required this.imprintUrl, + required this.privacyUrl, + }); void showPopup(BuildContext context) { showDetailsBottomSheet( diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart index f5bdc85..8939266 100644 --- a/lib/view/pages/talk/chat_list.dart +++ b/lib/view/pages/talk/chat_list.dart @@ -23,7 +23,8 @@ class ChatList extends StatelessWidget { const ChatList({super.key}); @override - Widget build(BuildContext context) => BlocModule>( + Widget build(BuildContext context) => + BlocModule>( create: (_) => ChatListBloc(), child: (context, bloc, _) => const _ChatListView(), ); @@ -83,16 +84,22 @@ class _ChatListViewState extends State<_ChatListView> { void _maybeAskForNotificationPermission() { final notificationSettings = _settings.val().notificationSettings; - if (notificationSettings.enabled || notificationSettings.askUsageDismissed) return; + if (notificationSettings.enabled || + notificationSettings.askUsageDismissed) { + return; + } _settings.val(write: true).notificationSettings.askUsageDismissed = true; ConfirmDialog( icon: Icons.notifications_active_outlined, title: 'Benachrichtigungen aktivieren', - content: 'Auf wunsch kannst du Push-Benachrichtigungen aktivieren. Deine Einstellungen kannst du jederzeit ändern.', + content: + 'Auf wunsch kannst du Push-Benachrichtigungen aktivieren. Deine Einstellungen kannst du jederzeit ändern.', confirmButton: 'Weiter', onConfirm: () { - FirebaseMessaging.instance.requestPermission(provisional: false).then((value) { + FirebaseMessaging.instance.requestPermission(provisional: false).then(( + value, + ) { if (!mounted) return; switch (value.authorizationStatus) { case AuthorizationStatus.authorized: @@ -129,7 +136,10 @@ class _ChatListViewState extends State<_ChatListView> { onPressed: () { final rooms = bloc.state.data?.rooms; if (rooms == null) return; - showSearch(context: context, delegate: SearchChat(rooms.data.toList())); + showSearch( + context: context, + delegate: SearchChat(rooms.data.toList()), + ); }, ), ], @@ -138,11 +148,14 @@ class _ChatListViewState extends State<_ChatListView> { heroTag: 'createChat', backgroundColor: Theme.of(context).primaryColor, onPressed: () { - showSearch(context: context, delegate: JoinChat()).then((username) { + showSearch(context: context, delegate: JoinChat()).then(( + username, + ) { if (username == null || !context.mounted) return; ConfirmDialog( title: 'Chat starten', - content: "Möchtest du einen Chat mit Nutzer '$username' starten?", + content: + "Möchtest du einen Chat mit Nutzer '$username' starten?", confirmButton: 'Chat starten', onConfirmAsync: () => bloc.createDirectChat(username), ).asDialog(context); @@ -155,7 +168,10 @@ class _ChatListViewState extends State<_ChatListView> { final rooms = state.rooms; if (rooms == null) return const SizedBox.shrink(); - final talkSettings = context.watch().val().talkSettings; + final talkSettings = context + .watch() + .val() + .talkSettings; final sorted = rooms.sortBy( lastActivity: true, favoritesToTop: talkSettings.sortFavoritesToTop, @@ -172,7 +188,11 @@ class _ChatListViewState extends State<_ChatListView> { return ListView( padding: EdgeInsets.zero, children: sorted.map((room) { - final hasDraft = _settings.val().talkSettings.drafts.containsKey(room.token); + final hasDraft = _settings + .val() + .talkSettings + .drafts + .containsKey(room.token); return ChatTile(data: room, hasDraft: hasDraft); }).toList(), ); diff --git a/lib/view/pages/talk/chat_view.dart b/lib/view/pages/talk/chat_view.dart index 0b8c78c..2c81c48 100644 --- a/lib/view/pages/talk/chat_view.dart +++ b/lib/view/pages/talk/chat_view.dart @@ -20,7 +20,12 @@ class ChatView extends StatefulWidget { final String selfId; final UserAvatar avatar; - const ChatView({super.key, required this.room, required this.selfId, required this.avatar}); + const ChatView({ + super.key, + required this.room, + required this.selfId, + required this.avatar, + }); @override State createState() => _ChatViewState(); @@ -37,46 +42,58 @@ class _ChatViewState extends State { final messages = []; var lastDate = DateTime.now(); for (final element in response.sortByTimestamp()) { - final elementDate = DateTime.fromMillisecondsSinceEpoch(element.timestamp * 1000); + final elementDate = DateTime.fromMillisecondsSinceEpoch( + element.timestamp * 1000, + ); if (element.systemMessage.contains('reaction')) continue; if (element.systemMessage.contains('poll_voted')) continue; - final commonRead = int.parse(response.headers?['x-chat-last-common-read'] ?? '0'); + final commonRead = int.parse( + response.headers?['x-chat-last-common-read'] ?? '0', + ); if (!elementDate.isSameDay(lastDate)) { lastDate = elementDate; - messages.add(ChatBubble( - context: context, - isSender: false, - bubbleData: GetChatResponseObject.getDateDummy(element.timestamp), - chatData: widget.room, - refetch: ({bool renew = false}) => _refresh(), - )); + messages.add( + ChatBubble( + context: context, + isSender: false, + bubbleData: GetChatResponseObject.getDateDummy(element.timestamp), + chatData: widget.room, + refetch: ({bool renew = false}) => _refresh(), + ), + ); } - messages.add(ChatBubble( - context: context, - isSender: element.actorId == widget.selfId && - element.messageType == GetRoomResponseObjectMessageType.comment, - bubbleData: element, - chatData: widget.room, - refetch: ({bool renew = false}) => _refresh(), - isRead: element.id <= commonRead, - selfId: widget.selfId, - )); + messages.add( + ChatBubble( + context: context, + isSender: + element.actorId == widget.selfId && + element.messageType == GetRoomResponseObjectMessageType.comment, + bubbleData: element, + chatData: widget.room, + refetch: ({bool renew = false}) => _refresh(), + isRead: element.id <= commonRead, + selfId: widget.selfId, + ), + ); } if (response.data.length >= 200) { - messages.insert(0, ChatBubble( - context: context, - isSender: false, - bubbleData: GetChatResponseObject.getTextDummy( - 'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. ' - 'Um ältere Nachrichten abzurufen verwende die Webversion unter https://cloud.marianum-fulda.de', + messages.insert( + 0, + ChatBubble( + context: context, + isSender: false, + bubbleData: GetChatResponseObject.getTextDummy( + 'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. ' + 'Um ältere Nachrichten abzurufen verwende die Webversion unter https://cloud.marianum-fulda.de', + ), + chatData: widget.room, + refetch: ({bool renew = false}) => _refresh(), ), - chatData: widget.room, - refetch: ({bool renew = false}) => _refresh(), - )); + ); } return messages; @@ -84,52 +101,62 @@ class _ChatViewState extends State { @override Widget build(BuildContext context) => Scaffold( - backgroundColor: const Color(0xffefeae2), - appBar: ClickableAppBar( - onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)), - appBar: AppBar( - title: Row( - children: [ - widget.avatar, - const SizedBox(width: 10), - Expanded( - child: Text(widget.room.displayName, overflow: TextOverflow.ellipsis, maxLines: 1), - ), - ], + backgroundColor: const Color(0xffefeae2), + appBar: ClickableAppBar( + onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)), + appBar: AppBar( + title: Row( + children: [ + widget.avatar, + const SizedBox(width: 10), + Expanded( + child: Text( + widget.room.displayName, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), ), - ), + ], ), - body: DecoratedBox( - decoration: BoxDecoration( - image: DecorationImage( - image: const AssetImage('assets/background/chat.png'), - scale: 1.5, - opacity: 1, - repeat: ImageRepeat.repeat, - invertColors: AppTheme.isDarkMode(context), + ), + ), + body: DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: const AssetImage('assets/background/chat.png'), + scale: 1.5, + opacity: 1, + repeat: ImageRepeat.repeat, + invertColors: AppTheme.isDarkMode(context), + ), + ), + child: Column( + children: [ + Expanded( + child: LoadableStateConsumer( + isReady: (state) => + state.chatResponse != null && + state.currentToken == widget.room.token, + child: (state, _) => ListView( + reverse: true, + controller: _listController, + children: _buildMessages(state.chatResponse!).reversed.toList(), + ), ), ), - child: Column( - children: [ - Expanded( - child: LoadableStateConsumer( - isReady: (state) => - state.chatResponse != null && state.currentToken == widget.room.token, - child: (state, _) => ListView( - reverse: true, - controller: _listController, - children: _buildMessages(state.chatResponse!).reversed.toList(), + ColoredBox( + color: Theme.of(context).colorScheme.surface, + child: TalkNavigator.isSecondaryVisible(context) + ? ChatTextfield(widget.room.token, selfId: widget.selfId) + : SafeArea( + child: ChatTextfield( + widget.room.token, + selfId: widget.selfId, + ), ), - ), - ), - ColoredBox( - color: Theme.of(context).colorScheme.surface, - child: TalkNavigator.isSecondaryVisible(context) - ? ChatTextfield(widget.room.token, selfId: widget.selfId) - : SafeArea(child: ChatTextfield(widget.room.token, selfId: widget.selfId)), - ), - ], ), - ), - ); + ], + ), + ), + ); } diff --git a/lib/view/pages/talk/data/chat_bubble_styles.dart b/lib/view/pages/talk/data/chat_bubble_styles.dart index 6c7627a..c5c5f21 100644 --- a/lib/view/pages/talk/data/chat_bubble_styles.dart +++ b/lib/view/pages/talk/data/chat_bubble_styles.dart @@ -8,7 +8,12 @@ extension ColorExtensions on Color { final invertedR = 1.0 - r; final invertedG = 1.0 - g; final invertedB = 1.0 - b; - return Color.from(alpha: a, red: invertedR, green: invertedG, blue: invertedB); + return Color.from( + alpha: a, + red: invertedR, + green: invertedG, + blue: invertedB, + ); } Color withWhite(int whiteValue) { @@ -23,14 +28,18 @@ class ChatBubbleStyles { ChatBubbleStyles(this.context); BubbleStyle getSystemStyle() => BubbleStyle( - color: AppTheme.isDarkMode(context) ? const Color(0xff182229) : Colors.white, + color: AppTheme.isDarkMode(context) + ? const Color(0xff182229) + : Colors.white, elevation: 2, margin: const BubbleEdges.only(bottom: 20, top: 10), alignment: Alignment.center, ); BubbleStyle getRemoteStyle(bool seamless) { - var color = AppTheme.isDarkMode(context) ? const Color(0xff202c33) : Colors.white; + var color = AppTheme.isDarkMode(context) + ? const Color(0xff202c33) + : Colors.white; return BubbleStyle( nip: BubbleNip.leftTop, color: seamless ? Colors.transparent : color, @@ -41,7 +50,9 @@ class ChatBubbleStyles { } BubbleStyle getSelfStyle(bool seamless) { - var color = AppTheme.isDarkMode(context) ? const Color(0xff005c4b) : const Color(0xffd3d3d3); + var color = AppTheme.isDarkMode(context) + ? const Color(0xff005c4b) + : const Color(0xffd3d3d3); return BubbleStyle( nip: BubbleNip.rightBottom, color: seamless ? Colors.transparent : color, diff --git a/lib/view/pages/talk/data/chat_message.dart b/lib/view/pages/talk/data/chat_message.dart index 66b5aa1..30ec493 100644 --- a/lib/view/pages/talk/data/chat_message.dart +++ b/lib/view/pages/talk/data/chat_message.dart @@ -1,4 +1,3 @@ - import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; @@ -19,20 +18,19 @@ class ChatMessage { bool get containsFile => file != null; ChatMessage({required this.originalMessage, this.originalData}) { - if(originalData?.containsKey('file') ?? false) { + if (originalData?.containsKey('file') ?? false) { file = originalData?['file']; } - content = RichObjectStringProcessor.parseToString(originalMessage, originalData); + content = RichObjectStringProcessor.parseToString( + originalMessage, + originalData, + ); } Widget getWidget() { + var contentWidget = Linkify(text: content, onOpen: UrlOpener.onOpen); - var contentWidget = Linkify( - text: content, - onOpen: UrlOpener.onOpen, - ); - - if(originalData?['object']?.type == RichObjectStringObjectType.talkPoll) { + if (originalData?['object']?.type == RichObjectStringObjectType.talkPoll) { return ListTile( leading: const Icon(Icons.poll_outlined), title: Text(originalData!['object']!.name), @@ -40,38 +38,49 @@ class ChatMessage { ); } - if(file == null) return contentWidget; + if (file == null) return contentWidget; return Padding( - padding: const EdgeInsets.only(top: 5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CachedNetworkImage( - errorWidget: (context, url, error) => Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon(Icons.file_open_outlined, size: 35), - const SizedBox(width: 10), - Flexible(child: Text(file!.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.bold))), - const SizedBox(width: 10), - ], - ), - alignment: Alignment.center, - placeholder: (context, url) => const Padding(padding: EdgeInsets.all(15), child: SizedBox(width: 50, child: LinearProgressIndicator())), - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - errorListener: (value) {}, - httpHeaders: AccountData().authHeaders(), - imageUrl: 'https://${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1', + padding: const EdgeInsets.only(top: 5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CachedNetworkImage( + errorWidget: (context, url, error) => Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(Icons.file_open_outlined, size: 35), + const SizedBox(width: 10), + Flexible( + child: Text( + file!.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: 10), + ], ), - if(originalMessage != '{file}') ...[ - SizedBox(height: 5), - contentWidget - ] + alignment: Alignment.center, + placeholder: (context, url) => const Padding( + padding: EdgeInsets.all(15), + child: SizedBox(width: 50, child: LinearProgressIndicator()), + ), + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + errorListener: (value) {}, + httpHeaders: AccountData().authHeaders(), + imageUrl: + 'https://${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1', + ), + if (originalMessage != '{file}') ...[ + SizedBox(height: 5), + contentWidget, ], - ) + ], + ), ); } } diff --git a/lib/view/pages/talk/details/chat_info.dart b/lib/view/pages/talk/details/chat_info.dart index 820ee1a..fad96f5 100644 --- a/lib/view/pages/talk/details/chat_info.dart +++ b/lib/view/pages/talk/details/chat_info.dart @@ -28,18 +28,17 @@ class _ChatInfoState extends State { setState(() { participants = data; }); - } + }, ); super.initState(); } @override Widget build(BuildContext context) { - var isGroup = widget.room.type != GetRoomResponseObjectConversationType.oneToOne; + var isGroup = + widget.room.type != GetRoomResponseObjectConversationType.oneToOne; return Scaffold( - appBar: AppBar( - title: Text(widget.room.displayName), - ), + appBar: AppBar(title: Text(widget.room.displayName)), body: Center( child: Column( crossAxisAlignment: CrossAxisAlignment.center, @@ -52,23 +51,34 @@ class _ChatInfoState extends State { size: 80, ), onTap: () { - if(isGroup) return; - TalkNavigator.pushSplitView(context, LargeProfilePictureView(widget.room.name)); + if (isGroup) return; + TalkNavigator.pushSplitView( + context, + LargeProfilePictureView(widget.room.name), + ); }, ), const SizedBox(height: 30), - Text(widget.room.displayName, textAlign: TextAlign.center, style: const TextStyle(fontSize: 30)), - if(!isGroup) Text(widget.room.name), + Text( + widget.room.displayName, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 30), + ), + if (!isGroup) Text(widget.room.name), const SizedBox(height: 10), - if(isGroup) Text(widget.room.description, textAlign: TextAlign.center), + if (isGroup) + Text(widget.room.description, textAlign: TextAlign.center), const SizedBox(height: 30), - if(participants == null) const LoadingSpinner(), - if(participants != null) ...[ + if (participants == null) const LoadingSpinner(), + if (participants != null) ...[ ListTile( leading: const Icon(Icons.supervised_user_circle), title: Text('${participants!.data.length} Mitglieder'), trailing: const Icon(Icons.arrow_right), - onTap: () => TalkNavigator.pushSplitView(context, ParticipantsListView(participants!)), + onTap: () => TalkNavigator.pushSplitView( + context, + ParticipantsListView(participants!), + ), ), ], ], diff --git a/lib/view/pages/talk/details/message_reactions.dart b/lib/view/pages/talk/details/message_reactions.dart index 7f87faf..6a2a807 100644 --- a/lib/view/pages/talk/details/message_reactions.dart +++ b/lib/view/pages/talk/details/message_reactions.dart @@ -13,7 +13,11 @@ import '../../../../widget/user_avatar.dart'; class MessageReactions extends StatefulWidget { final String token; final int messageId; - const MessageReactions({super.key, required this.token, required this.messageId}); + const MessageReactions({ + super.key, + required this.token, + required this.messageId, + }); @override State createState() => _MessageReactionsState(); @@ -25,53 +29,67 @@ class _MessageReactionsState extends State { @override void initState() { super.initState(); - data = GetReactions(chatToken: widget.token, messageId: widget.messageId).run(); + data = GetReactions( + chatToken: widget.token, + messageId: widget.messageId, + ).run(); } @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Reaktionen'), - ), - body: FutureBuilder( - future: data, - builder: (context, snapshot) { - if(snapshot.connectionState == ConnectionState.waiting) return const LoadingSpinner(); - if(snapshot.data!.data.isEmpty) return const PlaceholderView(icon: Icons.search_off_outlined, text: 'Keine Reaktionen gefunden!'); - return ListView( - children: [ - ...snapshot.data!.data.entries.map((entry) => ExpansionTile( - textColor: Theme.of(context).colorScheme.onSurface, - collapsedTextColor: Theme.of(context).colorScheme.onSurface, - iconColor: Theme.of(context).colorScheme.onSurface, - collapsedIconColor: Theme.of(context).colorScheme.onSurface, + appBar: AppBar(title: const Text('Reaktionen')), + body: FutureBuilder( + future: data, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const LoadingSpinner(); + } + if (snapshot.data!.data.isEmpty) { + return const PlaceholderView( + icon: Icons.search_off_outlined, + text: 'Keine Reaktionen gefunden!', + ); + } + return ListView( + children: [ + ...snapshot.data!.data.entries.map( + (entry) => ExpansionTile( + textColor: Theme.of(context).colorScheme.onSurface, + collapsedTextColor: Theme.of(context).colorScheme.onSurface, + iconColor: Theme.of(context).colorScheme.onSurface, + collapsedIconColor: Theme.of(context).colorScheme.onSurface, - subtitle: const Text('Tippe für mehr'), - leading: CenteredLeading(Text(entry.key)), - title: Text('${entry.value.length} mal reagiert'), - children: entry.value.map((e) { - var isSelf = AccountData().getUsername() == e.actorId; - return ListTile( - leading: UserAvatar(id: e.actorId, isGroup: false), - title: Text(e.actorDisplayName), - subtitle: isSelf + subtitle: const Text('Tippe für mehr'), + leading: CenteredLeading(Text(entry.key)), + title: Text('${entry.value.length} mal reagiert'), + children: entry.value.map((e) { + var isSelf = AccountData().getUsername() == e.actorId; + return ListTile( + leading: UserAvatar(id: e.actorId, isGroup: false), + title: Text(e.actorDisplayName), + subtitle: isSelf ? const Text('Du') - : e.actorType == GetReactionsResponseObjectActorType.guests ? const Text('Gast') : null, - trailing: isSelf + : e.actorType == + GetReactionsResponseObjectActorType.guests + ? const Text('Gast') + : null, + trailing: isSelf ? null : Visibility( - visible: kReleaseMode, - child: IconButton( - onPressed: () => UnimplementedDialog.show(context), - icon: const Icon(Icons.textsms_outlined), + visible: kReleaseMode, + child: IconButton( + onPressed: () => + UnimplementedDialog.show(context), + icon: const Icon(Icons.textsms_outlined), + ), ), - ), - ); - }).toList(), - )) - ], - ); - }, - ), - ); + ); + }).toList(), + ), + ), + ], + ); + }, + ), + ); } diff --git a/lib/view/pages/talk/details/participants_list_view.dart b/lib/view/pages/talk/details/participants_list_view.dart index e2ec3d7..b3c4850 100644 --- a/lib/view/pages/talk/details/participants_list_view.dart +++ b/lib/view/pages/talk/details/participants_list_view.dart @@ -10,38 +10,46 @@ class ParticipantsListView extends StatelessWidget { @override Widget build(BuildContext context) { - String lastname(participant) => participant.displayName.toString().split(' ').last; - - final participants = participantsResponse.data - .sorted((a, b) { - final typeComparison = a.participantType.index.compareTo(b.participantType.index); - if (typeComparison != 0) return typeComparison; - return lastname(a).compareTo(lastname(b)); - }); - var groupedParticipants = participants.groupListsBy((participant) => participant.participantType); + String lastname(participant) => + participant.displayName.toString().split(' ').last; + + final participants = participantsResponse.data.sorted((a, b) { + final typeComparison = a.participantType.index.compareTo( + b.participantType.index, + ); + if (typeComparison != 0) return typeComparison; + return lastname(a).compareTo(lastname(b)); + }); + var groupedParticipants = participants.groupListsBy( + (participant) => participant.participantType, + ); return Scaffold( - appBar: AppBar( - title: const Text('Mitglieder'), - ), + appBar: AppBar(title: const Text('Mitglieder')), body: ListView( children: [ - ...groupedParticipants.entries.map((entry) => Column( - children: [ - ListTile( - title: Text(entry.key.prettyName), - titleTextStyle: Theme.of(context).textTheme.titleMedium - ), - ...entry.value.map((participant) => ListTile( - leading: UserAvatar(id: participant.actorId), - title: Text(participant.displayName), - subtitle: participant.statusMessage != null ? Text(participant.statusMessage!) : null, - )), - Divider(), - ], - )) + ...groupedParticipants.entries.map( + (entry) => Column( + children: [ + ListTile( + title: Text(entry.key.prettyName), + titleTextStyle: Theme.of(context).textTheme.titleMedium, + ), + ...entry.value.map( + (participant) => ListTile( + leading: UserAvatar(id: participant.actorId), + title: Text(participant.displayName), + subtitle: participant.statusMessage != null + ? Text(participant.statusMessage!) + : null, + ), + ), + Divider(), + ], + ), + ), ], - ) + ), ); } } diff --git a/lib/view/pages/talk/join_chat.dart b/lib/view/pages/talk/join_chat.dart index 56e99bb..51188f2 100644 --- a/lib/view/pages/talk/join_chat.dart +++ b/lib/view/pages/talk/join_chat.dart @@ -1,4 +1,3 @@ - import 'package:async/async.dart'; import 'package:flutter/material.dart'; @@ -14,10 +13,11 @@ class JoinChat extends SearchDelegate { @override List? buildActions(BuildContext context) => [ - if(future != null && query.isNotEmpty) FutureBuilder( + if (future != null && query.isNotEmpty) + FutureBuilder( future: future!.value, builder: (context, snapshot) { - if(snapshot.connectionState != ConnectionState.done) { + if (snapshot.connectionState != ConnectionState.done) { return const Padding( padding: EdgeInsets.all(10), child: Center(child: AppProgressIndicator.medium()), @@ -26,17 +26,18 @@ class JoinChat extends SearchDelegate { return const SizedBox.shrink(); }, ), - if(query.isNotEmpty) IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)), - ]; + if (query.isNotEmpty) + IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)), + ]; @override Widget? buildLeading(BuildContext context) => null; @override Widget buildResults(BuildContext context) { - if(future != null) future!.cancel(); + if (future != null) future!.cancel(); - if(query.isEmpty) { + if (query.isEmpty) { return const PlaceholderView( text: 'Suche nach benutzern', icon: Icons.person_search_outlined, @@ -47,13 +48,15 @@ class JoinChat extends SearchDelegate { return FutureBuilder( future: future!.value, builder: (context, snapshot) { - if(snapshot.hasData) { + if (snapshot.hasData) { return ListView.builder( itemCount: snapshot.data!.data.length, itemBuilder: (context, index) { var object = snapshot.data!.data[index]; var circleAvatar = CircleAvatar( - foregroundImage: Image.network('https://${EndpointData().nextcloud().full()}/avatar/${object.id}/128').image, + foregroundImage: Image.network( + 'https://${EndpointData().nextcloud().full()}/avatar/${object.id}/128', + ).image, backgroundColor: Theme.of(context).primaryColor, foregroundColor: Colors.white, child: const Icon(Icons.person), @@ -67,9 +70,9 @@ class JoinChat extends SearchDelegate { close(context, object.id); }, ); - } + }, ); - } else if(snapshot.hasError) { + } else if (snapshot.hasError) { return PlaceholderView( icon: Icons.search_off, text: errorToUserMessage(snapshot.error), @@ -83,5 +86,4 @@ class JoinChat extends SearchDelegate { @override Widget buildSuggestions(BuildContext context) => buildResults(context); - } diff --git a/lib/view/pages/talk/search_chat.dart b/lib/view/pages/talk/search_chat.dart index 0a58622..9f36bf7 100644 --- a/lib/view/pages/talk/search_chat.dart +++ b/lib/view/pages/talk/search_chat.dart @@ -10,17 +10,26 @@ class SearchChat extends SearchDelegate { @override List? buildActions(BuildContext context) => [ - if(query.isNotEmpty) IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)), - ]; + if (query.isNotEmpty) + IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)), + ]; @override Widget? buildLeading(BuildContext context) => null; @override Widget buildResults(BuildContext context) { - var items = chats.where( - (e) => e.displayName.toString().toLowerCase().contains(query.toLowerCase()) || e.name.toString().toLowerCase().contains(query.toLowerCase()) - ).toList()..sort((a, b) => b.lastActivity.compareTo(a.lastActivity)); + var items = + chats + .where( + (e) => + e.displayName.toString().toLowerCase().contains( + query.toLowerCase(), + ) || + e.name.toString().toLowerCase().contains(query.toLowerCase()), + ) + .toList() + ..sort((a, b) => b.lastActivity.compareTo(a.lastActivity)); return ListView.builder( itemCount: items.length, itemBuilder: (context, index) { diff --git a/lib/view/pages/talk/talk_navigator.dart b/lib/view/pages/talk/talk_navigator.dart index 18c33ab..a6a7e00 100644 --- a/lib/view/pages/talk/talk_navigator.dart +++ b/lib/view/pages/talk/talk_navigator.dart @@ -1,16 +1,23 @@ - import 'package:flutter/material.dart'; import 'package:flutter_split_view/flutter_split_view.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; class TalkNavigator { - static bool hasSplitViewState(BuildContext context) => context.findAncestorStateOfType() != null; - static bool isSecondaryVisible(BuildContext context) => hasSplitViewState(context) && SplitView.of(context).isSecondaryVisible; + static bool hasSplitViewState(BuildContext context) => + context.findAncestorStateOfType() != null; + static bool isSecondaryVisible(BuildContext context) => + hasSplitViewState(context) && SplitView.of(context).isSecondaryVisible; - static void pushSplitView(BuildContext context, Widget view, {bool overrideToSingleSubScreen = false}) { - if(isSecondaryVisible(context)) { + static void pushSplitView( + BuildContext context, + Widget view, { + bool overrideToSingleSubScreen = false, + }) { + if (isSecondaryVisible(context)) { var splitView = SplitView.of(context); - overrideToSingleSubScreen ? splitView.setSecondary(view) : splitView.push(view); + overrideToSingleSubScreen + ? splitView.setSecondary(view) + : splitView.push(view); } else { pushScreen(context, screen: view, withNavBar: false); } diff --git a/lib/view/pages/talk/widgets/answer_reference.dart b/lib/view/pages/talk/widgets/answer_reference.dart index 8171d14..6508ae5 100644 --- a/lib/view/pages/talk/widgets/answer_reference.dart +++ b/lib/view/pages/talk/widgets/answer_reference.dart @@ -8,7 +8,12 @@ class AnswerReference extends StatelessWidget { final BuildContext context; final GetChatResponseObject referenceMessage; final String? selfId; - const AnswerReference({required this.context, required this.referenceMessage, required this.selfId, super.key}); + const AnswerReference({ + required this.context, + required this.referenceMessage, + required this.selfId, + super.key, + }); @override Widget build(BuildContext context) { @@ -16,15 +21,25 @@ class AnswerReference extends StatelessWidget { return DecoratedBox( decoration: BoxDecoration( color: referenceMessage.actorId == selfId - ? style.getSelfStyle(false).color!.withGreen(200).withValues(alpha: 0.2) - : style.getRemoteStyle(false).color!.withWhite(200).withValues(alpha: 0.2), + ? style + .getSelfStyle(false) + .color! + .withGreen(200) + .withValues(alpha: 0.2) + : style + .getRemoteStyle(false) + .color! + .withWhite(200) + .withValues(alpha: 0.2), borderRadius: const BorderRadius.all(Radius.circular(5)), - border: Border(left: BorderSide( + border: Border( + left: BorderSide( color: referenceMessage.actorId == selfId ? style.getSelfStyle(false).color!.withGreen(200) : style.getRemoteStyle(false).color!.withWhite(200), - width: 5 - )), + width: 5, + ), + ), ), child: Padding( padding: const EdgeInsets.all(5).add(const EdgeInsets.only(left: 5)), @@ -43,7 +58,10 @@ class AnswerReference extends StatelessWidget { ), ), Text( - RichObjectStringProcessor.parseToString(referenceMessage.message, referenceMessage.messageParameters), + RichObjectStringProcessor.parseToString( + referenceMessage.message, + referenceMessage.messageParameters, + ), maxLines: 2, style: TextStyle( overflow: TextOverflow.ellipsis, diff --git a/lib/view/pages/talk/widgets/bubble.dart b/lib/view/pages/talk/widgets/bubble.dart index bd87a2a..30d1fac 100644 --- a/lib/view/pages/talk/widgets/bubble.dart +++ b/lib/view/pages/talk/widgets/bubble.dart @@ -3,12 +3,17 @@ 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.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; + : top = value, + bottom = value, + left = value, + right = value; final double top; final double bottom; @@ -53,9 +58,19 @@ class Bubble extends StatelessWidget { final flat = Radius.zero; switch (style.nip) { case BubbleNip.leftTop: - return BorderRadius.only(topLeft: flat, topRight: r, bottomLeft: r, bottomRight: r); + return BorderRadius.only( + topLeft: flat, + topRight: r, + bottomLeft: r, + bottomRight: r, + ); case BubbleNip.rightBottom: - return BorderRadius.only(topLeft: r, topRight: r, bottomLeft: r, bottomRight: flat); + return BorderRadius.only( + topLeft: r, + topRight: r, + bottomLeft: r, + bottomRight: flat, + ); case BubbleNip.none: return BorderRadius.all(r); } @@ -72,10 +87,19 @@ class Bubble extends StatelessWidget { color: style.color, borderRadius: radius, border: style.borderWidth > 0 - ? Border.all(color: Theme.of(context).dividerColor, width: style.borderWidth) + ? Border.all( + color: Theme.of(context).dividerColor, + width: style.borderWidth, + ) : null, boxShadow: style.elevation > 0 - ? [BoxShadow(color: Colors.black26, blurRadius: style.elevation * 2, offset: Offset(0, style.elevation))] + ? [ + BoxShadow( + color: Colors.black26, + blurRadius: style.elevation * 2, + offset: Offset(0, style.elevation), + ), + ] : null, ), padding: style.padding.toEdgeInsets(), diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index 8958be9..7e50a9e 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -40,13 +40,15 @@ class ChatBubble extends StatefulWidget { required this.refetch, this.isRead = false, this.selfId, - super.key}); + super.key, + }); @override State createState() => _ChatBubbleState(); } -class _ChatBubbleState extends State with SingleTickerProviderStateMixin { +class _ChatBubbleState extends State + with SingleTickerProviderStateMixin { late ChatMessage message; DownloadJob? _job; @@ -109,7 +111,10 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM final file = message.file; final filePath = file?.path; if (file == null || filePath == null) return; - final job = await DownloadManager.instance.start(remotePath: filePath, name: file.name); + final job = await DownloadManager.instance.start( + remotePath: filePath, + name: file.name, + ); if (!mounted) return; if (_job == job) return; _detachJob(); @@ -129,19 +134,22 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM BubbleStyle _getStyle() { final styles = ChatBubbleStyles(context); - if (widget.bubbleData.messageType != GetRoomResponseObjectMessageType.comment) { + if (widget.bubbleData.messageType != + GetRoomResponseObjectMessageType.comment) { return styles.getSystemStyle(); } - return widget.isSender ? styles.getSelfStyle(false) : styles.getRemoteStyle(false); + return widget.isSender + ? styles.getSelfStyle(false) + : styles.getRemoteStyle(false); } void _showOptionsDialog() => showChatMessageOptionsDialog( - context, - chatData: widget.chatData, - bubbleData: widget.bubbleData, - isSender: widget.isSender, - onRefetch: widget.refetch, - ); + context, + chatData: widget.chatData, + bubbleData: widget.bubbleData, + isSender: widget.isSender, + onRefetch: widget.refetch, + ); void _onTap() { final obj = message.originalData?['object']; @@ -165,24 +173,40 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM @override Widget build(BuildContext context) { - message = ChatMessage(originalMessage: widget.bubbleData.message, originalData: widget.bubbleData.messageParameters); - final showActorDisplayName = widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment - && widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne; - final showBubbleTime = widget.bubbleData.messageType != GetRoomResponseObjectMessageType.system - && widget.bubbleData.messageType != GetRoomResponseObjectMessageType.deletedComment; + message = ChatMessage( + originalMessage: widget.bubbleData.message, + originalData: widget.bubbleData.messageParameters, + ); + final showActorDisplayName = + widget.bubbleData.messageType == + GetRoomResponseObjectMessageType.comment && + widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne; + final showBubbleTime = + widget.bubbleData.messageType != + GetRoomResponseObjectMessageType.system && + widget.bubbleData.messageType != + GetRoomResponseObjectMessageType.deletedComment; final parent = widget.bubbleData.parent; final actorText = Text( widget.bubbleData.actorDisplayName, textAlign: TextAlign.start, overflow: TextOverflow.ellipsis, - style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold), + style: TextStyle( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + ), ); final timeText = Text( - DateTime.fromMillisecondsSinceEpoch(widget.bubbleData.timestamp * 1000).formatHm(), + DateTime.fromMillisecondsSinceEpoch( + widget.bubbleData.timestamp * 1000, + ).formatHm(), textAlign: TextAlign.end, - style: TextStyle(color: widget.timeIconColor, fontSize: widget.timeIconSize), + style: TextStyle( + color: widget.timeIconColor, + fontSize: widget.timeIconSize, + ), ); return Column( @@ -206,7 +230,9 @@ class _ChatBubbleState extends State with SingleTickerProviderStateM final isAction = _position.dx.abs() > 50; setState(() => _position = Offset.zero); if (widget.bubbleData.isReplyable && isAction) { - context.read().setReferenceMessageId(widget.bubbleData.id); + context.read().setReferenceMessageId( + widget.bubbleData.id, + ); } }, onLongPress: _showOptionsDialog, @@ -281,67 +307,68 @@ class _BubbleContent extends StatelessWidget { @override Widget build(BuildContext context) => Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.9, - minWidth: showActorDisplayName - ? actorText.size.width - : timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3, + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.9, + minWidth: showActorDisplayName + ? actorText.size.width + : timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3, + ), + child: Stack( + children: [ + if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorText), + Padding( + padding: EdgeInsets.only( + bottom: showBubbleTime ? 18 : 0, + top: showActorDisplayName ? 18 : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (parent != null && + bubbleData.messageType == + GetRoomResponseObjectMessageType.comment) ...[ + AnswerReference( + context: context, + referenceMessage: parent!, + selfId: selfId, + ), + const SizedBox(height: 5), + ], + messageWidget, + ], + ), ), - child: Stack( - children: [ - if (showActorDisplayName) - Positioned(top: 0, left: 0, child: actorText), - Padding( - padding: EdgeInsets.only( - bottom: showBubbleTime ? 18 : 0, - top: showActorDisplayName ? 18 : 0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (parent != null && bubbleData.messageType == GetRoomResponseObjectMessageType.comment) ...[ - AnswerReference( - context: context, - referenceMessage: parent!, - selfId: selfId, - ), - const SizedBox(height: 5), - ], - messageWidget, + if (showBubbleTime) + Positioned( + bottom: 0, + right: 0, + child: Row( + children: [ + timeText, + if (isSender) ...[ + SizedBox(width: spacing), + Icon( + isRead ? Icons.done_all_outlined : Icons.done_outlined, + size: timeIconSize, + color: timeIconColor, + ), ], - ), + ], ), - if (showBubbleTime) - Positioned( - bottom: 0, - right: 0, - child: Row( - children: [ - timeText, - if (isSender) ...[ - SizedBox(width: spacing), - Icon( - isRead ? Icons.done_all_outlined : Icons.done_outlined, - size: timeIconSize, - color: timeIconColor, - ), - ], - ], - ), - ), - if (downloadJob?.status.value is DownloadInProgress) - Positioned( - bottom: 0, - right: 0, - left: 0, - child: LinearProgressIndicator( - value: () { - final s = downloadJob!.status.value as DownloadInProgress; - return s.percent <= 0 ? null : s.percent / 100; - }(), - ), - ), - ], - ), - ); + ), + if (downloadJob?.status.value is DownloadInProgress) + Positioned( + bottom: 0, + right: 0, + left: 0, + child: LinearProgressIndicator( + value: () { + final s = downloadJob!.status.value as DownloadInProgress; + return s.percent <= 0 ? null : s.percent / 100; + }(), + ), + ), + ], + ), + ); } diff --git a/lib/view/pages/talk/widgets/chat_bubble_poll.dart b/lib/view/pages/talk/widgets/chat_bubble_poll.dart index 96c25ed..2f5b3a6 100644 --- a/lib/view/pages/talk/widgets/chat_bubble_poll.dart +++ b/lib/view/pages/talk/widgets/chat_bubble_poll.dart @@ -22,14 +22,14 @@ void showChatBubblePollDialog( future: pollState, builder: (_, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { - return const Column(mainAxisSize: MainAxisSize.min, children: [LoadingSpinner()]); + return const Column( + mainAxisSize: MainAxisSize.min, + children: [LoadingSpinner()], + ); } final pollData = snapshot.data!.data; return SingleChildScrollView( - child: PollOptionsList( - pollData: pollData, - chatToken: chatToken, - ), + child: PollOptionsList(pollData: pollData, chatToken: chatToken), ); }, ), diff --git a/lib/view/pages/talk/widgets/chat_bubble_reactions.dart b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart index 9761474..02e80ac 100644 --- a/lib/view/pages/talk/widgets/chat_bubble_reactions.dart +++ b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart @@ -37,14 +37,20 @@ class ChatBubbleReactions extends StatelessWidget { alignment: isSender ? WrapAlignment.end : WrapAlignment.start, crossAxisAlignment: WrapCrossAlignment.start, children: reactions.entries.map((e) { - final hasSelfReacted = bubbleData.reactionsSelf?.contains(e.key) ?? false; + final hasSelfReacted = + bubbleData.reactionsSelf?.contains(e.key) ?? false; return Container( margin: const EdgeInsets.only(right: 2.5, left: 2.5), child: ActionChip( label: Text('${e.key} ${e.value}'), - visualDensity: const VisualDensity(vertical: VisualDensity.minimumDensity, horizontal: VisualDensity.minimumDensity), + visualDensity: const VisualDensity( + vertical: VisualDensity.minimumDensity, + horizontal: VisualDensity.minimumDensity, + ), padding: EdgeInsets.zero, - backgroundColor: hasSelfReacted ? Theme.of(context).primaryColor : null, + backgroundColor: hasSelfReacted + ? Theme.of(context).primaryColor + : null, onPressed: () { runWithErrorDialog(context, () async { if (hasSelfReacted) { 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 6816d71..227490b 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -29,11 +29,13 @@ void showChatMessageOptionsDialog( required void Function({bool renew}) onRefetch, }) { final parentContext = context; - final canReact = bubbleData.messageType == GetRoomResponseObjectMessageType.comment; - final canDelete = isSender && - DateTime.fromMillisecondsSinceEpoch(bubbleData.timestamp * 1000) - .add(const Duration(hours: 6)) - .isAfter(DateTime.now()); + final canReact = + bubbleData.messageType == GetRoomResponseObjectMessageType.comment; + final canDelete = + isSender && + DateTime.fromMillisecondsSinceEpoch( + bubbleData.timestamp * 1000, + ).add(const Duration(hours: 6)).isAfter(DateTime.now()); showDetailsBottomSheet( context, @@ -61,7 +63,11 @@ void showChatMessageOptionsDialog( onTap: () { Navigator.of(sheetCtx).pop(); if (!parentContext.mounted) return; - AppRoutes.openMessageReactions(parentContext, chatData.token, bubbleData.id); + AppRoutes.openMessageReactions( + parentContext, + chatData.token, + bubbleData.id, + ); }, ), if (bubbleData.message != '{file}') @@ -73,7 +79,9 @@ void showChatMessageOptionsDialog( Navigator.of(sheetCtx).pop(); }, ), - if (!kReleaseMode && !isSender && chatData.type != GetRoomResponseObjectConversationType.oneToOne) + if (!kReleaseMode && + !isSender && + chatData.type != GetRoomResponseObjectConversationType.oneToOne) ListTile( leading: const Icon(Icons.sms_outlined), title: Text("Private Nachricht an '${bubbleData.actorDisplayName}'"), @@ -136,54 +144,57 @@ class _ReactionsRowState extends State<_ReactionsRow> { @override Widget build(BuildContext context) => AnimatedBuilder( - animation: _controller, - builder: (context, _) { - final busy = _controller.busy; - final err = _controller.error; - return Column( - mainAxisSize: MainAxisSize.min, + animation: _controller, + builder: (context, _) { + final busy = _controller.busy; + final err = _controller.error; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + alignment: WrapAlignment.center, children: [ - Wrap( - alignment: WrapAlignment.center, - children: [ - ..._commonReactions.map( - (emoji) => TextButton( - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(40, 40), - ), - onPressed: busy ? null : () => _react(emoji), - child: Text(emoji), - ), - ), - IconButton( - onPressed: busy ? null : () => _showEmojiPicker(context), - style: IconButton.styleFrom( - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(40, 40), - ), - icon: busy - ? const AppProgressIndicator.small() - : const Icon(Icons.add_circle_outline_outlined), - ), - ], - ), - if (err != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Text( - err, - textAlign: TextAlign.center, - style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12), + ..._commonReactions.map( + (emoji) => TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(40, 40), ), + onPressed: busy ? null : () => _react(emoji), + child: Text(emoji), ), - const Divider(), + ), + IconButton( + onPressed: busy ? null : () => _showEmojiPicker(context), + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(40, 40), + ), + icon: busy + ? const AppProgressIndicator.small() + : const Icon(Icons.add_circle_outline_outlined), + ), ], - ); - }, + ), + if (err != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Text( + err, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, + ), + ), + ), + const Divider(), + ], ); + }, + ); void _showEmojiPicker(BuildContext rowContext) { showDialog( @@ -214,7 +225,9 @@ class _ReactionsRowState extends State<_ReactionsRow> { noRecents: const Text('Keine zuletzt verwendeten Emojis'), columns: 7, ), - bottomActionBarConfig: const emojis.BottomActionBarConfig(enabled: false), + bottomActionBarConfig: const emojis.BottomActionBarConfig( + enabled: false, + ), categoryViewConfig: emojis.CategoryViewConfig( backgroundColor: Theme.of(pickerCtx).hoverColor, iconColorSelected: Theme.of(pickerCtx).primaryColor, diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index 8cea791..f4067b8 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -39,13 +39,17 @@ class _ChatTextfieldState extends State { void share(String shareFolder, List filePaths) { for (final element in filePaths) { final fileName = element.split(Platform.pathSeparator).last; - FileSharingApi().share(FileSharingApiParams( - shareType: 10, - shareWith: widget.sendToToken, - path: '$shareFolder/$fileName', - )).then((_) { - if (mounted) context.read().refresh(); - }); + FileSharingApi() + .share( + FileSharingApiParams( + shareType: 10, + shareWith: widget.sendToToken, + path: '$shareFolder/$fileName', + ), + ) + .then((_) { + if (mounted) context.read().refresh(); + }); } } @@ -53,19 +57,25 @@ class _ChatTextfieldState extends State { if (paths == null) return; const shareFolder = 'MarianumMobile'; - unawaited(WebdavApi.webdav.then((webdav) => webdav.mkcol(PathUri.parse('/$shareFolder')))); + unawaited( + WebdavApi.webdav.then( + (webdav) => webdav.mkcol(PathUri.parse('/$shareFolder')), + ), + ); if (!mounted) return; - unawaited(pushScreen( - context, - withNavBar: false, - screen: FilesUploadDialog( - filePaths: paths, - remotePath: shareFolder, - onUploadFinished: (uploaded) => share(shareFolder, uploaded), - uniqueNames: true, + unawaited( + pushScreen( + context, + withNavBar: false, + screen: FilesUploadDialog( + filePaths: paths, + remotePath: shareFolder, + onUploadFinished: (uploaded) => share(shareFolder, uploaded), + uniqueNames: true, + ), ), - )); + ); } void _setDraft(String text) { @@ -82,7 +92,9 @@ class _ChatTextfieldState extends State { if (messageId != null) { talkSettings.draftReplies[widget.sendToToken] = messageId; } else { - talkSettings.draftReplies.removeWhere((key, _) => key == widget.sendToToken); + talkSettings.draftReplies.removeWhere( + (key, _) => key == widget.sendToToken, + ); } } @@ -90,7 +102,10 @@ class _ChatTextfieldState extends State { void initState() { super.initState(); settings = context.read(); - final draftReply = settings.val().talkSettings.draftReplies[widget.sendToToken]; + final draftReply = settings + .val() + .talkSettings + .draftReplies[widget.sendToToken]; if (draftReply != null) { context.read().setReferenceMessageId(draftReply); } @@ -121,16 +136,19 @@ class _ChatTextfieldState extends State { @override Widget build(BuildContext context) { - _textBoxController.text = settings.val().talkSettings.drafts[widget.sendToToken] ?? ''; + _textBoxController.text = + settings.val().talkSettings.drafts[widget.sendToToken] ?? ''; final chatBloc = context.watch(); final chatState = chatBloc.state.data; Widget replyBanner = const SizedBox.shrink(); - if (chatState != null && chatState.referenceMessageId != null && chatState.chatResponse != null) { + if (chatState != null && + chatState.referenceMessageId != null && + chatState.chatResponse != null) { try { - final referenceMessage = chatState.chatResponse!.sortByTimestamp().firstWhere( - (e) => e.id == chatState.referenceMessageId, - ); + final referenceMessage = chatState.chatResponse! + .sortByTimestamp() + .firstWhere((e) => e.id == chatState.referenceMessageId); replyBanner = Row( children: [ Expanded( @@ -150,120 +168,150 @@ class _ChatTextfieldState extends State { ), ], ); - } catch (_) {/* reference no longer in current chat data */} + } catch (_) { + /* reference no longer in current chat data */ + } } - return Stack(children: [ - Align( - alignment: Alignment.bottomLeft, - child: Container( - padding: const EdgeInsets.only(left: 10, bottom: 3, top: 3, right: 10), - width: double.infinity, - child: Column( - children: [ - replyBanner, - if (_sendError != null) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text( - _sendError!, - style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12), - ), - ), - Row(children: [ - GestureDetector( - onTap: () { - showDetailsBottomSheet( - context, - children: (sheetCtx) => [ - ListTile( - leading: const Icon(Icons.file_open), - title: const Text('Aus Dateien auswählen'), - onTap: () { - FilePick.documentPick().then(mediaUpload); - Navigator.of(sheetCtx).pop(); - }, - ), - ListTile( - leading: const Icon(Icons.image), - title: const Text('Aus Galerie auswählen'), - onTap: () { - FilePick.multipleGalleryPick().then((value) { - if (value != null) mediaUpload(value.map((e) => e.path).toList()); - }); - Navigator.of(sheetCtx).pop(); - }, - ), - ListTile( - leading: const Icon(Icons.camera_alt_outlined), - title: const Text('Foto aufnehmen'), - onTap: () { - FilePick.cameraPick().then((image) { - if (image != null) mediaUpload([image.path]); - }); - Navigator.of(sheetCtx).pop(); - }, - ), - ], - ); - }, - child: Material( - elevation: 5, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)), - child: Container( - height: 30, - width: 30, - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - borderRadius: BorderRadius.circular(30), + return Stack( + children: [ + Align( + alignment: Alignment.bottomLeft, + child: Container( + padding: const EdgeInsets.only( + left: 10, + bottom: 3, + top: 3, + right: 10, + ), + width: double.infinity, + child: Column( + children: [ + replyBanner, + if (_sendError != null) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + _sendError!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, ), - child: const Icon(Icons.attach_file_outlined, color: Colors.white, size: 20), ), ), - ), - const SizedBox(width: 15), - Expanded( - child: TextField( - autocorrect: true, - textCapitalization: TextCapitalization.sentences, - controller: _textBoxController, - maxLines: 7, - minLines: 1, - decoration: const InputDecoration( - hintText: 'Nachricht schreiben...', - border: InputBorder.none, + Row( + children: [ + GestureDetector( + onTap: () { + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + ListTile( + leading: const Icon(Icons.file_open), + title: const Text('Aus Dateien auswählen'), + onTap: () { + FilePick.documentPick().then(mediaUpload); + Navigator.of(sheetCtx).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.image), + title: const Text('Aus Galerie auswählen'), + onTap: () { + FilePick.multipleGalleryPick().then((value) { + if (value != null) { + mediaUpload( + value.map((e) => e.path).toList(), + ); + } + }); + Navigator.of(sheetCtx).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.camera_alt_outlined), + title: const Text('Foto aufnehmen'), + onTap: () { + FilePick.cameraPick().then((image) { + if (image != null) mediaUpload([image.path]); + }); + Navigator.of(sheetCtx).pop(); + }, + ), + ], + ); + }, + child: Material( + elevation: 5, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + child: Container( + height: 30, + width: 30, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(30), + ), + child: const Icon( + Icons.attach_file_outlined, + color: Colors.white, + size: 20, + ), + ), + ), ), - onChanged: (text) { - if (text.trim().toLowerCase() == 'marbot marbot marbot') { - const newText = 'Roboter sind cool und so, aber Marbots sind besser!'; - _textBoxController.text = newText; - text = newText; - } - _setDraft(text); - }, - onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context), - ), + const SizedBox(width: 15), + Expanded( + child: TextField( + autocorrect: true, + textCapitalization: TextCapitalization.sentences, + controller: _textBoxController, + maxLines: 7, + minLines: 1, + decoration: const InputDecoration( + hintText: 'Nachricht schreiben...', + border: InputBorder.none, + ), + onChanged: (text) { + if (text.trim().toLowerCase() == + 'marbot marbot marbot') { + const newText = + 'Roboter sind cool und so, aber Marbots sind besser!'; + _textBoxController.text = newText; + text = newText; + } + _setDraft(text); + }, + onTapOutside: (_) => + FocusBehaviour.textFieldTapOutside(context), + ), + ), + const SizedBox(width: 15), + ValueListenableBuilder( + valueListenable: _textBoxController, + builder: (context, value, _) => AsyncFab( + mini: true, + heroTag: 'chatSend_${widget.sendToToken}', + icon: Icons.send, + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Colors.white, + controller: _sendController, + onPressed: value.text.trim().isEmpty + ? null + : () => _sendMessage(chatBloc), + onError: (message) => + setState(() => _sendError = message), + onSuccess: () => setState(() => _sendError = null), + ), + ), + ], ), - const SizedBox(width: 15), - ValueListenableBuilder( - valueListenable: _textBoxController, - builder: (context, value, _) => AsyncFab( - mini: true, - heroTag: 'chatSend_${widget.sendToToken}', - icon: Icons.send, - backgroundColor: Theme.of(context).primaryColor, - foregroundColor: Colors.white, - controller: _sendController, - onPressed: value.text.trim().isEmpty ? null : () => _sendMessage(chatBloc), - onError: (message) => setState(() => _sendError = message), - onSuccess: () => setState(() => _sendError = null), - ), - ), - ]), - ], + ], + ), ), ), - ), - ]); + ], + ); } } diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index f6a5cab..ddb0127 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -25,7 +25,12 @@ class ChatTile extends StatefulWidget { final bool disableContextActions; final bool hasDraft; - const ChatTile({super.key, required this.data, this.disableContextActions = false, this.hasDraft = false}); + const ChatTile({ + super.key, + required this.data, + this.disableContextActions = false, + this.hasDraft = false, + }); @override State createState() => _ChatTileState(); @@ -39,7 +44,11 @@ class _ChatTileState extends State { super.initState(); AccountData().waitForPopulation().then((_) { if (!mounted) return; - setState(() => selfUsername = AccountData().isPopulated() ? AccountData().getUsername() : null); + setState( + () => selfUsername = AccountData().isPopulated() + ? AccountData().getUsername() + : null, + ); }); } @@ -49,7 +58,9 @@ class _ChatTileState extends State { await SetReadMarker( widget.data.token, true, - setReadMarkerParams: SetReadMarkerParams(lastReadMessage: widget.data.lastMessage.id), + setReadMarkerParams: SetReadMarkerParams( + lastReadMessage: widget.data.lastMessage.id, + ), ).run(); if (!mounted) return; _refreshList(); @@ -58,12 +69,18 @@ class _ChatTileState extends State { @override Widget build(BuildContext context) { final chatBloc = context.watch(); - final isGroup = widget.data.type != GetRoomResponseObjectConversationType.oneToOne; - final circleAvatar = UserAvatar(id: isGroup ? widget.data.token : widget.data.name, isGroup: isGroup); + final isGroup = + widget.data.type != GetRoomResponseObjectConversationType.oneToOne; + final circleAvatar = UserAvatar( + id: isGroup ? widget.data.token : widget.data.name, + isGroup: isGroup, + ); return ListTile( style: ListTileStyle.list, - tileColor: chatBloc.state.data?.currentToken == widget.data.token && TalkNavigator.isSecondaryVisible(context) + tileColor: + chatBloc.state.data?.currentToken == widget.data.token && + TalkNavigator.isSecondaryVisible(context) ? Theme.of(context).primaryColor.withAlpha(100) : null, leading: Stack( @@ -80,16 +97,25 @@ class _ChatTileState extends State { color: Theme.of(context).primaryColor.withAlpha(200), borderRadius: BorderRadius.circular(90.0), ), - child: const Icon(Icons.star, color: Colors.amberAccent, size: 15), + child: const Icon( + Icons.star, + color: Colors.amberAccent, + size: 15, + ), ), ), - ) + ), ], ), title: Row( mainAxisSize: MainAxisSize.min, children: [ - Flexible(child: Text(widget.data.displayName, overflow: TextOverflow.ellipsis)), + Flexible( + child: Text( + widget.data.displayName, + overflow: TextOverflow.ellipsis, + ), + ), if (widget.hasDraft) ...[ const SizedBox(width: 5), const Icon(Icons.edit_outlined, size: 15), @@ -119,8 +145,16 @@ class _ChatTileState extends State { onTap: () { if (selfUsername == null) return; unawaited(_setCurrentAsRead()); - final view = ChatView(room: widget.data, selfId: selfUsername!, avatar: circleAvatar); - TalkNavigator.pushSplitView(context, view, overrideToSingleSubScreen: true); + final view = ChatView( + room: widget.data, + selfId: selfUsername!, + avatar: circleAvatar, + ); + TalkNavigator.pushSplitView( + context, + view, + overrideToSingleSubScreen: true, + ); context.read().setToken(widget.data.token); }, onLongPress: () { @@ -168,7 +202,8 @@ class _ChatTileState extends State { Navigator.of(sheetCtx).pop(); ConfirmDialog( title: 'Chat verlassen', - content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.', + content: + 'Du benötigst ggf. eine Einladung um erneut beizutreten.', confirmButton: 'Verlassen', onConfirmAsync: () async { await LeaveRoom(widget.data.token).run(); diff --git a/lib/view/pages/talk/widgets/poll_options_list.dart b/lib/view/pages/talk/widgets/poll_options_list.dart index a02a320..637b153 100644 --- a/lib/view/pages/talk/widgets/poll_options_list.dart +++ b/lib/view/pages/talk/widgets/poll_options_list.dart @@ -1,4 +1,3 @@ - import 'package:flutter/material.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; @@ -8,7 +7,11 @@ import '../../../../utils/url_opener.dart'; class PollOptionsList extends StatefulWidget { final GetPollStateResponseObject pollData; final String chatToken; - const PollOptionsList({super.key, required this.pollData, required this.chatToken}); + const PollOptionsList({ + super.key, + required this.pollData, + required this.chatToken, + }); @override State createState() => _PollOptionsListState(); @@ -23,44 +26,48 @@ class _PollOptionsListState extends State { var votedSelf = widget.pollData.votedSelf.contains(optionId); var portionsVisible = widget.pollData.votes is Map; var votes = portionsVisible - ? (widget.pollData.votes['option-$optionId'] as num?) ?? 0 - : 0; + ? (widget.pollData.votes['option-$optionId'] as num?) ?? 0 + : 0; var numVoters = widget.pollData.numVoters ?? 0; final portion = numVoters == 0 ? 0.0 : (votes / numVoters); return ListTile( isThreeLine: portionsVisible, dense: true, - title: Text( - option, - style: Theme.of(context).textTheme.bodyLarge, - ), + title: Text(option, style: Theme.of(context).textTheme.bodyLarge), leading: Icon( votedSelf ? Icons.check_circle_outlined : Icons.circle_outlined, color: votedSelf ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.6) - : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + : Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), ), - subtitle: portionsVisible ? Row( - children: [ - Expanded( - child: LinearProgressIndicator(value: portion.clamp(0.0, 1.0)), - ), - Container( - margin: const EdgeInsets.only(left: 10), - child: Text('${(portion * 100).round()}%'), - ), - ], - ) : null, + subtitle: portionsVisible + ? Row( + children: [ + Expanded( + child: LinearProgressIndicator( + value: portion.clamp(0.0, 1.0), + ), + ), + Container( + margin: const EdgeInsets.only(left: 10), + child: Text('${(portion * 100).round()}%'), + ), + ], + ) + : null, ); }), ListTile( title: Linkify( - text: 'Wenn du abstimmen möchtest, verwende die Webversion unter https://cloud.marianum-fulda.de/call/${widget.chatToken}', + text: + 'Wenn du abstimmen möchtest, verwende die Webversion unter https://cloud.marianum-fulda.de/call/${widget.chatToken}', onOpen: UrlOpener.onOpen, style: Theme.of(context).textTheme.bodySmall, ), - ) + ), ], ); } diff --git a/lib/view/pages/talk/widgets/split_view_placeholder.dart b/lib/view/pages/talk/widgets/split_view_placeholder.dart index 590a1ec..d5b12e5 100644 --- a/lib/view/pages/talk/widgets/split_view_placeholder.dart +++ b/lib/view/pages/talk/widgets/split_view_placeholder.dart @@ -7,21 +7,25 @@ class SplitViewPlaceholder extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - MediaQuery( - data: MediaQuery.of(context).copyWith( - invertColors: !AppTheme.isDarkMode(context), - ), - child: Image.asset('assets/logo/icon.png', height: 200), - ), - const SizedBox(height: 30), - const Text('Marianum Fulda\nTalk', textAlign: TextAlign.center, style: TextStyle(fontSize: 30)), - ], - ), - ) - ); + appBar: AppBar(), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(invertColors: !AppTheme.isDarkMode(context)), + child: Image.asset('assets/logo/icon.png', height: 200), + ), + const SizedBox(height: 30), + const Text( + 'Marianum Fulda\nTalk', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 30), + ), + ], + ), + ), + ); } diff --git a/lib/view/pages/timetable/custom_events/custom_event_colors.dart b/lib/view/pages/timetable/custom_events/custom_event_colors.dart index 04f623a..3a74984 100644 --- a/lib/view/pages/timetable/custom_events/custom_event_colors.dart +++ b/lib/view/pages/timetable/custom_events/custom_event_colors.dart @@ -5,7 +5,8 @@ import '../../../../theming/dark_app_theme.dart'; enum CustomTimetableColors { orange, red, green, blue } class TimetableColors { - static const CustomTimetableColors defaultColor = CustomTimetableColors.orange; + static const CustomTimetableColors defaultColor = + CustomTimetableColors.orange; static ColorModeDisplay getDisplayOptions(CustomTimetableColors color) { switch (color) { @@ -14,17 +15,24 @@ class TimetableColors { case CustomTimetableColors.blue: return ColorModeDisplay(color: Colors.blue, displayName: 'Blau'); case CustomTimetableColors.orange: - return ColorModeDisplay(color: Colors.orange.shade800, displayName: 'Orange'); + return ColorModeDisplay( + color: Colors.orange.shade800, + displayName: 'Orange', + ); case CustomTimetableColors.red: - return ColorModeDisplay(color: DarkAppTheme.marianumRed, displayName: 'Rot'); + return ColorModeDisplay( + color: DarkAppTheme.marianumRed, + displayName: 'Rot', + ); } } - static Color getColorFromString(String color) => - getDisplayOptions(CustomTimetableColors.values.firstWhere( - (e) => e.name == color, - orElse: () => defaultColor, - )).color; + static Color getColorFromString(String color) => getDisplayOptions( + CustomTimetableColors.values.firstWhere( + (e) => e.name == color, + orElse: () => defaultColor, + ), + ).color; } class ColorModeDisplay { 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 db1d06b..8dd64f2 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 @@ -42,7 +42,8 @@ class _CustomEventEditDialogState extends State { static const TimeOfDay _defaultEnd = TimeOfDay(hour: 9, minute: 30); static const int _minDurationMinutes = 15; - late DateTime _date = widget.existingEvent?.startDate ?? widget.initialStart ?? DateTime.now(); + late DateTime _date = + widget.existingEvent?.startDate ?? widget.initialStart ?? DateTime.now(); late TimeOfDay _startTime; late TimeOfDay _endTime; late bool _isAllDay; @@ -85,13 +86,18 @@ class _CustomEventEditDialogState extends State { _endTime = clamped.$2; } - static (TimeOfDay, TimeOfDay) _clampToVisibleWindow(TimeOfDay rawStart, TimeOfDay rawEnd) { + static (TimeOfDay, TimeOfDay) _clampToVisibleWindow( + TimeOfDay rawStart, + TimeOfDay rawEnd, + ) { int toMin(TimeOfDay t) => t.hour * 60 + t.minute; TimeOfDay fromMin(int m) => TimeOfDay(hour: m ~/ 60, minute: m % 60); final windowStart = toMin(_windowStart); final windowEnd = toMin(_windowEnd); - var start = toMin(rawStart).clamp(windowStart, windowEnd - _minDurationMinutes); + var start = toMin( + rawStart, + ).clamp(windowStart, windowEnd - _minDurationMinutes); var end = toMin(rawEnd); if (end < start + _minDurationMinutes) end = start + _minDurationMinutes; if (end > windowEnd) { @@ -165,10 +171,7 @@ class _CustomEventEditDialogState extends State { context: context, start: _startTime, end: _endTime, - disabledTime: TimeRange( - startTime: _windowEnd, - endTime: _windowStart, - ), + disabledTime: TimeRange(startTime: _windowEnd, endTime: _windowStart), disabledColor: Colors.grey, paintingStyle: PaintingStyle.fill, interval: const Duration(minutes: 5), @@ -188,103 +191,118 @@ class _CustomEventEditDialogState extends State { @override Widget build(BuildContext context) => AlertDialog( - insetPadding: const EdgeInsets.all(20), - contentPadding: const EdgeInsets.all(10), - title: Text(_isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: TextField( - controller: _name, - autofocus: true, - decoration: const InputDecoration(labelText: 'Terminname', border: OutlineInputBorder()), - onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context), - ), + insetPadding: const EdgeInsets.all(20), + contentPadding: const EdgeInsets.all(10), + title: Text(_isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: TextField( + controller: _name, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Terminname', + border: OutlineInputBorder(), ), - ListTile( - title: TextField( - controller: _description, - maxLines: 2, - minLines: 2, - decoration: const InputDecoration(labelText: 'Beschreibung', border: OutlineInputBorder()), - onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context), - ), - ), - const Divider(), - ListTile( - leading: const Icon(Icons.date_range_outlined), - title: Text(Jiffy.parseFromDateTime(_date).yMMMd), - subtitle: const Text('Datum'), - onTap: _pickDate, - ), - SwitchListTile( - secondary: const Icon(Icons.today_outlined), - title: const Text('Ganztägig'), - value: _isAllDay, - onChanged: (v) => setState(() => _isAllDay = v), - ), - if (!_isAllDay) - ListTile( - leading: const Icon(Icons.access_time_outlined), - title: Text('${_startTime.format(context)} - ${_endTime.format(context)}'), - subtitle: const Text('Zeitraum'), - onTap: _pickTimeRange, - ), - const Divider(), - ListTile( - leading: const Icon(Icons.color_lens_outlined), - title: const Text('Farbgebung'), - trailing: DropdownButton( - value: _color, - icon: const Icon(Icons.arrow_drop_down), - items: CustomTimetableColors.values - .map((e) => DropdownMenuItem( - value: e, - enabled: e != _color, - child: Row( - children: [ - Icon(Icons.circle, color: TimetableColors.getDisplayOptions(e).color), - const SizedBox(width: 10), - Text(TimetableColors.getDisplayOptions(e).displayName), - ], - ), - )) - .toList(), - onChanged: (e) => setState(() => _color = e!), - ), - ), - const Divider(), - RRuleGenerator( - config: RRuleGeneratorConfig( - selectDayStyle: RRuleSelectDayStyle( - dayStyle: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context).colorScheme.secondary, - ), - dayTextStyle: const TextStyle(color: Colors.black), - selectedDayStyle: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context).primaryColor, - ), - ), - ), - initialRRule: _rrule, - locale: RRuleLocale.de_DE, - onChange: (newValue) { - log('Rule: $newValue'); - setState(() => _rrule = newValue); - }, - ), - ], + onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context), + ), ), - ), - actions: [ - AsyncDialogAction( - confirmLabel: _isEditing ? 'Speichern' : 'Erstellen', - onConfirm: _save, + ListTile( + title: TextField( + controller: _description, + maxLines: 2, + minLines: 2, + decoration: const InputDecoration( + labelText: 'Beschreibung', + border: OutlineInputBorder(), + ), + onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context), + ), + ), + const Divider(), + ListTile( + leading: const Icon(Icons.date_range_outlined), + title: Text(Jiffy.parseFromDateTime(_date).yMMMd), + subtitle: const Text('Datum'), + onTap: _pickDate, + ), + SwitchListTile( + secondary: const Icon(Icons.today_outlined), + title: const Text('Ganztägig'), + value: _isAllDay, + onChanged: (v) => setState(() => _isAllDay = v), + ), + if (!_isAllDay) + ListTile( + leading: const Icon(Icons.access_time_outlined), + title: Text( + '${_startTime.format(context)} - ${_endTime.format(context)}', + ), + subtitle: const Text('Zeitraum'), + onTap: _pickTimeRange, + ), + const Divider(), + ListTile( + leading: const Icon(Icons.color_lens_outlined), + title: const Text('Farbgebung'), + trailing: DropdownButton( + value: _color, + icon: const Icon(Icons.arrow_drop_down), + items: CustomTimetableColors.values + .map( + (e) => DropdownMenuItem( + value: e, + enabled: e != _color, + child: Row( + children: [ + Icon( + Icons.circle, + color: TimetableColors.getDisplayOptions(e).color, + ), + const SizedBox(width: 10), + Text( + TimetableColors.getDisplayOptions(e).displayName, + ), + ], + ), + ), + ) + .toList(), + onChanged: (e) => setState(() => _color = e!), + ), + ), + const Divider(), + RRuleGenerator( + config: RRuleGeneratorConfig( + selectDayStyle: RRuleSelectDayStyle( + dayStyle: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.secondary, + ), + dayTextStyle: const TextStyle(color: Colors.black), + selectedDayStyle: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).primaryColor, + ), + ), + ), + initialRRule: _rrule, + locale: RRuleLocale.de_DE, + onChange: (newValue) { + log('Rule: $newValue'); + setState(() => _rrule = newValue); + }, ), ], - ); + ), + ), + actions: [ + AsyncDialogAction( + confirmLabel: _isEditing ? 'Speichern' : 'Erstellen', + onConfirm: _save, + ), + ], + ); } diff --git a/lib/view/pages/timetable/custom_events/custom_events_view.dart b/lib/view/pages/timetable/custom_events/custom_events_view.dart index 83d0b24..24263f7 100644 --- a/lib/view/pages/timetable/custom_events/custom_events_view.dart +++ b/lib/view/pages/timetable/custom_events/custom_events_view.dart @@ -22,57 +22,69 @@ class CustomEventsView extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Eigene Termine'), - actions: [ - IconButton( - icon: const Icon(Icons.add), + appBar: AppBar( + title: const Text('Eigene Termine'), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () => _openCreateDialog(context), + ), + ], + ), + body: LoadableStateConsumer( + child: (state, _) { + final events = state.customEvents?.events ?? const []; + + if (events.isEmpty) { + return PlaceholderView( + icon: Icons.calendar_today_outlined, + text: 'Keine Einträge vorhanden', + button: TextButton( onPressed: () => _openCreateDialog(context), + child: const Text('Termin erstellen'), ), - ], - ), - body: LoadableStateConsumer( - child: (state, _) { - final events = state.customEvents?.events ?? const []; + ); + } - if (events.isEmpty) { - return PlaceholderView( - icon: Icons.calendar_today_outlined, - text: 'Keine Einträge vorhanden', - button: TextButton( - onPressed: () => _openCreateDialog(context), - child: const Text('Termin erstellen'), + return ListView( + children: events + .map( + (e) => ListTile( + title: Text(e.title), + subtitle: Text( + '${e.rrule.isNotEmpty ? "wiederholend, " : ""}' + 'beginnend ${e.startDate.formatRelative()}', + ), + leading: CenteredLeading( + Icon( + e.rrule.isEmpty + ? Icons.event_outlined + : Icons.event_repeat_outlined, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: () => showDialog( + context: context, + builder: (_) => + CustomEventEditDialog(existingEvent: e), + ), + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => + showDeleteCustomEventDialog(context, e), + ), + ], + ), ), - ); - } - - return ListView( - children: events.map((e) => ListTile( - title: Text(e.title), - subtitle: Text( - '${e.rrule.isNotEmpty ? "wiederholend, " : ""}' - 'beginnend ${e.startDate.formatRelative()}', - ), - leading: CenteredLeading(Icon(e.rrule.isEmpty ? Icons.event_outlined : Icons.event_repeat_outlined)), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit_outlined), - onPressed: () => showDialog( - context: context, - builder: (_) => CustomEventEditDialog(existingEvent: e), - ), - ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: () => showDeleteCustomEventDialog(context, e), - ), - ], - ), - )).toList(), - ); - }, - ), - ); + ) + .toList(), + ); + }, + ), + ); } diff --git a/lib/view/pages/timetable/data/arbitrary_appointment.dart b/lib/view/pages/timetable/data/arbitrary_appointment.dart index 6d2320a..1f8dd82 100644 --- a/lib/view/pages/timetable/data/arbitrary_appointment.dart +++ b/lib/view/pages/timetable/data/arbitrary_appointment.dart @@ -8,9 +8,9 @@ sealed class ArbitraryAppointment { required T Function(GetTimetableResponseObject lesson) webuntis, required T Function(CustomTimetableEvent event) custom, }) => switch (this) { - WebuntisAppointment(:final lesson) => webuntis(lesson), - CustomAppointment(:final event) => custom(event), - }; + WebuntisAppointment(:final lesson) => webuntis(lesson), + CustomAppointment(:final event) => custom(event), + }; } class WebuntisAppointment extends ArbitraryAppointment { diff --git a/lib/view/pages/timetable/data/calendar_logic.dart b/lib/view/pages/timetable/data/calendar_logic.dart index e16faa9..aeead8b 100644 --- a/lib/view/pages/timetable/data/calendar_logic.dart +++ b/lib/view/pages/timetable/data/calendar_logic.dart @@ -43,24 +43,28 @@ List expandRegionsForDay(List regions, DateTime day) { final result = []; final dayStart = DateTime(day.year, day.month, day.day); for (final region in regions) { - final isRecurringDaily = region.recurrenceRule != null && + final isRecurringDaily = + region.recurrenceRule != null && region.recurrenceRule!.toUpperCase().contains('FREQ=DAILY'); if (isRecurringDaily) { - final start = dayStart.add(Duration( - hours: region.startTime.hour, - minutes: region.startTime.minute, - )); - final end = dayStart.add(Duration( - hours: region.endTime.hour, - minutes: region.endTime.minute, - )); + final start = dayStart.add( + Duration( + hours: region.startTime.hour, + minutes: region.startTime.minute, + ), + ); + final end = dayStart.add( + Duration(hours: region.endTime.hour, minutes: region.endTime.minute), + ); result.add(BoundRegion(region: region, start: start, end: end)); } else if (region.startTime.isSameDay(day)) { - result.add(BoundRegion( - region: region, - start: region.startTime, - end: region.endTime, - )); + result.add( + BoundRegion( + region: region, + start: region.startTime, + end: region.endTime, + ), + ); } } return result; @@ -73,8 +77,10 @@ List expandRegionsForDay(List regions, DateTime day) { /// [kCalendarStartHour] or end after [kCalendarEndHour]). The outside bucket /// is rendered as chips above the grid. ({List> inside, List> outside}) - partitionAppointmentsForWeek( - List appointments, DateTime weekStart) { +partitionAppointmentsForWeek( + List appointments, + DateTime weekStart, +) { final inside = List>.generate(5, (_) => []); final outside = List>.generate(5, (_) => []); final weekEnd = weekStart.add(const Duration(days: 5)); @@ -104,12 +110,19 @@ List expandRegionsForDay(List regions, DateTime day) { if (!occUtc.isBefore(weekEndUtc)) break; if (occUtc.isBefore(weekStartUtc)) continue; final occLocal = occUtc.toLocal(); - final idx = DateTime(occLocal.year, occLocal.month, occLocal.day) - .difference(weekStart) - .inDays; + final idx = DateTime( + occLocal.year, + occLocal.month, + occLocal.day, + ).difference(weekStart).inDays; if (idx < 0 || idx >= 5) continue; - final newStart = DateTime(occLocal.year, occLocal.month, occLocal.day, - a.startTime.hour, a.startTime.minute); + final newStart = DateTime( + occLocal.year, + occLocal.month, + occLocal.day, + a.startTime.hour, + a.startTime.minute, + ); place( idx, Appointment( @@ -150,8 +163,7 @@ class PeriodLayout { double _h(LessonPeriod p) => p.isBreak ? breakHeight : lessonHeight; - double get totalHeight => - periods.fold(0, (sum, p) => sum + _h(p)); + double get totalHeight => periods.fold(0, (sum, p) => sum + _h(p)); double topOf(LessonPeriod period) { var y = 0.0; @@ -241,7 +253,13 @@ class LaidOutOverflow extends LaidOutCell { final DateTime startTime; @override final DateTime endTime; - LaidOutOverflow(this.appointments, this.lane, this.laneCount, this.startTime, this.endTime); + LaidOutOverflow( + this.appointments, + this.lane, + this.laneCount, + this.startTime, + this.endTime, + ); } /// Horizontal ordering rank for parallel appointments. Lower = further left. @@ -269,17 +287,21 @@ int _appointmentPriority(Appointment a) { /// is free at its `startTime`. When no lane is free, open a new one. /// 3. A cluster ends as soon as every active lane's end is at or before the /// next appointment's start. -List assignLanes(List appts, {required int maxLanes}) { +List assignLanes( + List appts, { + required int maxLanes, +}) { assert(maxLanes >= 2, 'maxLanes must reserve at least one slot for overflow'); if (appts.isEmpty) return const []; - final sorted = [...appts]..sort((a, b) { - final c = a.startTime.compareTo(b.startTime); - if (c != 0) return c; - final p = _appointmentPriority(a).compareTo(_appointmentPriority(b)); - if (p != 0) return p; - return b.endTime.compareTo(a.endTime); - }); + final sorted = [...appts] + ..sort((a, b) { + final c = a.startTime.compareTo(b.startTime); + if (c != 0) return c; + final p = _appointmentPriority(a).compareTo(_appointmentPriority(b)); + if (p != 0) return p; + return b.endTime.compareTo(a.endTime); + }); // Phase 1: greedy lane assignment, grouped by cluster. final clusters = >[]; @@ -288,7 +310,8 @@ List assignLanes(List appts, {required int maxLanes}) for (final apt in sorted) { final allFree = - laneEnds.isNotEmpty && laneEnds.every((end) => !end.isAfter(apt.startTime)); + laneEnds.isNotEmpty && + laneEnds.every((end) => !end.isAfter(apt.startTime)); if (allFree) { clusters.add(current); current = <({Appointment apt, int lane})>[]; @@ -316,8 +339,10 @@ List assignLanes(List appts, {required int maxLanes}) // Phase 2: emit cells per cluster, collapsing if too wide. final result = []; for (final cluster in clusters) { - final laneCount = - cluster.fold(0, (m, e) => e.lane + 1 > m ? e.lane + 1 : m); + final laneCount = cluster.fold( + 0, + (m, e) => e.lane + 1 > m ? e.lane + 1 : m, + ); if (laneCount <= maxLanes) { for (final entry in cluster) { @@ -348,8 +373,9 @@ List assignLanes(List appts, {required int maxLanes}) if (a.startTime.isBefore(earliest)) earliest = a.startTime; if (a.endTime.isAfter(latest)) latest = a.endTime; } - result.add(LaidOutOverflow( - overflow, maxLanes - 1, maxLanes, earliest, latest)); + result.add( + LaidOutOverflow(overflow, maxLanes - 1, maxLanes, earliest, latest), + ); } } return result; diff --git a/lib/view/pages/timetable/data/lesson_period_schedule.dart b/lib/view/pages/timetable/data/lesson_period_schedule.dart index 01b3f47..dfd9b5a 100644 --- a/lib/view/pages/timetable/data/lesson_period_schedule.dart +++ b/lib/view/pages/timetable/data/lesson_period_schedule.dart @@ -17,8 +17,8 @@ class LessonPeriod { }); Duration get duration => Duration( - minutes: (end.hour * 60 + end.minute) - (start.hour * 60 + start.minute), - ); + minutes: (end.hour * 60 + end.minute) - (start.hour * 60 + start.minute), + ); int get _startMinutes => start.hour * 60 + start.minute; } @@ -31,39 +31,94 @@ class LessonPeriodSchedule { static LessonPeriodSchedule? fromApi(GetTimegridUnitsResponse response) { final canonical = response.result.firstWhere( (d) => d.day == 1, - orElse: () => response.result.isNotEmpty ? response.result.first : GetTimegridUnitsResponseDay(0, []), + orElse: () => response.result.isNotEmpty + ? response.result.first + : GetTimegridUnitsResponseDay(0, []), ); if (canonical.timeUnits.isEmpty) return null; - final periods = canonical.timeUnits - .map((u) => LessonPeriod( - name: u.name, - start: _fromHHMM(u.startTime), - end: _fromHHMM(u.endTime), - )) - .toList() - ..sort((a, b) => a._startMinutes.compareTo(b._startMinutes)); + final periods = + canonical.timeUnits + .map( + (u) => LessonPeriod( + name: u.name, + start: _fromHHMM(u.startTime), + end: _fromHHMM(u.endTime), + ), + ) + .toList() + ..sort((a, b) => a._startMinutes.compareTo(b._startMinutes)); return LessonPeriodSchedule(periods); } static LessonPeriodSchedule fallback() => const LessonPeriodSchedule([ - LessonPeriod(name: '0', start: TimeOfDay(hour: 7, minute: 10), end: TimeOfDay(hour: 7, minute: 53)), - LessonPeriod(name: '1', start: TimeOfDay(hour: 7, minute: 55), end: TimeOfDay(hour: 8, minute: 40)), - LessonPeriod(name: '2', start: TimeOfDay(hour: 8, minute: 40), end: TimeOfDay(hour: 9, minute: 25)), - LessonPeriod(name: '3', start: TimeOfDay(hour: 9, minute: 30), end: TimeOfDay(hour: 10, minute: 15)), - LessonPeriod(name: '4', start: TimeOfDay(hour: 10, minute: 35), end: TimeOfDay(hour: 11, minute: 20)), - LessonPeriod(name: '5', start: TimeOfDay(hour: 11, minute: 25), end: TimeOfDay(hour: 12, minute: 10)), - LessonPeriod(name: '6', start: TimeOfDay(hour: 12, minute: 15), end: TimeOfDay(hour: 13, minute: 0)), - LessonPeriod(name: '7', start: TimeOfDay(hour: 13, minute: 5), end: TimeOfDay(hour: 13, minute: 50)), - LessonPeriod(name: '8', start: TimeOfDay(hour: 14, minute: 5), end: TimeOfDay(hour: 14, minute: 50)), - LessonPeriod(name: '9', start: TimeOfDay(hour: 14, minute: 50), end: TimeOfDay(hour: 15, minute: 35)), - LessonPeriod(name: '10', start: TimeOfDay(hour: 15, minute: 40), end: TimeOfDay(hour: 16, minute: 25)), - LessonPeriod(name: '11', start: TimeOfDay(hour: 16, minute: 25), end: TimeOfDay(hour: 17, minute: 10)), - ]); + LessonPeriod( + name: '0', + start: TimeOfDay(hour: 7, minute: 10), + end: TimeOfDay(hour: 7, minute: 53), + ), + LessonPeriod( + name: '1', + start: TimeOfDay(hour: 7, minute: 55), + end: TimeOfDay(hour: 8, minute: 40), + ), + LessonPeriod( + name: '2', + start: TimeOfDay(hour: 8, minute: 40), + end: TimeOfDay(hour: 9, minute: 25), + ), + LessonPeriod( + name: '3', + start: TimeOfDay(hour: 9, minute: 30), + end: TimeOfDay(hour: 10, minute: 15), + ), + LessonPeriod( + name: '4', + start: TimeOfDay(hour: 10, minute: 35), + end: TimeOfDay(hour: 11, minute: 20), + ), + LessonPeriod( + name: '5', + start: TimeOfDay(hour: 11, minute: 25), + end: TimeOfDay(hour: 12, minute: 10), + ), + LessonPeriod( + name: '6', + start: TimeOfDay(hour: 12, minute: 15), + end: TimeOfDay(hour: 13, minute: 0), + ), + LessonPeriod( + name: '7', + start: TimeOfDay(hour: 13, minute: 5), + end: TimeOfDay(hour: 13, minute: 50), + ), + LessonPeriod( + name: '8', + start: TimeOfDay(hour: 14, minute: 5), + end: TimeOfDay(hour: 14, minute: 50), + ), + LessonPeriod( + name: '9', + start: TimeOfDay(hour: 14, minute: 50), + end: TimeOfDay(hour: 15, minute: 35), + ), + LessonPeriod( + name: '10', + start: TimeOfDay(hour: 15, minute: 40), + end: TimeOfDay(hour: 16, minute: 25), + ), + LessonPeriod( + name: '11', + start: TimeOfDay(hour: 16, minute: 25), + end: TimeOfDay(hour: 17, minute: 10), + ), + ]); static LessonPeriodSchedule fromState(TimetableState state) { - final fromApi = state.timegrid != null ? LessonPeriodSchedule.fromApi(state.timegrid!) : null; + final fromApi = state.timegrid != null + ? LessonPeriodSchedule.fromApi(state.timegrid!) + : null; return (fromApi ?? fallback()).withSyntheticBreaks(); } @@ -74,21 +129,22 @@ class LessonPeriodSchedule { result.add(current); if (i + 1 >= periods.length) continue; final next = periods[i + 1]; - final gapMinutes = next._startMinutes - (current.end.hour * 60 + current.end.minute); + final gapMinutes = + next._startMinutes - (current.end.hour * 60 + current.end.minute); if (gapMinutes >= 10) { - result.add(LessonPeriod( - name: 'Pause', - start: current.end, - end: next.start, - isBreak: true, - )); + result.add( + LessonPeriod( + name: 'Pause', + start: current.end, + end: next.start, + isBreak: true, + ), + ); } } return LessonPeriodSchedule(result); } - static TimeOfDay _fromHHMM(int hhmm) => TimeOfDay( - hour: hhmm ~/ 100, - minute: hhmm % 100, - ); + static TimeOfDay _fromHHMM(int hhmm) => + TimeOfDay(hour: hhmm ~/ 100, minute: hhmm % 100); } diff --git a/lib/view/pages/timetable/data/lesson_status.dart b/lib/view/pages/timetable/data/lesson_status.dart index 90e24d0..6f63937 100644 --- a/lib/view/pages/timetable/data/lesson_status.dart +++ b/lib/view/pages/timetable/data/lesson_status.dart @@ -20,10 +20,17 @@ class LessonStatusClassifier { }) { if (lesson.code == 'cancelled') return LessonStatus.cancelled; if (isEvent) return LessonStatus.event; - if (lesson.code == 'irregular' || (lesson.te.isNotEmpty && lesson.te.first.id == 0)) return LessonStatus.irregular; - if (lesson.te.any((t) => t.orgname != null)) return LessonStatus.teacherChanged; + if (lesson.code == 'irregular' || + (lesson.te.isNotEmpty && lesson.te.first.id == 0)) { + return LessonStatus.irregular; + } + if (lesson.te.any((t) => t.orgname != null)) { + return LessonStatus.teacherChanged; + } if (endTime.isBefore(now)) return LessonStatus.past; - if (startTime.isBefore(now) && endTime.isAfter(now)) return LessonStatus.ongoing; + if (startTime.isBefore(now) && endTime.isAfter(now)) { + return LessonStatus.ongoing; + } return LessonStatus.regular; } } diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index cdcb733..cbce1a1 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -31,7 +31,9 @@ class TimetableAppointmentFactory { }); List build() { - final source = settings.connectDoubleLessons ? _mergeAdjacentLessons(lessons) : lessons; + final source = settings.connectDoubleLessons + ? _mergeAdjacentLessons(lessons) + : lessons; return [ ...source.map(_lessonToAppointment), ...customEvents.map(_customEventToAppointment), @@ -42,7 +44,9 @@ class TimetableAppointmentFactory { try { final startTime = WebuntisTime.parse(lesson.date, lesson.startTime); final endTime = WebuntisTime.parse(lesson.date, lesson.endTime); - final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id); + final subject = subjects.result.firstWhereOrNull( + (s) => s.id == lesson.su.firstOrNull?.id, + ); final status = LessonStatusClassifier.classify( lesson, startTime, @@ -81,16 +85,26 @@ class TimetableAppointmentFactory { id: CustomAppointment(event), startTime: event.startDate, endTime: allDay - ? DateTime(event.startDate.year, event.startDate.month, event.startDate.day, 23, 59) + ? DateTime( + event.startDate.year, + event.startDate.month, + event.startDate.day, + 23, + 59, + ) : event.endDate, isAllDay: allDay, // Preserve user-entered newlines in descriptions; the tile soft-wraps to // fill the available height. For lessons we still collapse whitespace // so room/teacher stay on one line each. - location: event.description.trim().isEmpty ? null : event.description.trim(), + location: event.description.trim().isEmpty + ? null + : event.description.trim(), subject: _collapseWhitespace(event.title) ?? event.title, recurrenceRule: event.rrule, - color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name), + color: TimetableColors.getColorFromString( + event.color ?? TimetableColors.defaultColor.name, + ), startTimeZone: '', endTimeZone: '', ); @@ -114,7 +128,10 @@ class TimetableAppointmentFactory { e.second == 0; } - String _subjectName(GetTimetableResponseObject lesson, GetSubjectsResponseObject? subject) { + String _subjectName( + GetTimetableResponseObject lesson, + GetSubjectsResponseObject? subject, + ) { if (subject == null) return 'Event'; final name = switch (settings.timetableNameMode) { TimetableNameMode.name => subject.name, @@ -125,10 +142,15 @@ class TimetableAppointmentFactory { } String _locationLabel(GetTimetableResponseObject lesson) { - final roomName = _collapseWhitespace( - rooms.result.firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id)?.name) ?? + final roomName = + _collapseWhitespace( + rooms.result + .firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id) + ?.name, + ) ?? 'Unbekannt'; - final teacherName = _collapseWhitespace(lesson.te.firstOrNull?.longname) ?? 'Unbekannt'; + final teacherName = + _collapseWhitespace(lesson.te.firstOrNull?.longname) ?? 'Unbekannt'; return '$roomName\n$teacherName'; } @@ -161,8 +183,13 @@ class TimetableAppointmentFactory { }) { if (input.isEmpty) return const []; - final sorted = [...input]..sort((a, b) => - WebuntisTime.parse(a.date, a.startTime).compareTo(WebuntisTime.parse(b.date, b.startTime))); + final sorted = [...input] + ..sort( + (a, b) => WebuntisTime.parse( + a.date, + a.startTime, + ).compareTo(WebuntisTime.parse(b.date, b.startTime)), + ); final merged = []; for (final current in sorted) { @@ -180,10 +207,16 @@ class TimetableAppointmentFactory { static GetTimetableResponseObject _copyLesson(GetTimetableResponseObject l) => GetTimetableResponseObject.fromJson(l.toJson()); - static bool _canMerge(GetTimetableResponseObject a, GetTimetableResponseObject b, Duration maxGap) { + static bool _canMerge( + GetTimetableResponseObject a, + GetTimetableResponseObject b, + Duration maxGap, + ) { final aSubject = a.su.firstOrNull?.id; final bSubject = b.su.firstOrNull?.id; - if (aSubject == null || bSubject == null || aSubject != bSubject) return false; + if (aSubject == null || bSubject == null || aSubject != bSubject) { + return false; + } if (a.ro.firstOrNull?.id != b.ro.firstOrNull?.id) return false; if (a.te.firstOrNull?.id != b.te.firstOrNull?.id) return false; if (a.code != b.code) return false; @@ -193,7 +226,10 @@ class TimetableAppointmentFactory { // overlap in time would silently collapse into one — and because the // merge sets `previous.endTime = current.endTime`, an overlapping merge // can even truncate the earlier lesson. - final gap = WebuntisTime.parse(b.date, b.startTime).difference(WebuntisTime.parse(a.date, a.endTime)); + final gap = WebuntisTime.parse( + b.date, + b.startTime, + ).difference(WebuntisTime.parse(a.date, a.endTime)); return !gap.isNegative && gap <= maxGap; } } diff --git a/lib/view/pages/timetable/data/timetable_name_mode.dart b/lib/view/pages/timetable/data/timetable_name_mode.dart index 7e534a9..39e3e24 100644 --- a/lib/view/pages/timetable/data/timetable_name_mode.dart +++ b/lib/view/pages/timetable/data/timetable_name_mode.dart @@ -8,11 +8,20 @@ class TimetableNameModes { static DropdownDisplay getDisplayOptions(TimetableNameMode mode) { switch (mode) { case TimetableNameMode.name: - return DropdownDisplay(icon: Icons.device_unknown_outlined, displayName: 'Name'); + return DropdownDisplay( + icon: Icons.device_unknown_outlined, + displayName: 'Name', + ); case TimetableNameMode.longName: - return DropdownDisplay(icon: Icons.perm_device_info_outlined, displayName: 'Langname'); + return DropdownDisplay( + icon: Icons.perm_device_info_outlined, + displayName: 'Langname', + ); case TimetableNameMode.alternateName: - return DropdownDisplay(icon: Icons.on_device_training_outlined, displayName: 'Kurzform'); + return DropdownDisplay( + icon: Icons.on_device_training_outlined, + displayName: 'Kurzform', + ); } } } diff --git a/lib/view/pages/timetable/data/webuntis_time.dart b/lib/view/pages/timetable/data/webuntis_time.dart index bd9b6fa..da9ff04 100644 --- a/lib/view/pages/timetable/data/webuntis_time.dart +++ b/lib/view/pages/timetable/data/webuntis_time.dart @@ -5,7 +5,9 @@ class WebuntisTime { static DateTime parse(int date, int time) { final timeString = time.toString().padLeft(4, '0'); - return DateTime.parse('$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}'); + return DateTime.parse( + '$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}', + ); } static int formatDate(DateTime date) => int.parse(_dateFormat.format(date)); diff --git a/lib/view/pages/timetable/details/appointment_details_dispatcher.dart b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart index 4a6ae76..f1ce427 100644 --- a/lib/view/pages/timetable/details/appointment_details_dispatcher.dart +++ b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart @@ -7,12 +7,17 @@ import 'custom_event_sheet.dart'; import 'webuntis_lesson_sheet.dart'; class AppointmentDetailsDispatcher { - static void show(BuildContext context, TimetableBloc bloc, Appointment appointment) { + static void show( + BuildContext context, + TimetableBloc bloc, + Appointment appointment, + ) { final id = appointment.id; if (id is! ArbitraryAppointment) return; id.when( - webuntis: (lesson) => WebuntisLessonSheet.show(context, bloc, appointment, lesson), + webuntis: (lesson) => + WebuntisLessonSheet.show(context, bloc, appointment, lesson), custom: (event) => CustomEventSheet.show(context, event), ); } diff --git a/lib/view/pages/timetable/details/custom_event_sheet.dart b/lib/view/pages/timetable/details/custom_event_sheet.dart index 1615a9e..dc7b6d5 100644 --- a/lib/view/pages/timetable/details/custom_event_sheet.dart +++ b/lib/view/pages/timetable/details/custom_event_sheet.dart @@ -17,7 +17,10 @@ class CustomEventSheet { context, header: ListTile( leading: const Icon(Icons.event_outlined, size: 32), - title: Text(event.title, style: const TextStyle(fontWeight: FontWeight.bold)), + title: Text( + event.title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), subtitle: Text(timeRange), ), children: (sheetCtx) => [ @@ -31,7 +34,8 @@ class CustomEventSheet { Navigator.of(sheetCtx).pop(); showDialog( context: context, - builder: (_) => CustomEventEditDialog(existingEvent: event), + builder: (_) => + CustomEventEditDialog(existingEvent: event), ); }, label: const Text('Bearbeiten'), @@ -39,7 +43,9 @@ class CustomEventSheet { ), TextButton.icon( onPressed: () { - showDeleteCustomEventDialog(context, event).future.then((_) { + showDeleteCustomEventDialog(context, event).future.then(( + _, + ) { if (!sheetCtx.mounted) return; Navigator.of(sheetCtx).pop(); }); @@ -54,18 +60,28 @@ class CustomEventSheet { const Divider(height: 1), ListTile( leading: const Icon(Icons.info_outline), - title: Text(event.description.isEmpty ? 'Keine Beschreibung' : event.description), + title: Text( + event.description.isEmpty + ? 'Keine Beschreibung' + : event.description, + ), ), ListTile( leading: const CenteredLeading(Icon(Icons.repeat_outlined)), - title: Text('Serie: ${event.rrule.isNotEmpty ? "Wiederholend" : "Einmalig"}'), + title: Text( + 'Serie: ${event.rrule.isNotEmpty ? "Wiederholend" : "Einmalig"}', + ), subtitle: FutureBuilder( future: RruleL10nEn.create(), builder: (_, snapshot) { - if (event.rrule.isEmpty) return const Text('Keine weiteren Vorkommnisse'); + if (event.rrule.isEmpty) { + return const Text('Keine weiteren Vorkommnisse'); + } if (snapshot.data == null) return const Text('...'); final rrule = RecurrenceRule.fromString(event.rrule); - if (!rrule.canFullyConvertToText) return const Text('Keine genauere Angabe möglich.'); + if (!rrule.canFullyConvertToText) { + return const Text('Keine genauere Angabe möglich.'); + } return Text(rrule.toText(l10n: snapshot.data!)); }, ), diff --git a/lib/view/pages/timetable/details/delete_custom_event.dart b/lib/view/pages/timetable/details/delete_custom_event.dart index 1ada219..33d186c 100644 --- a/lib/view/pages/timetable/details/delete_custom_event.dart +++ b/lib/view/pages/timetable/details/delete_custom_event.dart @@ -7,12 +7,16 @@ import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart' import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../widget/confirm_dialog.dart'; -Completer showDeleteCustomEventDialog(BuildContext context, CustomTimetableEvent event) { +Completer showDeleteCustomEventDialog( + BuildContext context, + CustomTimetableEvent event, +) { final completer = Completer(); final bloc = context.read(); ConfirmDialog( title: 'Termin löschen', - content: 'Der ${event.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.', + content: + 'Der ${event.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.', confirmButton: 'Löschen', onConfirmAsync: () async { await bloc.removeCustomEvent(event.id); diff --git a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart index afd3230..a5cd101 100644 --- a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart +++ b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart @@ -14,13 +14,30 @@ import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/unimplemented_dialog.dart'; class WebuntisLessonSheet { - static void show(BuildContext context, TimetableBloc bloc, Appointment appointment, GetTimetableResponseObject lesson) { + static void show( + BuildContext context, + TimetableBloc bloc, + Appointment appointment, + GetTimetableResponseObject lesson, + ) { final state = bloc.state.data; if (state == null) return; - final headerSubject = LessonResolver.resolveSubject(state, lesson.su.firstOrNull?.id); - final headerTitle = firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']); - final headerLongName = headerSubject.longName.isNotEmpty && headerSubject.longName != headerTitle ? headerSubject.longName : ''; + final headerSubject = LessonResolver.resolveSubject( + state, + lesson.su.firstOrNull?.id, + ); + final headerTitle = firstNonEmpty([ + headerSubject.alternateName, + headerSubject.name, + headerSubject.longName, + '?', + ]); + final headerLongName = + headerSubject.longName.isNotEmpty && + headerSubject.longName != headerTitle + ? headerSubject.longName + : ''; final timeRange = appointment.startTime.timeRangeTo(appointment.endTime); @@ -32,9 +49,9 @@ class WebuntisLessonSheet { '${LessonFormatter.codePrefix(lesson.code)}$headerTitle', style: const TextStyle(fontWeight: FontWeight.bold), ), - subtitle: Text(headerLongName.isNotEmpty - ? '$timeRange\n$headerLongName' - : timeRange), + subtitle: Text( + headerLongName.isNotEmpty ? '$timeRange\n$headerLongName' : timeRange, + ), isThreeLine: headerLongName.isNotEmpty, ), children: (_) => [ @@ -66,10 +83,12 @@ class WebuntisLessonSheet { icon: Icons.people, label: lesson.kl.length == 1 ? 'Klasse' : 'Klassen', entries: lesson.kl - .map((k) => LessonFormatter.formatLine( - k.name.isNotEmpty ? k.name : '?', - longname: k.longname, - )) + .map( + (k) => LessonFormatter.formatLine( + k.name.isNotEmpty ? k.name : '?', + longname: k.longname, + ), + ) .toList(), ), ..._optionalTextTiles(lesson), @@ -78,7 +97,11 @@ class WebuntisLessonSheet { ); } - static Widget _roomTile(BuildContext context, TimetableState state, GetTimetableResponseObject lesson) { + static Widget _roomTile( + BuildContext context, + TimetableState state, + GetTimetableResponseObject lesson, + ) { final trailing = IconButton( icon: const Icon(Icons.house_outlined), onPressed: () => AppRoutes.openRoomplan(context), @@ -112,7 +135,10 @@ class WebuntisLessonSheet { ); } - static Widget _teacherTile(BuildContext context, GetTimetableResponseObject lesson) { + static Widget _teacherTile( + BuildContext context, + GetTimetableResponseObject lesson, + ) { final trailing = Visibility( visible: !kReleaseMode, child: IconButton( diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index ffced37..6b7c8f3 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -27,7 +27,8 @@ class Timetable extends StatefulWidget { } class _TimetableState extends State { - final GlobalKey _calendarKey = GlobalKey(); + final GlobalKey _calendarKey = + GlobalKey(); List? _cachedAppointments; int? _lastDataVersion; @@ -53,7 +54,10 @@ class _TimetableState extends State { } List _appointments(TimetableState state) { - final timetableSettings = context.watch().val().timetableSettings; + final timetableSettings = context + .watch() + .val() + .timetableSettings; if (_cachedAppointments != null && _lastDataVersion == state.dataVersion && identical(_lastTimetableSettings, timetableSettings)) { @@ -81,7 +85,11 @@ class _TimetableState extends State { bool _isOnInitialWeek(TimetableState state) { final target = _initialDisplayDate(); final targetMonday = target.subtract(Duration(days: target.weekday - 1)); - final mondayOnly = DateTime(targetMonday.year, targetMonday.month, targetMonday.day); + final mondayOnly = DateTime( + targetMonday.year, + targetMonday.month, + targetMonday.day, + ); return state.startDate == mondayOnly; } @@ -105,7 +113,10 @@ class _TimetableState extends State { itemBuilder: (_) => const [ PopupMenuItem( value: _CalendarAction.addEvent, - child: ListTile(title: Text('Kalendereintrag hinzufügen'), leading: Icon(Icons.add)), + child: ListTile( + title: Text('Kalendereintrag hinzufügen'), + leading: Icon(Icons.add), + ), ), PopupMenuItem( value: _CalendarAction.viewEvents, @@ -142,9 +153,14 @@ class _TimetableState extends State { appointments: appointments, timeRegions: regions, initialDate: _initialDisplayDate(), - minDate: DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.sunday), - maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday), - onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show(context, bloc, apt), + minDate: DateTime.now() + .subtract(const Duration(days: 14)) + .nextWeekday(DateTime.sunday), + maxDate: DateTime.now() + .add(const Duration(days: 7)) + .nextWeekday(DateTime.saturday), + onAppointmentTap: (apt) => + AppointmentDetailsDispatcher.show(context, bloc, apt), onWeekChanged: (start, end) => bloc.changeWeek(start, end), isCrossedOut: _isCrossedOut, onCreateEvent: _onCreateEventAt, @@ -154,7 +170,8 @@ class _TimetableState extends State { void _onCreateEventAt(DateTime start, DateTime end) { showDialog( context: context, - builder: (_) => CustomEventEditDialog(initialStart: start, initialEnd: end), + builder: (_) => + CustomEventEditDialog(initialStart: start, initialEnd: end), barrierDismissible: false, ); } diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart index 940b1d0..61d08c0 100644 --- a/lib/view/pages/timetable/widgets/appointment_tile.dart +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -11,7 +11,11 @@ class AppointmentTile extends StatelessWidget { final Appointment appointment; final bool crossedOut; - const AppointmentTile({super.key, required this.appointment, this.crossedOut = false}); + const AppointmentTile({ + super.key, + required this.appointment, + this.crossedOut = false, + }); @override Widget build(BuildContext context) { @@ -56,11 +60,15 @@ class AppointmentTile extends StatelessWidget { ), ), ] else ...[ - for (final line in description - .split('\n') - .where((p) => p.isNotEmpty) - .take(2)) - _ScaledLine(text: line, fontSize: kAppointmentBodyFontSize), + for (final line + in description + .split('\n') + .where((p) => p.isNotEmpty) + .take(2)) + _ScaledLine( + text: line, + fontSize: kAppointmentBodyFontSize, + ), ], ], ), @@ -72,7 +80,10 @@ class AppointmentTile extends StatelessWidget { borderRadius: _radius, child: DecoratedBox( decoration: BoxDecoration( - border: Border.all(width: 2, color: Colors.red.withAlpha(200)), + border: Border.all( + width: 2, + color: Colors.red.withAlpha(200), + ), borderRadius: _radius, ), child: CustomPaint(painter: CrossPainter()), @@ -114,7 +125,10 @@ class _AdaptiveTitle extends StatelessWidget { builder: (context, constraints) { // Probe at the minimum size: if even that overflows, we have to ellipsize. final probe = TextPainter( - text: TextSpan(text: text, style: baseStyle.copyWith(fontSize: minFontSize)), + text: TextSpan( + text: text, + style: baseStyle.copyWith(fontSize: minFontSize), + ), textDirection: TextDirection.ltr, maxLines: 1, textScaler: textScaler, @@ -131,12 +145,7 @@ class _AdaptiveTitle extends StatelessWidget { return FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, - child: Text( - text, - style: baseStyle, - maxLines: 1, - softWrap: false, - ), + child: Text(text, style: baseStyle, maxLines: 1, softWrap: false), ); }, ); @@ -187,24 +196,17 @@ class _ScaledLine extends StatelessWidget { final String text; final double fontSize; - const _ScaledLine({ - required this.text, - required this.fontSize, - }); + const _ScaledLine({required this.text, required this.fontSize}); @override Widget build(BuildContext context) => FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - text, - style: TextStyle( - color: Colors.white, - fontSize: fontSize, - height: 1.1, - ), - maxLines: 1, - softWrap: false, - ), - ); + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + text, + style: TextStyle(color: Colors.white, fontSize: fontSize, height: 1.1), + maxLines: 1, + softWrap: false, + ), + ); } diff --git a/lib/view/pages/timetable/widgets/calendar/day_header.dart b/lib/view/pages/timetable/widgets/calendar/day_header.dart index 3bd683d..5f1ca7f 100644 --- a/lib/view/pages/timetable/widgets/calendar/day_header.dart +++ b/lib/view/pages/timetable/widgets/calendar/day_header.dart @@ -14,17 +14,17 @@ class _DayHeaderStrip extends StatelessWidget { @override Widget build(BuildContext context) => Row( - children: [ - SizedBox(width: rulerWidth), - for (var d = 0; d < 5; d++) - Expanded( - child: _DayHeaderCell( - date: weekStart.add(Duration(days: d)), - today: today, - ), - ), - ], - ); + children: [ + SizedBox(width: rulerWidth), + for (var d = 0; d < 5; d++) + Expanded( + child: _DayHeaderCell( + date: weekStart.add(Duration(days: d)), + today: today, + ), + ), + ], + ); } class _DayHeaderCell extends StatelessWidget { @@ -37,7 +37,10 @@ class _DayHeaderCell extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final isToday = date.isSameDay(today); - final dayName = DateFormat('EE', Localizations.localeOf(context).toString()).format(date).toUpperCase(); + final dayName = DateFormat( + 'EE', + Localizations.localeOf(context).toString(), + ).format(date).toUpperCase(); final accent = theme.colorScheme.primary; final onAccent = theme.colorScheme.onPrimary; diff --git a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart index b861f78..360c780 100644 --- a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart +++ b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart @@ -18,20 +18,30 @@ class _OutsideHoursStrip extends StatelessWidget { @override Widget build(BuildContext context) { - final outside = partitionAppointmentsForWeek(appointments, weekStart).outside; + final outside = partitionAppointmentsForWeek( + appointments, + weekStart, + ).outside; if (outside.every((day) => day.isEmpty)) return const SizedBox.shrink(); final theme = Theme.of(context); final maxChipsPerDay = outside - .map((day) => day.length > kOutsideChipsMaxVisible ? kOutsideChipsMaxVisible : day.length) + .map( + (day) => day.length > kOutsideChipsMaxVisible + ? kOutsideChipsMaxVisible + : day.length, + ) .fold(0, (m, c) => c > m ? c : m); - final stripHeight = kOutsideStripVerticalPadding * 2 + + final stripHeight = + kOutsideStripVerticalPadding * 2 + maxChipsPerDay * kOutsideChipHeight + (maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * kOutsideChipSpacing : 0); return Container( color: theme.colorScheme.surfaceContainerLowest, - padding: const EdgeInsets.symmetric(vertical: kOutsideStripVerticalPadding), + padding: const EdgeInsets.symmetric( + vertical: kOutsideStripVerticalPadding, + ), child: SizedBox( height: stripHeight - kOutsideStripVerticalPadding * 2, child: Row( @@ -72,27 +82,29 @@ class _OutsideDayColumn extends StatelessWidget { for (var i = 0; i < hidden.length; i++) { if (i > 0) tiles.add(const Divider(height: 1)); final apt = hidden[i]; - tiles.add(ListTile( - leading: Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: apt.color, - borderRadius: BorderRadius.circular(3), + tiles.add( + ListTile( + leading: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: apt.color, + borderRadius: BorderRadius.circular(3), + ), ), + title: Text( + apt.subject, + style: isCrossedOut(apt) + ? const TextStyle(decoration: TextDecoration.lineThrough) + : null, + ), + subtitle: Text(_subtitleFor(apt)), + onTap: () { + Navigator.of(sheetCtx).pop(); + onAppointmentTap(apt); + }, ), - title: Text( - apt.subject, - style: isCrossedOut(apt) - ? const TextStyle(decoration: TextDecoration.lineThrough) - : null, - ), - subtitle: Text(_subtitleFor(apt)), - onTap: () { - Navigator.of(sheetCtx).pop(); - onAppointmentTap(apt); - }, - )); + ); } return tiles; }, diff --git a/lib/view/pages/timetable/widgets/calendar/week_grid.dart b/lib/view/pages/timetable/widgets/calendar/week_grid.dart index 4cb7762..132298f 100644 --- a/lib/view/pages/timetable/widgets/calendar/week_grid.dart +++ b/lib/view/pages/timetable/widgets/calendar/week_grid.dart @@ -34,11 +34,7 @@ class _WeekGrid extends StatelessWidget { return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _PeriodRuler( - schedule: schedule, - layout: layout, - width: rulerWidth, - ), + _PeriodRuler(schedule: schedule, layout: layout, width: rulerWidth), for (var d = 0; d < 5; d++) Expanded( child: _DayColumn( @@ -112,7 +108,11 @@ class _PeriodLabel extends StatelessWidget { ), ), alignment: Alignment.center, - child: Icon(Icons.coffee_outlined, size: 12, color: secondaryTextColor.withAlpha(180)), + child: Icon( + Icons.coffee_outlined, + size: 12, + color: secondaryTextColor.withAlpha(180), + ), ); } @@ -207,27 +207,49 @@ class _DayColumn extends StatelessWidget { required this.onCreateEvent, }); - bool _overlapsExistingAppointment(DateTime start, DateTime end, List dayAppts) { + bool _overlapsExistingAppointment( + DateTime start, + DateTime end, + List dayAppts, + ) { for (final a in dayAppts) { if (a.endTime.isAfter(start) && a.startTime.isBefore(end)) return true; } return false; } - void _handleLongPress(LongPressStartDetails details, List dayAppts) { + void _handleLongPress( + LongPressStartDetails details, + List dayAppts, + ) { if (onCreateEvent == null) return; 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 = 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, + ); if (_overlapsExistingAppointment(start, end, dayAppts)) return; HapticFeedback.mediumImpact(); onCreateEvent!(start, end); } - void _showOverflowSheet(BuildContext context, List appointments) { + void _showOverflowSheet( + BuildContext context, + List appointments, + ) { final sorted = [...appointments] ..sort((a, b) => a.startTime.compareTo(b.startTime)); showDetailsBottomSheet( @@ -237,27 +259,29 @@ class _DayColumn extends StatelessWidget { for (var i = 0; i < sorted.length; i++) { if (i > 0) tiles.add(const Divider(height: 1)); final apt = sorted[i]; - tiles.add(ListTile( - leading: Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: apt.color, - borderRadius: BorderRadius.circular(3), + tiles.add( + ListTile( + leading: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: apt.color, + borderRadius: BorderRadius.circular(3), + ), ), + title: Text( + apt.subject, + style: isCrossedOut(apt) + ? const TextStyle(decoration: TextDecoration.lineThrough) + : null, + ), + subtitle: Text(_overflowSubtitle(apt)), + onTap: () { + Navigator.of(sheetContext).pop(); + onAppointmentTap(apt); + }, ), - title: Text( - apt.subject, - style: isCrossedOut(apt) - ? const TextStyle(decoration: TextDecoration.lineThrough) - : null, - ), - subtitle: Text(_overflowSubtitle(apt)), - onTap: () { - Navigator.of(sheetContext).pop(); - onAppointmentTap(apt); - }, - )); + ); } return tiles; }, @@ -288,46 +312,53 @@ class _DayColumn extends StatelessWidget { behavior: HitTestBehavior.translucent, onLongPressStart: (details) => _handleLongPress(details, dayAppointments), child: DecoratedBox( - decoration: BoxDecoration( - color: isToday ? theme.colorScheme.primary.withAlpha(14) : null, - border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)), - ), - child: LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - return Stack( - clipBehavior: Clip.none, - children: [ - for (final period in schedule.periods) - Positioned( - top: layout.topOf(period), - left: 0, - right: 0, - child: Container( - height: 0.5, - color: theme.dividerColor.withAlpha(60), + decoration: BoxDecoration( + color: isToday ? theme.colorScheme.primary.withAlpha(14) : null, + border: Border( + left: BorderSide( + color: theme.dividerColor.withAlpha(90), + width: 0.5, + ), + ), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + return Stack( + clipBehavior: Clip.none, + children: [ + for (final period in schedule.periods) + Positioned( + top: layout.topOf(period), + left: 0, + right: 0, + child: Container( + height: 0.5, + color: theme.dividerColor.withAlpha(60), + ), ), - ), - for (final region in dayRegions) - Positioned( - top: layout.yOfDateTime(region.start), - height: (layout.yOfDateTime(region.end) - - layout.yOfDateTime(region.start)) - .clamp(0, double.infinity), - left: 0, - right: 0, - child: TimeRegionTile(region: region.region), - ), - for (final cell in laidOut) - Positioned( - top: layout.yOfDateTime(cell.startTime), - height: (layout.yOfDateTime(cell.endTime) - - layout.yOfDateTime(cell.startTime)) - .clamp(0, double.infinity), - left: cell.lane * width / cell.laneCount, - width: width / cell.laneCount, - child: switch (cell) { - LaidOutAppointment(:final appointment) => GestureDetector( + for (final region in dayRegions) + Positioned( + top: layout.yOfDateTime(region.start), + height: + (layout.yOfDateTime(region.end) - + layout.yOfDateTime(region.start)) + .clamp(0, double.infinity), + left: 0, + right: 0, + child: TimeRegionTile(region: region.region), + ), + for (final cell in laidOut) + Positioned( + top: layout.yOfDateTime(cell.startTime), + height: + (layout.yOfDateTime(cell.endTime) - + layout.yOfDateTime(cell.startTime)) + .clamp(0, double.infinity), + left: cell.lane * width / cell.laneCount, + width: width / cell.laneCount, + child: switch (cell) { + LaidOutAppointment(:final appointment) => GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => onAppointmentTap(appointment), child: AppointmentTile( @@ -335,25 +366,27 @@ class _DayColumn extends StatelessWidget { crossedOut: isCrossedOut(appointment), ), ), - LaidOutOverflow(:final appointments) => GestureDetector( + LaidOutOverflow(:final appointments) => GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => - _showOverflowSheet(context, appointments), + onTap: () => _showOverflowSheet(context, appointments), child: _OverflowTile(count: appointments.length), ), - }, - ), - if (isToday) - ValueListenableBuilder( - valueListenable: nowNotifier, - builder: (_, now, child) => - _CurrentTimeMarker(now: now, layout: layout, theme: theme), - ), - ], - ); - }, + }, + ), + if (isToday) + ValueListenableBuilder( + valueListenable: nowNotifier, + builder: (_, now, child) => _CurrentTimeMarker( + now: now, + layout: layout, + theme: theme, + ), + ), + ], + ); + }, + ), ), - ), ); } } @@ -376,8 +409,7 @@ class _CurrentTimeMarker extends StatelessWidget { final tMin = now.hour * 60 + now.minute; final firstStart = periods.first.start.hour * 60 + periods.first.start.minute; - final lastEnd = - periods.last.end.hour * 60 + periods.last.end.minute; + final lastEnd = periods.last.end.hour * 60 + periods.last.end.minute; if (tMin < firstStart || tMin > lastEnd) return const SizedBox.shrink(); final y = layout.yOfDateTime(now); @@ -392,10 +424,7 @@ class _CurrentTimeMarker extends StatelessWidget { child: Stack( clipBehavior: Clip.none, children: [ - Container( - height: 2, - color: theme.colorScheme.primary, - ), + Container(height: 2, color: theme.colorScheme.primary), Positioned( top: -3, left: -4, @@ -456,7 +485,10 @@ class _OverflowTile extends StatelessWidget { child: FittedBox( fit: BoxFit.scaleDown, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 642618f..b8f0a5c 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -72,7 +72,8 @@ class CustomWorkWeekCalendarState extends State { _firstMonday = _mondayOf(widget.minDate); final lastMonday = _mondayOf(widget.maxDate); _totalWeeks = lastMonday.difference(_firstMonday).inDays ~/ 7 + 1; - _currentWeekIndex = _mondayOf(widget.initialDate).difference(_firstMonday).inDays ~/ 7; + _currentWeekIndex = + _mondayOf(widget.initialDate).difference(_firstMonday).inDays ~/ 7; _pageController = PageController(initialPage: _currentWeekIndex); _nowNotifier = ValueNotifier(DateTime.now()); @@ -113,7 +114,9 @@ class CustomWorkWeekCalendarState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final visibleWeekStart = _firstMonday.add(Duration(days: _currentWeekIndex * 7)); + final visibleWeekStart = _firstMonday.add( + Duration(days: _currentWeekIndex * 7), + ); return Column( children: [ @@ -168,13 +171,13 @@ class CustomWorkWeekCalendarState extends State { child: LayoutBuilder( builder: (context, constraints) { final periods = widget.schedule.periods; - final lessonCount = - periods.where((p) => !p.isBreak).length; + final lessonCount = periods.where((p) => !p.isBreak).length; final breakCount = periods.length - lessonCount; final available = constraints.maxHeight - breakCount * kBreakBlockHeight; - final fitLessonH = - lessonCount > 0 ? available / lessonCount : kLessonBlockMinHeight; + final fitLessonH = lessonCount > 0 + ? available / lessonCount + : kLessonBlockMinHeight; final lessonH = fitLessonH < kLessonBlockMinHeight ? kLessonBlockMinHeight : fitLessonH; @@ -194,11 +197,18 @@ class CustomWorkWeekCalendarState extends State { itemCount: _totalWeeks, onPageChanged: (index) { setState(() => _currentWeekIndex = index); - final weekStart = _firstMonday.add(Duration(days: index * 7)); - widget.onWeekChanged(weekStart, weekStart.add(const Duration(days: 4))); + final weekStart = _firstMonday.add( + Duration(days: index * 7), + ); + widget.onWeekChanged( + weekStart, + weekStart.add(const Duration(days: 4)), + ); }, itemBuilder: (_, weekIndex) { - final weekStart = _firstMonday.add(Duration(days: weekIndex * 7)); + final weekStart = _firstMonday.add( + Duration(days: weekIndex * 7), + ); return _WeekGrid( weekStart: weekStart, schedule: widget.schedule, diff --git a/lib/view/pages/timetable/widgets/special_regions_builder.dart b/lib/view/pages/timetable/widgets/special_regions_builder.dart index bffdfb7..02a06be 100644 --- a/lib/view/pages/timetable/widgets/special_regions_builder.dart +++ b/lib/view/pages/timetable/widgets/special_regions_builder.dart @@ -22,45 +22,61 @@ class SpecialRegionsBuilder { }); List build() { - final lastMonday = DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.monday); + final lastMonday = DateTime.now() + .subtract(const Duration(days: 14)) + .nextWeekday(DateTime.monday); final holidayRegions = _buildHolidayRegions().toList(); - bool isInHoliday(DateTime time) => holidayRegions.any((region) => region.startTime.isSameDay(time)); + bool isInHoliday(DateTime time) => + holidayRegions.any((region) => region.startTime.isSameDay(time)); - final breakRegions = schedule.periods.where((p) => p.isBreak).map((p) { - final start = lastMonday.copyWith(hour: p.start.hour, minute: p.start.minute); - return _breakRegion(start, p.duration); - }).where((region) => !isInHoliday(region.startTime)); + final breakRegions = schedule.periods + .where((p) => p.isBreak) + .map((p) { + final start = lastMonday.copyWith( + hour: p.start.hour, + minute: p.start.minute, + ); + return _breakRegion(start, p.duration); + }) + .where((region) => !isInHoliday(region.startTime)); - return [ - ...holidayRegions, - ...breakRegions, - ]; + return [...holidayRegions, ...breakRegions]; } - Iterable _buildHolidayRegions() => holidays.result.expand((holiday) { - final startDay = WebuntisTime.parse(holiday.startDate, 0); - final dayCount = WebuntisTime.parse(holiday.endDate, 0).difference(startDay).inDays; - final days = List.generate(dayCount, (i) => startDay.add(Duration(days: i))); - final gridStartHour = kCalendarStartHour.floor(); - final gridStartMinute = ((kCalendarStartHour - gridStartHour) * 60).round(); - final gridEndHour = kCalendarEndHour.floor(); - final gridEndMinute = ((kCalendarEndHour - gridEndHour) * 60).round(); - return days.map((day) => TimeRegion( - startTime: day.copyWith(hour: gridStartHour, minute: gridStartMinute), - endTime: day.copyWith(hour: gridEndHour, minute: gridEndMinute), - text: '$kTimeRegionHolidayPrefix${holiday.name}', - color: disabledColor.withAlpha(50), - iconData: Icons.holiday_village_outlined, - )); - }); + Iterable _buildHolidayRegions() => holidays.result.expand(( + holiday, + ) { + final startDay = WebuntisTime.parse(holiday.startDate, 0); + final dayCount = WebuntisTime.parse( + holiday.endDate, + 0, + ).difference(startDay).inDays; + final days = List.generate( + dayCount, + (i) => startDay.add(Duration(days: i)), + ); + final gridStartHour = kCalendarStartHour.floor(); + final gridStartMinute = ((kCalendarStartHour - gridStartHour) * 60).round(); + final gridEndHour = kCalendarEndHour.floor(); + final gridEndMinute = ((kCalendarEndHour - gridEndHour) * 60).round(); + return days.map( + (day) => TimeRegion( + startTime: day.copyWith(hour: gridStartHour, minute: gridStartMinute), + endTime: day.copyWith(hour: gridEndHour, minute: gridEndMinute), + text: '$kTimeRegionHolidayPrefix${holiday.name}', + color: disabledColor.withAlpha(50), + iconData: Icons.holiday_village_outlined, + ), + ); + }); TimeRegion _breakRegion(DateTime start, Duration duration) => TimeRegion( - startTime: start, - endTime: start.add(duration), - recurrenceRule: 'FREQ=DAILY;INTERVAL=1', - text: kTimeRegionCenterIcon, - color: colorScheme.primary.withAlpha(50), - iconData: Icons.restaurant, - ); + startTime: start, + endTime: start.add(duration), + recurrenceRule: 'FREQ=DAILY;INTERVAL=1', + text: kTimeRegionCenterIcon, + color: colorScheme.primary.withAlpha(50), + iconData: Icons.restaurant, + ); } diff --git a/lib/view/pages/timetable/widgets/time_region_tile.dart b/lib/view/pages/timetable/widgets/time_region_tile.dart index 9fa946c..292e170 100644 --- a/lib/view/pages/timetable/widgets/time_region_tile.dart +++ b/lib/view/pages/timetable/widgets/time_region_tile.dart @@ -18,7 +18,11 @@ class TimeRegionTile extends StatelessWidget { return Container( color: color, alignment: Alignment.center, - child: Icon(region.iconData, size: 17, color: Theme.of(context).colorScheme.primary), + child: Icon( + region.iconData, + size: 17, + color: Theme.of(context).colorScheme.primary, + ), ); } diff --git a/lib/widget/about/about.dart b/lib/widget/about/about.dart index 17003f2..0d1d30e 100644 --- a/lib/widget/about/about.dart +++ b/lib/widget/about/about.dart @@ -1,4 +1,3 @@ - import 'package:flutter/material.dart'; class About extends StatelessWidget { @@ -6,13 +5,11 @@ class About extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Über diese App'), - ), - body: const Card( - elevation: 1, - borderOnForeground: true, - child: Text('Marianum Fulda'), - ), - ); + appBar: AppBar(title: const Text('Über diese App')), + body: const Card( + elevation: 1, + borderOnForeground: true, + child: Text('Marianum Fulda'), + ), + ); } diff --git a/lib/widget/animated_time.dart b/lib/widget/animated_time.dart index 2c00b18..1ba4ccd 100644 --- a/lib/widget/animated_time.dart +++ b/lib/widget/animated_time.dart @@ -29,30 +29,43 @@ class _AnimatedTimeState extends State { @override Widget build(BuildContext context) => Row( - children: [ - const Text('Noch '), - buildWidget(current.inDays), - const Text(' Tage, '), - buildWidget(current.inHours > 24 ? current.inHours - current.inDays * 24 : current.inHours), - const Text(':'), - buildWidget(current.inMinutes > 60 ? current.inMinutes - current.inHours * 60 : current.inMinutes), - const Text(':'), - buildWidget(current.inSeconds > 60 ? current.inSeconds - current.inMinutes * 60 : current.inSeconds), - ], - ); + children: [ + const Text('Noch '), + buildWidget(current.inDays), + const Text(' Tage, '), + buildWidget( + current.inHours > 24 + ? current.inHours - current.inDays * 24 + : current.inHours, + ), + const Text(':'), + buildWidget( + current.inMinutes > 60 + ? current.inMinutes - current.inHours * 60 + : current.inMinutes, + ), + const Text(':'), + buildWidget( + current.inSeconds > 60 + ? current.inSeconds - current.inMinutes * 60 + : current.inSeconds, + ), + ], + ); Widget buildWidget(int value) => AnimatedSwitcher( - duration: const Duration(milliseconds: 100), - transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child), - child: Text( - '$value', - key: ValueKey(value), - style: TextStyle( - fontSize: 15, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ); + duration: const Duration(milliseconds: 100), + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: Text( + '$value', + key: ValueKey(value), + style: TextStyle( + fontSize: 15, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ); @override void dispose() { diff --git a/lib/widget/app_progress_indicator.dart b/lib/widget/app_progress_indicator.dart index 643096f..c383687 100644 --- a/lib/widget/app_progress_indicator.dart +++ b/lib/widget/app_progress_indicator.dart @@ -12,13 +12,13 @@ class AppProgressIndicator extends StatelessWidget { }); const AppProgressIndicator.small({Color? color}) - : this._(size: 16, strokeWidth: 2, color: color); + : this._(size: 16, strokeWidth: 2, color: color); const AppProgressIndicator.medium({Color? color}) - : this._(size: 24, strokeWidth: 2.5, color: color); + : this._(size: 24, strokeWidth: 2.5, color: color); const AppProgressIndicator.large({Color? color}) - : this._(size: 40, strokeWidth: 3, color: color); + : this._(size: 40, strokeWidth: 3, color: color); @override Widget build(BuildContext context) { diff --git a/lib/widget/async_actions/async_action_button.dart b/lib/widget/async_actions/async_action_button.dart index 29367b2..51e4373 100644 --- a/lib/widget/async_actions/async_action_button.dart +++ b/lib/widget/async_actions/async_action_button.dart @@ -26,33 +26,33 @@ class AsyncActionButton extends StatelessWidget { @override Widget build(BuildContext context) => _AsyncMixin( - onPressed: onPressed, - controller: controller, - errorBuilder: errorBuilder, - onError: onError, - onSuccess: onSuccess, - builder: (context, busy, handler) { - final spinner = AppProgressIndicator.small( - color: Theme.of(context).colorScheme.onPrimary, - ); - final content = busy - ? Row( - mainAxisSize: MainAxisSize.min, - children: [spinner, const SizedBox(width: 8), child], - ) - : (icon != null - ? Row( - mainAxisSize: MainAxisSize.min, - children: [Icon(icon), const SizedBox(width: 8), child], - ) - : child); - final button = ElevatedButton( - onPressed: handler, - style: style, - child: content, - ); - if (!showInlineError) return button; - return _InlineErrorWrapper(controller: controller, child: button); - }, + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + final spinner = AppProgressIndicator.small( + color: Theme.of(context).colorScheme.onPrimary, ); + final content = busy + ? Row( + mainAxisSize: MainAxisSize.min, + children: [spinner, const SizedBox(width: 8), child], + ) + : (icon != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [Icon(icon), const SizedBox(width: 8), child], + ) + : child); + final button = ElevatedButton( + onPressed: handler, + style: style, + child: content, + ); + if (!showInlineError) return button; + return _InlineErrorWrapper(controller: controller, child: button); + }, + ); } diff --git a/lib/widget/async_actions/async_action_controller.dart b/lib/widget/async_actions/async_action_controller.dart index 8b63cbe..2046c22 100644 --- a/lib/widget/async_actions/async_action_controller.dart +++ b/lib/widget/async_actions/async_action_controller.dart @@ -16,9 +16,13 @@ Future runWithErrorDialog( return true; } catch (e) { if (!context.mounted) return false; - final message = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e); + final message = errorBuilder != null + ? errorBuilder(e) + : errorToUserMessage(e); final details = errorToTechnicalDetails(e); - final body = details != null && details != message ? '$message\n\n$details' : message; + final body = details != null && details != message + ? '$message\n\n$details' + : message; InfoDialog.show(context, body, copyable: true, title: 'Fehler'); return false; } diff --git a/lib/widget/async_actions/async_dialog_action.dart b/lib/widget/async_actions/async_dialog_action.dart index 24309c2..e96d59b 100644 --- a/lib/widget/async_actions/async_dialog_action.dart +++ b/lib/widget/async_actions/async_dialog_action.dart @@ -31,60 +31,65 @@ class _AsyncDialogActionState extends State { @override Widget build(BuildContext context) => AnimatedBuilder( - animation: _controller, - builder: (context, _) { - final err = _controller.error; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (err != null) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - err, - textAlign: TextAlign.center, - style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13), - ), + animation: _controller, + builder: (context, _) { + final err = _controller.error; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (err != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + err, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 13, ), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (widget.cancelLabel != null) - TextButton( - onPressed: _controller.busy ? null : () => Navigator.of(context).pop(), - child: Text(widget.cancelLabel!), - ), - TextButton( - style: widget.confirmStyle, - onPressed: _controller.busy - ? null - : () async { - final ok = await _controller.run( - widget.onConfirm, - errorBuilder: widget.errorBuilder, - ); - if (ok && context.mounted) { - Navigator.of(context).pop(true); - } - }, - child: _controller.busy - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - AppProgressIndicator.small( - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Text(widget.confirmLabel), - ], - ) - : Text(widget.confirmLabel), - ), - ], + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (widget.cancelLabel != null) + TextButton( + onPressed: _controller.busy + ? null + : () => Navigator.of(context).pop(), + child: Text(widget.cancelLabel!), + ), + TextButton( + style: widget.confirmStyle, + onPressed: _controller.busy + ? null + : () async { + final ok = await _controller.run( + widget.onConfirm, + errorBuilder: widget.errorBuilder, + ); + if (ok && context.mounted) { + Navigator.of(context).pop(true); + } + }, + child: _controller.busy + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppProgressIndicator.small( + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text(widget.confirmLabel), + ], + ) + : Text(widget.confirmLabel), ), ], - ); - }, + ), + ], ); + }, + ); } diff --git a/lib/widget/async_actions/async_fab.dart b/lib/widget/async_actions/async_fab.dart index 04eff65..e041d83 100644 --- a/lib/widget/async_actions/async_fab.dart +++ b/lib/widget/async_actions/async_fab.dart @@ -28,21 +28,21 @@ class AsyncFab extends StatelessWidget { @override Widget build(BuildContext context) => _AsyncMixin( - onPressed: onPressed, - controller: controller, - errorBuilder: errorBuilder, - onError: onError, - onSuccess: onSuccess, - builder: (context, busy, handler) { - final fg = foregroundColor ?? Theme.of(context).colorScheme.onPrimary; - return FloatingActionButton( - heroTag: heroTag, - backgroundColor: backgroundColor, - foregroundColor: fg, - mini: mini, - onPressed: handler, - child: busy ? AppProgressIndicator.small(color: fg) : Icon(icon), - ); - }, + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + final fg = foregroundColor ?? Theme.of(context).colorScheme.onPrimary; + return FloatingActionButton( + heroTag: heroTag, + backgroundColor: backgroundColor, + foregroundColor: fg, + mini: mini, + onPressed: handler, + child: busy ? AppProgressIndicator.small(color: fg) : Icon(icon), ); + }, + ); } diff --git a/lib/widget/async_actions/async_icon_button.dart b/lib/widget/async_actions/async_icon_button.dart index de9cea4..46b5cbf 100644 --- a/lib/widget/async_actions/async_icon_button.dart +++ b/lib/widget/async_actions/async_icon_button.dart @@ -24,23 +24,23 @@ class AsyncIconButton extends StatelessWidget { @override Widget build(BuildContext context) => _AsyncMixin( - onPressed: onPressed, - controller: controller, - errorBuilder: errorBuilder, - onError: onError, - onSuccess: onSuccess, - builder: (context, busy, handler) { - if (busy) { - return Padding( - padding: const EdgeInsets.all(12), - child: AppProgressIndicator.small(color: color), - ); - } - return IconButton( - icon: Icon(icon, color: color), - tooltip: tooltip, - onPressed: handler, - ); - }, + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + if (busy) { + return Padding( + padding: const EdgeInsets.all(12), + child: AppProgressIndicator.small(color: color), + ); + } + return IconButton( + icon: Icon(icon, color: color), + tooltip: tooltip, + onPressed: handler, ); + }, + ); } diff --git a/lib/widget/async_actions/async_list_tile.dart b/lib/widget/async_actions/async_list_tile.dart index 526ee25..5422679 100644 --- a/lib/widget/async_actions/async_list_tile.dart +++ b/lib/widget/async_actions/async_list_tile.dart @@ -36,7 +36,10 @@ class _AsyncListTileState extends State { } Future _handleTap() async { - final ok = await _controller.run(widget.onPressed, errorBuilder: widget.errorBuilder); + final ok = await _controller.run( + widget.onPressed, + errorBuilder: widget.errorBuilder, + ); if (!mounted) return; if (ok) { widget.onSuccess?.call(); @@ -48,38 +51,41 @@ class _AsyncListTileState extends State { @override Widget build(BuildContext context) => AnimatedBuilder( - animation: _controller, - builder: (context, _) { - final busy = _controller.busy; - final err = _controller.error; - final leading = busy - ? const SizedBox( - width: 24, - height: 24, - child: AppProgressIndicator.small(), - ) - : widget.leading; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ListTile( - leading: leading, - title: widget.title, - subtitle: widget.subtitle, - enabled: widget.enabled && !busy, - onTap: busy ? null : _handleTap, - ), - if (err != null) - Padding( - padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), - child: Text( - err, - style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13), - ), + animation: _controller, + builder: (context, _) { + final busy = _controller.busy; + final err = _controller.error; + final leading = busy + ? const SizedBox( + width: 24, + height: 24, + child: AppProgressIndicator.small(), + ) + : widget.leading; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + leading: leading, + title: widget.title, + subtitle: widget.subtitle, + enabled: widget.enabled && !busy, + onTap: busy ? null : _handleTap, + ), + if (err != null) + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), + child: Text( + err, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 13, ), - ], - ); - }, + ), + ), + ], ); + }, + ); } diff --git a/lib/widget/async_actions/async_mixin.dart b/lib/widget/async_actions/async_mixin.dart index 72ff984..85d2dc3 100644 --- a/lib/widget/async_actions/async_mixin.dart +++ b/lib/widget/async_actions/async_mixin.dart @@ -6,7 +6,8 @@ class _AsyncMixin extends StatefulWidget { final AsyncErrorBuilder? errorBuilder; final void Function(String message)? onError; final VoidCallback? onSuccess; - final Widget Function(BuildContext context, bool busy, VoidCallback? handler) builder; + final Widget Function(BuildContext context, bool busy, VoidCallback? handler) + builder; const _AsyncMixin({ required this.onPressed, @@ -59,7 +60,10 @@ class _AsyncMixinState extends State<_AsyncMixin> { Future _trigger() async { final action = widget.onPressed; if (action == null) return; - final success = await _controller.run(action, errorBuilder: widget.errorBuilder); + final success = await _controller.run( + action, + errorBuilder: widget.errorBuilder, + ); if (!mounted) return; if (success) { widget.onSuccess?.call(); @@ -71,7 +75,11 @@ class _AsyncMixinState extends State<_AsyncMixin> { @override Widget build(BuildContext context) { final handler = widget.onPressed == null ? null : _trigger; - return widget.builder(context, _controller.busy, _controller.busy ? null : handler); + return widget.builder( + context, + _controller.busy, + _controller.busy ? null : handler, + ); } } @@ -98,7 +106,10 @@ class _InlineErrorWrapper extends StatelessWidget { Text( err, textAlign: TextAlign.center, - style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13), + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 13, + ), ), ], ], diff --git a/lib/widget/async_actions/async_text_button.dart b/lib/widget/async_actions/async_text_button.dart index cf662f6..725ac79 100644 --- a/lib/widget/async_actions/async_text_button.dart +++ b/lib/widget/async_actions/async_text_button.dart @@ -22,27 +22,27 @@ class AsyncTextButton extends StatelessWidget { @override Widget build(BuildContext context) => _AsyncMixin( - onPressed: onPressed, - controller: controller, - errorBuilder: errorBuilder, - onError: onError, - onSuccess: onSuccess, - builder: (context, busy, handler) { - final content = busy - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - AppProgressIndicator.small( - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - child, - ], - ) - : child; - final button = TextButton(onPressed: handler, child: content); - if (!showInlineError) return button; - return _InlineErrorWrapper(controller: controller, child: button); - }, - ); + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + final content = busy + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppProgressIndicator.small( + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + child, + ], + ) + : child; + final button = TextButton(onPressed: handler, child: content); + if (!showInlineError) return button; + return _InlineErrorWrapper(controller: controller, child: button); + }, + ); } diff --git a/lib/widget/breaker/breaker.dart b/lib/widget/breaker/breaker.dart index 006844e..b969096 100644 --- a/lib/widget/breaker/breaker.dart +++ b/lib/widget/breaker/breaker.dart @@ -18,7 +18,8 @@ class Breaker extends StatelessWidget { if (blocked != null) { return PlaceholderView( icon: Icons.app_blocking_outlined, - text: 'Die App / Dieser Bereich ist zurzeit nicht verfügbar!\n\n' + text: + 'Die App / Dieser Bereich ist zurzeit nicht verfügbar!\n\n' '${blocked.isEmpty ? "Es wurde vom Server kein Grund übermittelt.\nAktualisiere die App und versuche es später erneut" : blocked}', ); } diff --git a/lib/widget/centered_leading.dart b/lib/widget/centered_leading.dart index 2993e3f..be8bc15 100644 --- a/lib/widget/centered_leading.dart +++ b/lib/widget/centered_leading.dart @@ -6,8 +6,8 @@ class CenteredLeading extends StatelessWidget { @override Widget build(BuildContext context) => Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [child], - ); + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [child], + ); } diff --git a/lib/widget/clickable_app_bar.dart b/lib/widget/clickable_app_bar.dart index a928113..c74a692 100644 --- a/lib/widget/clickable_app_bar.dart +++ b/lib/widget/clickable_app_bar.dart @@ -6,7 +6,8 @@ class ClickableAppBar extends StatelessWidget implements PreferredSizeWidget { const ClickableAppBar({required this.onTap, required this.appBar, super.key}); @override - Widget build(BuildContext context) => GestureDetector(onTap: onTap, child: appBar); + Widget build(BuildContext context) => + GestureDetector(onTap: onTap, child: appBar); @override Size get preferredSize => appBar.preferredSize; diff --git a/lib/widget/confirm_dialog.dart b/lib/widget/confirm_dialog.dart index 5bb6d1f..acdcf65 100644 --- a/lib/widget/confirm_dialog.dart +++ b/lib/widget/confirm_dialog.dart @@ -23,8 +23,10 @@ class ConfirmDialog extends StatelessWidget { this.onConfirm, this.onConfirmAsync, this.errorBuilder, - }) : assert(onConfirm != null || onConfirmAsync != null, - 'ConfirmDialog requires either onConfirm or onConfirmAsync'); + }) : assert( + onConfirm != null || onConfirmAsync != null, + 'ConfirmDialog requires either onConfirm or onConfirmAsync', + ); void asDialog(BuildContext context) { showDialog(context: context, builder: build); @@ -32,32 +34,32 @@ class ConfirmDialog extends StatelessWidget { @override Widget build(BuildContext context) => AlertDialog( - icon: icon != null ? Icon(icon) : null, - title: Text(title), - content: Text(content), - actions: onConfirmAsync != null - ? [ - AsyncDialogAction( - confirmLabel: confirmButton, - cancelLabel: cancelButton, - onConfirm: onConfirmAsync!, - errorBuilder: errorBuilder, - ), - ] - : [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(cancelButton), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - onConfirm!(); - }, - child: Text(confirmButton), - ), - ], - ); + icon: icon != null ? Icon(icon) : null, + title: Text(title), + content: Text(content), + actions: onConfirmAsync != null + ? [ + AsyncDialogAction( + confirmLabel: confirmButton, + cancelLabel: cancelButton, + onConfirm: onConfirmAsync!, + errorBuilder: errorBuilder, + ), + ] + : [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(cancelButton), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onConfirm!(); + }, + child: Text(confirmButton), + ), + ], + ); static void openBrowser(BuildContext context, String url) { showDialog( @@ -66,7 +68,8 @@ class ConfirmDialog extends StatelessWidget { title: 'Link öffnen', content: 'Möchtest du den folgenden Link öffnen?\n$url', confirmButton: 'Öffnen', - onConfirm: () => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication), + onConfirm: () => + launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication), ), ); } diff --git a/lib/widget/debug/cache_view.dart b/lib/widget/debug/cache_view.dart index beb46ce..abfa8c9 100644 --- a/lib/widget/debug/cache_view.dart +++ b/lib/widget/debug/cache_view.dart @@ -1,4 +1,3 @@ - import 'dart:async'; import 'dart:convert'; import 'package:filesize/filesize.dart'; @@ -21,9 +20,15 @@ class CacheView extends StatefulWidget { } Future totalSize() async { - final data = await Localstore.instance.collection(RequestCache.collection).get(); + final data = await Localstore.instance + .collection(RequestCache.collection) + .get(); if (data == null || data.isEmpty) return 0; - return data.values.fold(0, (sum, value) => sum + jsonEncode(value).length) * 8; + return data.values.fold( + 0, + (sum, value) => sum + jsonEncode(value).length, + ) * + 8; } } @@ -39,41 +44,45 @@ class _CacheViewState extends State { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Cache storage'), - ), - body: FutureBuilder( - future: files, - builder: (context, snapshot) { - if(snapshot.hasData) { - return ListView.builder( - itemCount: snapshot.data!.length, - itemBuilder: (context, index) { - final key = snapshot.data!.keys.elementAt(index); - final element = snapshot.data![key] as Map; - final filename = key.split('/').last; + appBar: AppBar(title: const Text('Cache storage')), + body: FutureBuilder( + future: files, + builder: (context, snapshot) { + if (snapshot.hasData) { + return ListView.builder( + itemCount: snapshot.data!.length, + itemBuilder: (context, index) { + final key = snapshot.data!.keys.elementAt(index); + final element = snapshot.data![key] as Map; + final filename = key.split('/').last; - return ListTile( - leading: const Icon(Icons.text_snippet_outlined), - title: Text(filename), - subtitle: Text('${filesize(jsonEncode(element).length * 8)}, ${Jiffy.parseFromMillisecondsSinceEpoch(element['lastupdate'] as int).fromNow()}'), - trailing: const Icon(Icons.arrow_right), - onTap: () => JsonViewer.asDialog(context, jsonDecode(element['json'] as String) as Map), - ); - }, - ); - } else if(snapshot.connectionState != ConnectionState.done) { - return const Center( - child: CircularProgressIndicator() - ); - } else { - return const Center( - child: PlaceholderView(icon: Icons.hourglass_empty, text: 'Keine Daten'), - ); - } - }, - ), - ); + return ListTile( + leading: const Icon(Icons.text_snippet_outlined), + title: Text(filename), + subtitle: Text( + '${filesize(jsonEncode(element).length * 8)}, ${Jiffy.parseFromMillisecondsSinceEpoch(element['lastupdate'] as int).fromNow()}', + ), + trailing: const Icon(Icons.arrow_right), + onTap: () => JsonViewer.asDialog( + context, + jsonDecode(element['json'] as String) as Map, + ), + ); + }, + ); + } else if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } else { + return const Center( + child: PlaceholderView( + icon: Icons.hourglass_empty, + text: 'Keine Daten', + ), + ); + } + }, + ), + ); } extension FutureExtension on Future { diff --git a/lib/widget/debug/debug_tile.dart b/lib/widget/debug/debug_tile.dart index 50bf937..f167d1c 100644 --- a/lib/widget/debug/debug_tile.dart +++ b/lib/widget/debug/debug_tile.dart @@ -12,23 +12,29 @@ class DebugTile { DebugTile(this.context, {this.onlyInDebug = false}); bool devConditionFulfilled() => - context.read().val().devToolsEnabled && (onlyInDebug ? kDebugMode : true); + context.read().val().devToolsEnabled && + (onlyInDebug ? kDebugMode : true); - Widget jsonData(Map data, {bool ignoreConfig = false}) => callback( + Widget jsonData(Map data, {bool ignoreConfig = false}) => + callback( title: 'JSON daten anzeigen', onTab: () => JsonViewer.asDialog(context, data), ); - Widget callback({String title = 'Debugaktion', required void Function() onTab}) => child( - ListTile( - leading: const CenteredLeading(Icon(Icons.developer_mode_outlined)), - title: Text(title), - subtitle: const Text('Entwicklermodus aktiviert'), - onTap: onTab, - ), - ); + Widget callback({ + String title = 'Debugaktion', + required void Function() onTab, + }) => child( + ListTile( + leading: const CenteredLeading(Icon(Icons.developer_mode_outlined)), + title: Text(title), + subtitle: const Text('Entwicklermodus aktiviert'), + onTap: onTab, + ), + ); - Widget child(Widget child) => Visibility(visible: devConditionFulfilled(), child: child); + Widget child(Widget child) => + Visibility(visible: devConditionFulfilled(), child: child); void run(void Function() callback) { if (!devConditionFulfilled()) return; diff --git a/lib/widget/debug/json_viewer.dart b/lib/widget/debug/json_viewer.dart index 461a15e..57e13a0 100644 --- a/lib/widget/debug/json_viewer.dart +++ b/lib/widget/debug/json_viewer.dart @@ -12,35 +12,50 @@ class JsonViewer extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: Text(title), - ), - body: SingleChildScrollView( - scrollDirection: Axis.vertical, - child: Text(format(data)), - ), - ); + appBar: AppBar(title: Text(title)), + body: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Text(format(data)), + ), + ); static final _encoder = const JsonEncoder.withIndent(' '); - static String format(Map jsonInput) => _encoder.convert(jsonInput); + static String format(Map jsonInput) => + _encoder.convert(jsonInput); static void asDialog(BuildContext context, Map dataMap) { - showDialog(context: context, builder: (dialogCtx) => AlertDialog( + showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( scrollable: true, - title: const Row(children: [Icon(Icons.bug_report_outlined), Text('Rohdaten')]), + title: const Row( + children: [Icon(Icons.bug_report_outlined), Text('Rohdaten')], + ), content: Text(JsonViewer.format(dataMap)), actions: [ TextButton( - onPressed: () => copyToClipboard(dialogCtx, JsonViewer.format(dataMap), successMessage: 'Formatiertes JSON kopiert'), + onPressed: () => copyToClipboard( + dialogCtx, + JsonViewer.format(dataMap), + successMessage: 'Formatiertes JSON kopiert', + ), child: const Text('Kopieren'), ), TextButton( - onPressed: () => copyToClipboard(dialogCtx, dataMap.toString(), successMessage: 'Inline JSON kopiert'), + onPressed: () => copyToClipboard( + dialogCtx, + dataMap.toString(), + successMessage: 'Inline JSON kopiert', + ), child: const Text('Inline Kopieren'), ), - TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Schließen')) + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(), + child: const Text('Schließen'), + ), ], - )); + ), + ); } } diff --git a/lib/widget/details_bottom_sheet.dart b/lib/widget/details_bottom_sheet.dart index 0618f40..db6aff2 100644 --- a/lib/widget/details_bottom_sheet.dart +++ b/lib/widget/details_bottom_sheet.dart @@ -21,10 +21,7 @@ void showDetailsBottomSheet( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (header != null) ...[ - header, - const Divider(height: 1), - ], + if (header != null) ...[header, const Divider(height: 1)], ...children(sheetContext), ], ), diff --git a/lib/widget/file_pick.dart b/lib/widget/file_pick.dart index 0e678f3..130880e 100644 --- a/lib/widget/file_pick.dart +++ b/lib/widget/file_pick.dart @@ -9,7 +9,8 @@ class FilePick { return pickedImages.isNotEmpty ? pickedImages : null; } - static Future cameraPick() => _picker.pickImage(source: ImageSource.camera); + static Future cameraPick() => + _picker.pickImage(source: ImageSource.camera); static Future?> documentPick() async { final result = await FilePicker.pickFiles(allowMultiple: true); diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index 80c5664..6440bad 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -25,11 +25,7 @@ class FileViewer extends StatefulWidget { State createState() => _FileViewerState(); } -enum FileViewingActions { - openExternal, - share, - save -} +enum FileViewingActions { openExternal, share, save } /// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal /// LayoutBuilder calls `localToGlobal` during build, which asserts when an @@ -88,7 +84,9 @@ class _FileViewerState extends State { @override void initState() { - openExternal = settings.val().fileViewSettings.alwaysOpenExternally || widget.openExternal; + openExternal = + settings.val().fileViewSettings.alwaysOpenExternally || + widget.openExternal; super.initState(); } @@ -101,96 +99,113 @@ class _FileViewerState extends State { @override Widget build(BuildContext context) { AppBar appbar({List actions = const []}) => AppBar( - title: Text(widget.path.split('/').last), - actions: [ - ...actions, - PopupMenuButton( - onSelected: (value) async { - switch(value) { - case FileViewingActions.openExternal: - AppRoutes.openFileViewer(context, widget.path, openExternal: true); - break; - case FileViewingActions.share: - unawaited(SharePlus.instance.share( + title: Text(widget.path.split('/').last), + actions: [ + ...actions, + PopupMenuButton( + onSelected: (value) async { + switch (value) { + case FileViewingActions.openExternal: + AppRoutes.openFileViewer( + context, + widget.path, + openExternal: true, + ); + break; + case FileViewingActions.share: + unawaited( + SharePlus.instance.share( ShareParams( files: [XFile(widget.path)], sharePositionOrigin: SharePositionOrigin.get(context), ), - )); - break; - case FileViewingActions.save: - try { - final bytes = await File(widget.path).readAsBytes(); - final saved = await FilePicker.saveFile( - fileName: widget.path.split('/').last, - bytes: bytes, - ); - if (!context.mounted) return; - if (saved != null) InfoDialog.show(context, 'Datei gespeichert.'); - } on Object catch (e) { - if (!context.mounted) return; - InfoDialog.show(context, 'Speichern fehlgeschlagen: $e', copyable: true, title: 'Fehler'); + ), + ); + break; + case FileViewingActions.save: + try { + final bytes = await File(widget.path).readAsBytes(); + final saved = await FilePicker.saveFile( + fileName: widget.path.split('/').last, + bytes: bytes, + ); + if (!context.mounted) return; + if (saved != null) { + InfoDialog.show(context, 'Datei gespeichert.'); } - break; - } - }, - itemBuilder: (context) => >[ - const PopupMenuItem( - value: FileViewingActions.openExternal, - child: ListTile( - leading: Icon(Icons.open_in_new), - title: Text('Extern öffnen'), - dense: true, - ), + } on Object catch (e) { + if (!context.mounted) return; + InfoDialog.show( + context, + 'Speichern fehlgeschlagen: $e', + copyable: true, + title: 'Fehler', + ); + } + break; + } + }, + itemBuilder: (context) => >[ + const PopupMenuItem( + value: FileViewingActions.openExternal, + child: ListTile( + leading: Icon(Icons.open_in_new), + title: Text('Extern öffnen'), + dense: true, ), - const PopupMenuItem( - value: FileViewingActions.share, - child: ListTile( - leading: Icon(Icons.share_outlined), - title: Text('Teilen'), - dense: true, - ), + ), + const PopupMenuItem( + value: FileViewingActions.share, + child: ListTile( + leading: Icon(Icons.share_outlined), + title: Text('Teilen'), + dense: true, ), - const PopupMenuItem( - value: FileViewingActions.save, - child: ListTile( - leading: Icon(Icons.save_alt_outlined), - title: Text('Speichern'), - dense: true, - ), + ), + const PopupMenuItem( + value: FileViewingActions.save, + child: ListTile( + leading: Icon(Icons.save_alt_outlined), + title: Text('Speichern'), + dense: true, ), - ], - ), - ], - ); + ), + ], + ), + ], + ); - switch(openExternal ? '' : widget.path.split('.').last.toLowerCase()) { + switch (openExternal ? '' : widget.path.split('.').last.toLowerCase()) { case 'png': case 'jpg': case 'jpeg': case 'webp': case 'gif': return Scaffold( - appBar: appbar( - actions: [ - IconButton(onPressed: () { - setState(() { - photoViewController.rotation += pi/2; - }); - }, icon: const Icon(Icons.rotate_right)), - ] + appBar: appbar( + actions: [ + IconButton( + onPressed: () { + setState(() { + photoViewController.rotation += pi / 2; + }); + }, + icon: const Icon(Icons.rotate_right), + ), + ], + ), + backgroundColor: Colors.white, + body: PhotoView( + controller: photoViewController, + maxScale: 3.0, + minScale: 0.1, + imageProvider: Image.file(File(widget.path)).image, + backgroundDecoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, ), - backgroundColor: Colors.white, - body: PhotoView( - controller: photoViewController, - maxScale: 3.0, - minScale: 0.1, - imageProvider: Image.file(File(widget.path)).image, - backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), - ) + ), ); - case 'pdf': return Scaffold( appBar: appbar(), diff --git a/lib/widget/large_profile_picture_view.dart b/lib/widget/large_profile_picture_view.dart index c8abe23..6751bdd 100644 --- a/lib/widget/large_profile_picture_view.dart +++ b/lib/widget/large_profile_picture_view.dart @@ -9,12 +9,14 @@ class LargeProfilePictureView extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Profilbild'), + appBar: AppBar(title: const Text('Profilbild')), + body: PhotoView( + imageProvider: Image.network( + 'https://${EndpointData().nextcloud().full()}/avatar/$username/1024', + ).image, + backgroundDecoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, ), - body: PhotoView( - imageProvider: Image.network('https://${EndpointData().nextcloud().full()}/avatar/$username/1024').image, - backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), - ), - ); + ), + ); } diff --git a/lib/widget/list_view_util.dart b/lib/widget/list_view_util.dart index 468ae95..efa8ee8 100644 --- a/lib/widget/list_view_util.dart +++ b/lib/widget/list_view_util.dart @@ -1,9 +1,10 @@ - import 'package:flutter/material.dart'; 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, - ); + 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, + ); } diff --git a/lib/widget/loading_spinner.dart b/lib/widget/loading_spinner.dart index 4cf9ab0..054214c 100644 --- a/lib/widget/loading_spinner.dart +++ b/lib/widget/loading_spinner.dart @@ -1,4 +1,3 @@ - import 'dart:async'; import 'package:flutter/material.dart'; @@ -16,7 +15,7 @@ class _LoadingSpinnerState extends State { @override void initState() { - timer = Timer(const Duration(seconds: 30), () { + timer = Timer(const Duration(seconds: 30), () { setState(() { textVisible = true; }); @@ -27,25 +26,25 @@ class _LoadingSpinnerState extends State { @override Widget build(BuildContext context) => Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Visibility( - visible: !textVisible, - replacement: const Icon(Icons.sentiment_dissatisfied_outlined), - child: const CircularProgressIndicator(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Visibility( + visible: !textVisible, + replacement: const Icon(Icons.sentiment_dissatisfied_outlined), + child: const CircularProgressIndicator(), + ), + const SizedBox(height: 30), + Visibility( + visible: textVisible, + child: const Text( + textAlign: TextAlign.center, + 'Irgendetwas funktioniert nicht!\nBist du mit dem Internet verbunden?\n\nVersuche die App neuzustarten', ), - const SizedBox(height: 30), - Visibility( - visible: textVisible, - child: const Text( - textAlign: TextAlign.center, - 'Irgendetwas funktioniert nicht!\nBist du mit dem Internet verbunden?\n\nVersuche die App neuzustarten' - ), - ), - ], - ), - ); + ), + ], + ), + ); @override void dispose() { diff --git a/lib/widget/placeholder_view.dart b/lib/widget/placeholder_view.dart index ce14d8d..27e114a 100644 --- a/lib/widget/placeholder_view.dart +++ b/lib/widget/placeholder_view.dart @@ -4,7 +4,12 @@ class PlaceholderView extends StatelessWidget { final IconData icon; final String text; final Widget? button; - const PlaceholderView({super.key, required this.icon, required this.text, this.button}); + const PlaceholderView({ + super.key, + required this.icon, + required this.text, + this.button, + }); @override Widget build(BuildContext context) => Scaffold( @@ -19,7 +24,7 @@ class PlaceholderView extends StatelessWidget { ), Text( text, - style: const TextStyle(fontSize: 20,), + style: const TextStyle(fontSize: 20), textAlign: TextAlign.center, ), const SizedBox(height: 30), diff --git a/lib/widget/share_position_origin.dart b/lib/widget/share_position_origin.dart index f110beb..2086f09 100644 --- a/lib/widget/share_position_origin.dart +++ b/lib/widget/share_position_origin.dart @@ -1,5 +1,10 @@ import 'package:flutter/material.dart'; class SharePositionOrigin { - static Rect get(BuildContext context) => Rect.fromLTWH(0, 0, MediaQuery.of(context).size.width, MediaQuery.of(context).size.height / 2); + static Rect get(BuildContext context) => Rect.fromLTWH( + 0, + 0, + MediaQuery.of(context).size.width, + MediaQuery.of(context).size.height / 2, + ); } diff --git a/lib/widget/string_extensions.dart b/lib/widget/string_extensions.dart index 21c3901..d802e9a 100644 --- a/lib/widget/string_extensions.dart +++ b/lib/widget/string_extensions.dart @@ -1,3 +1,4 @@ extension StringExtensions on String { - String capitalize() => '${this[0].toUpperCase()}${substring(1).toLowerCase()}'; + String capitalize() => + '${this[0].toUpperCase()}${substring(1).toLowerCase()}'; } diff --git a/lib/widget/unimplemented_dialog.dart b/lib/widget/unimplemented_dialog.dart index c02472d..dfae0eb 100644 --- a/lib/widget/unimplemented_dialog.dart +++ b/lib/widget/unimplemented_dialog.dart @@ -2,6 +2,10 @@ import 'package:flutter/material.dart'; class UnimplementedDialog { static void show(BuildContext context) { - showDialog(context: context, builder: (context) => const AlertDialog(content: Text('Not implemented yet'))); + showDialog( + context: context, + builder: (context) => + const AlertDialog(content: Text('Not implemented yet')), + ); } } diff --git a/lib/widget/user_avatar.dart b/lib/widget/user_avatar.dart index bcabac6..7e8cf4d 100644 --- a/lib/widget/user_avatar.dart +++ b/lib/widget/user_avatar.dart @@ -12,7 +12,12 @@ class UserAvatar extends StatefulWidget { final String id; final bool isGroup; final int size; - const UserAvatar({required this.id, this.isGroup = false, this.size = 20, super.key}); + const UserAvatar({ + required this.id, + this.isGroup = false, + this.size = 20, + super.key, + }); @override State createState() => _UserAvatarState(); @@ -79,10 +84,12 @@ class _UserAvatarState extends State { } static bool _looksLikeSvg(Uint8List bytes) { - final head = utf8.decode( - bytes.sublist(0, bytes.length < 256 ? bytes.length : 256), - allowMalformed: true, - ).trimLeft(); + final head = utf8 + .decode( + bytes.sublist(0, bytes.length < 256 ? bytes.length : 256), + allowMalformed: true, + ) + .trimLeft(); return head.startsWith(' - RichObjectString(type, 'id-$name', name, null, null); +RichObjectString _r( + String name, { + RichObjectStringObjectType type = RichObjectStringObjectType.user, +}) => RichObjectString(type, 'id-$name', name, null, null); void main() { group('RichObjectStringProcessor.parseToString', () { @@ -40,20 +42,18 @@ void main() { test('replaces every occurrence of the same placeholder', () { expect( - RichObjectStringProcessor.parseToString( - '{actor} {actor} {actor}', - {'actor': _r('A')}, - ), + RichObjectStringProcessor.parseToString('{actor} {actor} {actor}', { + 'actor': _r('A'), + }), 'A A A', ); }); test('placeholders with no matching key remain unchanged', () { expect( - RichObjectStringProcessor.parseToString( - '{actor} sah {file}', - {'actor': _r('Elias')}, - ), + RichObjectStringProcessor.parseToString('{actor} sah {file}', { + 'actor': _r('Elias'), + }), 'Elias sah {file}', ); }); @@ -67,8 +67,9 @@ void main() { test('messages without placeholders are returned verbatim', () { expect( - RichObjectStringProcessor.parseToString('reine Textnachricht', - {'actor': _r('A')}), + RichObjectStringProcessor.parseToString('reine Textnachricht', { + 'actor': _r('A'), + }), 'reine Textnachricht', ); }); diff --git a/test/api/webuntis/lesson_resolver_test.dart b/test/api/webuntis/lesson_resolver_test.dart index 4c900c7..01a675b 100644 --- a/test/api/webuntis/lesson_resolver_test.dart +++ b/test/api/webuntis/lesson_resolver_test.dart @@ -7,13 +7,12 @@ import 'package:marianum_mobile/state/app/modules/timetable/bloc/timetable_state TimetableState _state({ Set subjects = const {}, Set rooms = const {}, -}) => - TimetableState( - subjects: subjects.isEmpty ? null : GetSubjectsResponse(subjects), - rooms: rooms.isEmpty ? null : GetRoomsResponse(rooms), - startDate: DateTime(2026, 1, 1), - endDate: DateTime(2026, 12, 31), - ); +}) => TimetableState( + subjects: subjects.isEmpty ? null : GetSubjectsResponse(subjects), + rooms: rooms.isEmpty ? null : GetRoomsResponse(rooms), + startDate: DateTime(2026, 1, 1), + endDate: DateTime(2026, 12, 31), +); void main() { group('LessonResolver.resolveSubject', () { @@ -47,7 +46,13 @@ void main() { group('LessonResolver.resolveRoom', () { test('returns the matching room when the id is found', () { - final room = GetRoomsResponseObject(3, 'A1', 'Aula 1', true, 'Hauptgebäude'); + final room = GetRoomsResponseObject( + 3, + 'A1', + 'Aula 1', + true, + 'Hauptgebäude', + ); final state = _state(rooms: {room}); final result = LessonResolver.resolveRoom(state, 3); @@ -66,10 +71,14 @@ void main() { group('LessonFormatter', () { test('iconForCode picks the right icon per status', () { - expect(LessonFormatter.iconForCode('cancelled').codePoint, - isNot(LessonFormatter.iconForCode('irregular').codePoint)); - expect(LessonFormatter.iconForCode(null).codePoint, - isNot(LessonFormatter.iconForCode('cancelled').codePoint)); + expect( + LessonFormatter.iconForCode('cancelled').codePoint, + isNot(LessonFormatter.iconForCode('irregular').codePoint), + ); + expect( + LessonFormatter.iconForCode(null).codePoint, + isNot(LessonFormatter.iconForCode('cancelled').codePoint), + ); }); test('statusLabel maps known codes to German labels', () { @@ -88,7 +97,11 @@ void main() { test('formatLine renders name + (longname) + · extra in that order', () { expect( - LessonFormatter.formatLine('Mathe', longname: 'Mathematik', extra: 'Hauptgebäude'), + LessonFormatter.formatLine( + 'Mathe', + longname: 'Mathematik', + extra: 'Hauptgebäude', + ), 'Mathe (Mathematik) · Hauptgebäude', ); }); diff --git a/test/utils/debouncer_test.dart b/test/utils/debouncer_test.dart index 6084188..494c908 100644 --- a/test/utils/debouncer_test.dart +++ b/test/utils/debouncer_test.dart @@ -5,24 +5,34 @@ import 'package:marianum_mobile/utils/debouncer.dart'; void main() { // Each test is wrapped in fakeAsync so timers fire deterministically. group('Debouncer.debounce', () { - test('runs the action once after the delay elapses without further calls', () { - fakeAsync((async) { - var calls = 0; - Debouncer.debounce('tag', const Duration(milliseconds: 100), () => calls++); + test( + 'runs the action once after the delay elapses without further calls', + () { + fakeAsync((async) { + var calls = 0; + Debouncer.debounce( + 'tag', + const Duration(milliseconds: 100), + () => calls++, + ); - async.elapse(const Duration(milliseconds: 99)); - expect(calls, 0); + async.elapse(const Duration(milliseconds: 99)); + expect(calls, 0); - async.elapse(const Duration(milliseconds: 1)); - expect(calls, 1); - }); - }); + async.elapse(const Duration(milliseconds: 1)); + expect(calls, 1); + }); + }, + ); test('subsequent calls within the delay reset the timer (coalesce)', () { fakeAsync((async) { var calls = 0; void schedule() => Debouncer.debounce( - 'tag', const Duration(milliseconds: 100), () => calls++); + 'tag', + const Duration(milliseconds: 100), + () => calls++, + ); schedule(); async.elapse(const Duration(milliseconds: 80)); @@ -41,8 +51,16 @@ void main() { fakeAsync((async) { var aCalls = 0; var bCalls = 0; - Debouncer.debounce('a', const Duration(milliseconds: 100), () => aCalls++); - Debouncer.debounce('b', const Duration(milliseconds: 100), () => bCalls++); + Debouncer.debounce( + 'a', + const Duration(milliseconds: 100), + () => aCalls++, + ); + Debouncer.debounce( + 'b', + const Duration(milliseconds: 100), + () => bCalls++, + ); async.elapse(const Duration(milliseconds: 100)); expect(aCalls, 1); @@ -52,28 +70,67 @@ void main() { }); group('Debouncer.throttle', () { - test('first call runs immediately, subsequent calls within window are dropped', () { - fakeAsync((async) { - var calls = 0; - Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++); - expect(calls, 1, reason: 'throttle fires the first call synchronously'); + test( + 'first call runs immediately, subsequent calls within window are dropped', + () { + fakeAsync((async) { + var calls = 0; + Debouncer.throttle( + 'tag', + const Duration(milliseconds: 100), + () => calls++, + ); + expect( + calls, + 1, + reason: 'throttle fires the first call synchronously', + ); - Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++); - Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++); - expect(calls, 1, reason: 'subsequent calls within the gate are ignored'); + Debouncer.throttle( + 'tag', + const Duration(milliseconds: 100), + () => calls++, + ); + Debouncer.throttle( + 'tag', + const Duration(milliseconds: 100), + () => calls++, + ); + expect( + calls, + 1, + reason: 'subsequent calls within the gate are ignored', + ); - async.elapse(const Duration(milliseconds: 100)); - Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++); - expect(calls, 2, reason: 'after the window elapses, throttle fires again'); - }); - }); + async.elapse(const Duration(milliseconds: 100)); + Debouncer.throttle( + 'tag', + const Duration(milliseconds: 100), + () => calls++, + ); + expect( + calls, + 2, + reason: 'after the window elapses, throttle fires again', + ); + }); + }, + ); test('different tags throttle independently', () { fakeAsync((async) { var aCalls = 0; var bCalls = 0; - Debouncer.throttle('a', const Duration(milliseconds: 100), () => aCalls++); - Debouncer.throttle('b', const Duration(milliseconds: 100), () => bCalls++); + Debouncer.throttle( + 'a', + const Duration(milliseconds: 100), + () => aCalls++, + ); + Debouncer.throttle( + 'b', + const Duration(milliseconds: 100), + () => bCalls++, + ); expect(aCalls, 1); expect(bCalls, 1); @@ -86,7 +143,11 @@ void main() { test('cancels a pending debounce so the action never runs', () { fakeAsync((async) { var calls = 0; - Debouncer.debounce('tag', const Duration(milliseconds: 100), () => calls++); + Debouncer.debounce( + 'tag', + const Duration(milliseconds: 100), + () => calls++, + ); Debouncer.cancel('tag'); async.elapse(const Duration(milliseconds: 200)); @@ -94,19 +155,33 @@ void main() { }); }); - test('cancels an active throttle gate so the next call fires immediately', () { - fakeAsync((async) { - var calls = 0; - Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++); - expect(calls, 1); + test( + 'cancels an active throttle gate so the next call fires immediately', + () { + fakeAsync((async) { + var calls = 0; + Debouncer.throttle( + 'tag', + const Duration(milliseconds: 100), + () => calls++, + ); + expect(calls, 1); - Debouncer.cancel('tag'); - Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++); - expect(calls, 2, - reason: 'cancel removed the gate so the next throttle fires again'); + Debouncer.cancel('tag'); + Debouncer.throttle( + 'tag', + const Duration(milliseconds: 100), + () => calls++, + ); + expect( + calls, + 2, + reason: 'cancel removed the gate so the next throttle fires again', + ); - async.elapse(const Duration(milliseconds: 100)); - }); - }); + async.elapse(const Duration(milliseconds: 100)); + }); + }, + ); }); } diff --git a/test/view/files/sort_options_test.dart b/test/view/files/sort_options_test.dart index d7ddbe6..0f80d52 100644 --- a/test/view/files/sort_options_test.dart +++ b/test/view/files/sort_options_test.dart @@ -8,14 +8,13 @@ CacheableFile _file({ bool isDirectory = false, int? size, DateTime? modifiedAt, -}) => - CacheableFile( - path: '/$name', - isDirectory: isDirectory, - name: name, - size: size, - modifiedAt: modifiedAt, - ); +}) => CacheableFile( + path: '/$name', + isDirectory: isDirectory, + name: name, + size: size, + modifiedAt: modifiedAt, +); void main() { group('SortOptions.options', () { @@ -55,8 +54,14 @@ void main() { test('size comparator orders by file size when both known', () { final cmp = SortOptions.getOption(SortOption.size).compare; - expect(cmp(_file(name: 'a', size: 100), _file(name: 'b', size: 200)), lessThan(0)); - expect(cmp(_file(name: 'a', size: 200), _file(name: 'b', size: 100)), greaterThan(0)); + expect( + cmp(_file(name: 'a', size: 100), _file(name: 'b', size: 200)), + lessThan(0), + ); + expect( + cmp(_file(name: 'a', size: 200), _file(name: 'b', size: 100)), + greaterThan(0), + ); }); test('options map contains all enum values exactly once', () { @@ -67,8 +72,16 @@ void main() { group('ListFilesResponse.sortBy', () { final folderA = _file(name: 'A', isDirectory: true); final folderB = _file(name: 'B', isDirectory: true); - final fileA = _file(name: 'aaa', size: 100, modifiedAt: DateTime(2026, 1, 1)); - final fileB = _file(name: 'bbb', size: 50, modifiedAt: DateTime(2026, 5, 1)); + final fileA = _file( + name: 'aaa', + size: 100, + modifiedAt: DateTime(2026, 1, 1), + ); + final fileB = _file( + name: 'bbb', + size: 50, + modifiedAt: DateTime(2026, 5, 1), + ); // Note: sortBy uses a string-buffer sort + compareTo descending. The actual // list ordering reflects what users see in the file list. @@ -81,7 +94,10 @@ void main() { test('foldersToTop=false intermixes folders and files', () { final response = ListFilesResponse({fileA, fileB, folderA, folderB}); - final sorted = response.sortBy(sortOption: SortOption.name, foldersToTop: false); + final sorted = response.sortBy( + sortOption: SortOption.name, + foldersToTop: false, + ); final folderPositions = []; for (var i = 0; i < sorted.length; i++) { if (sorted[i].isDirectory) folderPositions.add(i); @@ -94,9 +110,15 @@ void main() { test('reversed flips the order within each section', () { final response = ListFilesResponse({fileA, fileB}); - final asc = response.sortBy(sortOption: SortOption.name, foldersToTop: false); + final asc = response.sortBy( + sortOption: SortOption.name, + foldersToTop: false, + ); final desc = response.sortBy( - sortOption: SortOption.name, foldersToTop: false, reversed: true); + sortOption: SortOption.name, + foldersToTop: false, + reversed: true, + ); expect(desc, asc.reversed.toList()); }); diff --git a/test/view/marianum_dates/event_formatter_test.dart b/test/view/marianum_dates/event_formatter_test.dart index 3dabafc..7d14a1b 100644 --- a/test/view/marianum_dates/event_formatter_test.dart +++ b/test/view/marianum_dates/event_formatter_test.dart @@ -7,15 +7,14 @@ MarianumDate _event({ required DateTime start, required DateTime end, bool isAllDay = false, -}) => - MarianumDate( - uid: 't', - title: 't', - description: null, - start: start, - end: end, - isAllDay: isAllDay, - ); +}) => MarianumDate( + uid: 't', + title: 't', + description: null, + start: start, + end: end, + isAllDay: isAllDay, +); void main() { setUpAll(() async { @@ -66,23 +65,32 @@ void main() { expect(EventFormatter.longRange(e), '08.05.2026 · Ganztägig'); }); - test('all-day multi-day shows inclusive end (one day before exclusive end)', () { - final e = _event( - start: DateTime(2026, 5, 8), - end: DateTime(2026, 5, 11), // exclusive → display "until 10.05." - isAllDay: true, - ); - expect(EventFormatter.longRange(e), '08.05.2026 – 10.05.2026 · Ganztägig'); - }); + test( + 'all-day multi-day shows inclusive end (one day before exclusive end)', + () { + final e = _event( + start: DateTime(2026, 5, 8), + end: DateTime(2026, 5, 11), // exclusive → display "until 10.05." + isAllDay: true, + ); + expect( + EventFormatter.longRange(e), + '08.05.2026 – 10.05.2026 · Ganztägig', + ); + }, + ); - test('all-day event whose end equals start (degenerate) renders as single day', () { - final e = _event( - start: DateTime(2026, 5, 8), - end: DateTime(2026, 5, 8), - isAllDay: true, - ); - expect(EventFormatter.longRange(e), '08.05.2026 · Ganztägig'); - }); + test( + 'all-day event whose end equals start (degenerate) renders as single day', + () { + final e = _event( + start: DateTime(2026, 5, 8), + end: DateTime(2026, 5, 8), + isAllDay: true, + ); + expect(EventFormatter.longRange(e), '08.05.2026 · Ganztägig'); + }, + ); test('zero-length same-day timed event shows single time', () { final at = DateTime(2026, 5, 8, 9, 30); @@ -103,7 +111,10 @@ void main() { start: DateTime(2026, 5, 8, 9), end: DateTime(2026, 5, 9, 11), ); - expect(EventFormatter.longRange(e), '08.05.2026 09:00 – 09.05.2026 11:00'); + expect( + EventFormatter.longRange(e), + '08.05.2026 09:00 – 09.05.2026 11:00', + ); }); }); } diff --git a/test/view/timetable/calendar_logic_test.dart b/test/view/timetable/calendar_logic_test.dart index fcd0a63..5e06910 100644 --- a/test/view/timetable/calendar_logic_test.dart +++ b/test/view/timetable/calendar_logic_test.dart @@ -17,18 +17,18 @@ Appointment _appt({ bool isAllDay = false, Object? id, String? rrule, -}) => - Appointment( - id: id, - startTime: start, - endTime: end, - subject: subject, - color: Colors.blue, - isAllDay: isAllDay, - recurrenceRule: rrule, - ); +}) => Appointment( + id: id, + startTime: start, + endTime: end, + subject: subject, + color: Colors.blue, + isAllDay: isAllDay, + recurrenceRule: rrule, +); -GetTimetableResponseObject _lesson({String? code}) => GetTimetableResponseObject( +GetTimetableResponseObject _lesson({String? code}) => + GetTimetableResponseObject( id: 0, date: 0, startTime: 0, @@ -41,21 +41,25 @@ GetTimetableResponseObject _lesson({String? code}) => GetTimetableResponseObject ); CustomTimetableEvent _customEvent() => CustomTimetableEvent( - id: 'x', - title: '', - description: '', - startDate: DateTime(2026), - endDate: DateTime(2026), - color: null, - rrule: '', - createdAt: DateTime(2026), - updatedAt: DateTime(2026), - ); + id: 'x', + title: '', + description: '', + startDate: DateTime(2026), + endDate: DateTime(2026), + color: null, + rrule: '', + createdAt: DateTime(2026), + updatedAt: DateTime(2026), +); void main() { group('isAllDayLike', () { test('explicit isAllDay flag wins', () { - final a = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 10), isAllDay: true); + final a = _appt( + start: _at(2026, 5, 8, 9), + end: _at(2026, 5, 8, 10), + isAllDay: true, + ); expect(isAllDayLike(a), isTrue); }); @@ -69,11 +73,20 @@ void main() { expect(isAllDayLike(a), isTrue); }); - test('Duration.inHours truncation does not let a 9h 30min event escape', () { - final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 17, 30)); - expect(isAllDayLike(a), isTrue, - reason: 'inHours would say 9; we compare in minutes (570 ≥ 480)'); - }); + test( + 'Duration.inHours truncation does not let a 9h 30min event escape', + () { + final a = _appt( + start: _at(2026, 5, 8, 8), + end: _at(2026, 5, 8, 17, 30), + ); + expect( + isAllDayLike(a), + isTrue, + reason: 'inHours would say 9; we compare in minutes (570 ≥ 480)', + ); + }, + ); }); group('isOutsideSchoolHours', () { @@ -85,7 +98,11 @@ void main() { }); test('all-day-like events are always outside', () { - final a = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 10), isAllDay: true); + final a = _appt( + start: _at(2026, 5, 8, 9), + end: _at(2026, 5, 8, 10), + isAllDay: true, + ); expect(isOutsideSchoolHours(a), isTrue); }); @@ -120,7 +137,9 @@ void main() { test('single non-recurring lesson lands in the right day bucket', () { final wednesday9 = _appt( - start: _at(2026, 5, 6, 9), end: _at(2026, 5, 6, 10)); + start: _at(2026, 5, 6, 9), + end: _at(2026, 5, 6, 10), + ); final result = partitionAppointmentsForWeek([wednesday9], monday); expect(result.inside[0], isEmpty); expect(result.inside[1], isEmpty); @@ -131,9 +150,10 @@ void main() { test('all-day events go to the outside bucket on their day', () { final tuesdayAllDay = _appt( - start: _at(2026, 5, 5), - end: _at(2026, 5, 6), - isAllDay: true); + start: _at(2026, 5, 5), + end: _at(2026, 5, 6), + isAllDay: true, + ); final result = partitionAppointmentsForWeek([tuesdayAllDay], monday); expect(result.inside.expand((e) => e), isEmpty); expect(result.outside[1], hasLength(1)); @@ -141,9 +161,13 @@ void main() { test('events outside the visible week are dropped', () { final lastWeek = _appt( - start: _at(2026, 4, 27, 9), end: _at(2026, 4, 27, 10)); + start: _at(2026, 4, 27, 9), + end: _at(2026, 4, 27, 10), + ); final nextWeek = _appt( - start: _at(2026, 5, 11, 9), end: _at(2026, 5, 11, 10)); + start: _at(2026, 5, 11, 9), + end: _at(2026, 5, 11, 10), + ); final result = partitionAppointmentsForWeek([lastWeek, nextWeek], monday); expect(result.inside.expand((e) => e), isEmpty); expect(result.outside.expand((e) => e), isEmpty); @@ -151,7 +175,9 @@ void main() { test('weekend events (Sat/Sun) are dropped, only Mon–Fri counted', () { final saturday = _appt( - start: _at(2026, 5, 9, 9), end: _at(2026, 5, 9, 10)); + start: _at(2026, 5, 9, 9), + end: _at(2026, 5, 9, 10), + ); final result = partitionAppointmentsForWeek([saturday], monday); expect(result.inside.expand((e) => e), isEmpty); }); @@ -160,20 +186,25 @@ void main() { // Anchor on the Monday before our visible week, repeating weekly. // The visible week's Monday should produce one occurrence. final anchor = _appt( - start: _at(2026, 4, 27, 9), - end: _at(2026, 4, 27, 10), - rrule: 'RRULE:FREQ=WEEKLY;BYDAY=MO'); + start: _at(2026, 4, 27, 9), + end: _at(2026, 4, 27, 10), + rrule: 'RRULE:FREQ=WEEKLY;BYDAY=MO', + ); final result = partitionAppointmentsForWeek([anchor], monday); - expect(result.inside[0], hasLength(1), - reason: 'Monday of the visible week should get one expansion'); + expect( + result.inside[0], + hasLength(1), + reason: 'Monday of the visible week should get one expansion', + ); expect(result.inside[0].first.startTime, _at(2026, 5, 4, 9)); }); test('malformed RRULE falls back to placing the anchor', () { final broken = _appt( - start: _at(2026, 5, 6, 9), - end: _at(2026, 5, 6, 10), - rrule: 'this is not a valid rrule'); + start: _at(2026, 5, 6, 9), + end: _at(2026, 5, 6, 10), + rrule: 'this is not a valid rrule', + ); final result = partitionAppointmentsForWeek([broken], monday); expect(result.inside[2], hasLength(1)); }); @@ -181,16 +212,21 @@ void main() { group('PeriodLayout', () { final p1 = const LessonPeriod( - name: '1', start: TimeOfDay(hour: 8, minute: 0), end: TimeOfDay(hour: 9, minute: 0)); + name: '1', + start: TimeOfDay(hour: 8, minute: 0), + end: TimeOfDay(hour: 9, minute: 0), + ); final brk = const LessonPeriod( - name: 'Pause', - start: TimeOfDay(hour: 9, minute: 0), - end: TimeOfDay(hour: 9, minute: 15), - isBreak: true); + name: 'Pause', + start: TimeOfDay(hour: 9, minute: 0), + end: TimeOfDay(hour: 9, minute: 15), + isBreak: true, + ); final p2 = const LessonPeriod( - name: '2', - start: TimeOfDay(hour: 9, minute: 15), - end: TimeOfDay(hour: 10, minute: 15)); + name: '2', + start: TimeOfDay(hour: 9, minute: 15), + end: TimeOfDay(hour: 10, minute: 15), + ); final layout = PeriodLayout( periods: [p1, brk, p2], @@ -249,7 +285,11 @@ void main() { expect(result, hasLength(2)); for (final cell in result) { expect(cell.lane, 0); - expect(cell.laneCount, 1, reason: 'separate clusters → laneCount=1 each'); + expect( + cell.laneCount, + 1, + reason: 'separate clusters → laneCount=1 each', + ); } }); @@ -262,85 +302,121 @@ void main() { expect(result.every((c) => c.laneCount == 2), isTrue); }); - test('three overlapping appointments with maxLanes=2 collapse the third into overflow', () { - final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 11)); - final b = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 11)); - final c = _appt(start: _at(2026, 5, 8, 10), end: _at(2026, 5, 8, 11)); - final result = assignLanes([a, b, c], maxLanes: 2); + test( + 'three overlapping appointments with maxLanes=2 collapse the third into overflow', + () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 11)); + final b = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 11)); + final c = _appt(start: _at(2026, 5, 8, 10), end: _at(2026, 5, 8, 11)); + final result = assignLanes([a, b, c], maxLanes: 2); - final visible = result.whereType().toList(); - final overflow = result.whereType().toList(); - expect(visible, hasLength(1), reason: 'maxLanes-1 = 1 visible appointment'); - expect(overflow, hasLength(1)); - expect(overflow.first.appointments, hasLength(2)); - expect(overflow.first.lane, 1); - expect(overflow.first.laneCount, 2); - }); + final visible = result.whereType().toList(); + final overflow = result.whereType().toList(); + expect( + visible, + hasLength(1), + reason: 'maxLanes-1 = 1 visible appointment', + ); + expect(overflow, hasLength(1)); + expect(overflow.first.appointments, hasLength(2)); + expect(overflow.first.lane, 1); + expect(overflow.first.laneCount, 2); + }, + ); test('CustomAppointment beats a regular lesson on lane priority', () { final custom = _appt( id: CustomAppointment(_customEvent()), - start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10), + start: _at(2026, 5, 8, 8), + end: _at(2026, 5, 8, 10), ); final regular = _appt( id: WebuntisAppointment(_lesson()), - start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10), + start: _at(2026, 5, 8, 8), + end: _at(2026, 5, 8, 10), ); - final result = assignLanes([regular, custom], maxLanes: 2) - .whereType() - .toList(); + final result = assignLanes([ + regular, + custom, + ], maxLanes: 2).whereType().toList(); // Same startTime → priority decides: custom (0) goes left of regular (2). - final customCell = result.firstWhere((c) => c.appointment.id is CustomAppointment); - final regularCell = result.firstWhere((c) => c.appointment.id is WebuntisAppointment); + final customCell = result.firstWhere( + (c) => c.appointment.id is CustomAppointment, + ); + final regularCell = result.firstWhere( + (c) => c.appointment.id is WebuntisAppointment, + ); expect(customCell.lane, lessThan(regularCell.lane)); }); test('cancelled lesson lands left of a non-cancelled one on tie', () { final cancelled = _appt( id: WebuntisAppointment(_lesson(code: 'cancelled')), - start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10), + start: _at(2026, 5, 8, 8), + end: _at(2026, 5, 8, 10), ); final regular = _appt( id: WebuntisAppointment(_lesson()), - start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10), + start: _at(2026, 5, 8, 8), + end: _at(2026, 5, 8, 10), ); - final result = assignLanes([regular, cancelled], maxLanes: 2) - .whereType() - .toList(); + final result = assignLanes([ + regular, + cancelled, + ], maxLanes: 2).whereType().toList(); String? codeOf(LaidOutAppointment c) { final id = c.appointment.id; return id is WebuntisAppointment ? id.lesson.code : null; } + final cancelledCell = result.firstWhere((c) => codeOf(c) == 'cancelled'); final regularCell = result.firstWhere((c) => codeOf(c) == null); expect(cancelledCell.lane, lessThan(regularCell.lane)); }); - test('overflow time-range spans earliest start to latest end of collapsed appointments', () { - // 4 overlapping appointments, maxLanes = 2 → 1 visible + overflow of 3. - final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 12)); - final b = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 10)); - final c = _appt(start: _at(2026, 5, 8, 9, 30), end: _at(2026, 5, 8, 14)); - final d = _appt(start: _at(2026, 5, 8, 10), end: _at(2026, 5, 8, 11)); + test( + 'overflow time-range spans earliest start to latest end of collapsed appointments', + () { + // 4 overlapping appointments, maxLanes = 2 → 1 visible + overflow of 3. + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 12)); + final b = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 10)); + final c = _appt( + start: _at(2026, 5, 8, 9, 30), + end: _at(2026, 5, 8, 14), + ); + final d = _appt(start: _at(2026, 5, 8, 10), end: _at(2026, 5, 8, 11)); - final overflow = assignLanes([a, b, c, d], maxLanes: 2) - .whereType() - .single; - expect(overflow.appointments, hasLength(3)); - expect(overflow.startTime, _at(2026, 5, 8, 9), - reason: 'earliest non-visible start time'); - expect(overflow.endTime, _at(2026, 5, 8, 14), - reason: 'latest non-visible end time'); - }); + final overflow = assignLanes([ + a, + b, + c, + d, + ], maxLanes: 2).whereType().single; + expect(overflow.appointments, hasLength(3)); + expect( + overflow.startTime, + _at(2026, 5, 8, 9), + reason: 'earliest non-visible start time', + ); + expect( + overflow.endTime, + _at(2026, 5, 8, 14), + reason: 'latest non-visible end time', + ); + }, + ); test('empty input returns an empty list', () { expect(assignLanes(const [], maxLanes: 2), isEmpty); }); test('asserts maxLanes >= 2', () { - expect(() => assignLanes(const [], maxLanes: 1), throwsA(isA())); + expect( + () => assignLanes(const [], maxLanes: 1), + throwsA(isA()), + ); }); }); } diff --git a/test/widget/async_action_controller_test.dart b/test/widget/async_action_controller_test.dart index 8bf8d18..a91489b 100644 --- a/test/widget/async_action_controller_test.dart +++ b/test/widget/async_action_controller_test.dart @@ -14,27 +14,33 @@ void main() { seenBusyInsideCallback = controller.busy; }); - expect(seenBusyInsideCallback, isTrue, - reason: 'busy must be true while the callback is running'); + expect( + seenBusyInsideCallback, + isTrue, + reason: 'busy must be true while the callback is running', + ); expect(ok, isTrue); expect(controller.busy, isFalse); expect(controller.error, isNull); }); - test('captures mapped error message on failure and returns false', () async { - final controller = AsyncActionController(); - addTearDown(controller.dispose); + test( + 'captures mapped error message on failure and returns false', + () async { + final controller = AsyncActionController(); + addTearDown(controller.dispose); - final ok = await controller.run( - () async => throw Exception('boom'), - errorBuilder: (e) => 'custom: $e', - ); + final ok = await controller.run( + () async => throw Exception('boom'), + errorBuilder: (e) => 'custom: $e', + ); - expect(ok, isFalse); - expect(controller.busy, isFalse); - expect(controller.error, contains('custom:')); - expect(controller.error, contains('boom')); - }); + expect(ok, isFalse); + expect(controller.busy, isFalse); + expect(controller.error, contains('custom:')); + expect(controller.error, contains('boom')); + }, + ); test('rejects re-entry while busy', () async { final controller = AsyncActionController(); @@ -51,8 +57,12 @@ void main() { expect(controller.busy, isTrue); final reentrant = await controller.run(() async {}); - expect(reentrant, isFalse, - reason: 'second run while busy must be rejected without invoking callback'); + expect( + reentrant, + isFalse, + reason: + 'second run while busy must be rejected without invoking callback', + ); firstCanFinish.complete(); expect(await firstFuture, isTrue);

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