From b6d06dd3b48e77205ff25e8dba260541a9d5393c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 31 May 2026 21:29:16 +0200 Subject: [PATCH] implemented foreign timetable support for students, teachers, rooms, and classes, including a searchable element picker with favorites support, introduced a capabilities system for feature gating, refactored the timetable UI into a reusable `TimetableCalendarView` component, and redesigned the chat input field with a unified emoji picker and integrated attachment actions. --- .../get_capabilities/get_capabilities.dart | 26 ++ .../get_capabilities_response.dart | 19 + .../get_capabilities_response.g.dart | 17 + .../timetable_get_classes.dart | 26 ++ .../timetable_get_classes_response.dart | 33 ++ .../timetable_get_classes_response.g.dart | 40 ++ .../timetable_element_type.dart | 43 ++ .../timetable_get_element_week.dart | 36 ++ .../timetable_get_students.dart | 26 ++ .../timetable_get_students_response.dart | 35 ++ .../timetable_get_students_response.g.dart | 42 ++ .../timetable_get_teachers.dart | 29 ++ .../timetable_get_teachers_response.dart | 36 ++ .../timetable_get_teachers_response.g.dart | 45 ++ lib/main.dart | 19 +- lib/routing/app_routes.dart | 20 + .../capabilities/bloc/capabilities_cubit.dart | 48 +++ .../capabilities/bloc/capabilities_state.dart | 18 + .../bloc/capabilities_state.freezed.dart | 286 +++++++++++++ .../bloc/capabilities_state.g.dart | 19 + .../bloc/foreign_timetable_bloc.dart | 212 ++++++++++ .../foreign_timetable_data_provider.dart | 64 +++ .../foreign_timetable_repository.dart | 12 + lib/storage/settings.dart | 3 + lib/storage/settings.g.dart | 4 + lib/storage/timetable_favorites_settings.dart | 49 +++ .../timetable_favorites_settings.g.dart | 44 ++ .../element_picker_page.dart | 396 ++++++++++++++++++ .../widgets/event_list_tile.dart | 2 +- .../pages/settings/data/default_settings.dart | 2 + .../pages/talk/details/message_reactions.dart | 3 +- .../talk/widgets/chat_bubble_reactions.dart | 10 +- .../widgets/chat_message_options_dialog.dart | 59 +-- .../pages/talk/widgets/chat_textfield.dart | 219 ++++++---- .../appointment_details_dispatcher.dart | 7 +- .../pages/timetable/details/lesson_sheet.dart | 5 +- lib/view/pages/timetable/timetable.dart | 357 +++++++++------- .../widgets/timetable_calendar_view.dart | 180 ++++++++ lib/widget/emoji_picker_dialog.dart | 64 +++ lib/widget/emoji_text.dart | 36 ++ test/api/timetable_element_type_test.dart | 24 ++ 41 files changed, 2325 insertions(+), 290 deletions(-) create mode 100644 lib/api/marianumconnect/queries/get_capabilities/get_capabilities.dart create mode 100644 lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.dart create mode 100644 lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart create mode 100644 lib/api/marianumconnect/queries/timetable_get_classes/timetable_get_classes.dart create mode 100644 lib/api/marianumconnect/queries/timetable_get_classes/timetable_get_classes_response.dart create mode 100644 lib/api/marianumconnect/queries/timetable_get_classes/timetable_get_classes_response.g.dart create mode 100644 lib/api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart create mode 100644 lib/api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.dart create mode 100644 lib/api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart create mode 100644 lib/api/marianumconnect/queries/timetable_get_students/timetable_get_students_response.dart create mode 100644 lib/api/marianumconnect/queries/timetable_get_students/timetable_get_students_response.g.dart create mode 100644 lib/api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart create mode 100644 lib/api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers_response.dart create mode 100644 lib/api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers_response.g.dart create mode 100644 lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart create mode 100644 lib/state/app/modules/capabilities/bloc/capabilities_state.dart create mode 100644 lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart create mode 100644 lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart create mode 100644 lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart create mode 100644 lib/state/app/modules/foreign_timetable/data_provider/foreign_timetable_data_provider.dart create mode 100644 lib/state/app/modules/foreign_timetable/repository/foreign_timetable_repository.dart create mode 100644 lib/storage/timetable_favorites_settings.dart create mode 100644 lib/storage/timetable_favorites_settings.g.dart create mode 100644 lib/view/pages/foreign_timetable/element_picker_page.dart create mode 100644 lib/view/pages/timetable/widgets/timetable_calendar_view.dart create mode 100644 lib/widget/emoji_picker_dialog.dart create mode 100644 lib/widget/emoji_text.dart create mode 100644 test/api/timetable_element_type_test.dart diff --git a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities.dart b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities.dart new file mode 100644 index 0000000..5e9485f --- /dev/null +++ b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities.dart @@ -0,0 +1,26 @@ +import 'package:dio/dio.dart'; + +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; +import 'get_capabilities_response.dart'; + +/// Fetches the current user's mobile capability flags from +/// `GET /api/mobile/v1/me/capabilities`. Goes through the shared dio singleton +/// so the bearer token is attached automatically. +class GetCapabilities { + final Dio _dio; + + GetCapabilities({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); + + Future run() async { + try { + final response = await _dio.get>( + MarianumConnectEndpoint.resolve('me/capabilities'), + ); + return CapabilitiesResponse.fromJson(response.data!); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.dart b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.dart new file mode 100644 index 0000000..1974d9b --- /dev/null +++ b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.dart @@ -0,0 +1,19 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'get_capabilities_response.g.dart'; + +/// Slimmed-down capability flags the mobile UI gates features on. The backend +/// only returns the handful of permissions the app actually consumes — not a +/// full permission dump. Unknown/missing fields default to `false` so a stale +/// client never accidentally enables a feature it shouldn't. +@JsonSerializable() +class CapabilitiesResponse { + @JsonKey(defaultValue: false) + final bool viewForeignTimetables; + + CapabilitiesResponse({required this.viewForeignTimetables}); + + factory CapabilitiesResponse.fromJson(Map json) => + _$CapabilitiesResponseFromJson(json); + Map toJson() => _$CapabilitiesResponseToJson(this); +} diff --git a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart new file mode 100644 index 0000000..f55593b --- /dev/null +++ b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'get_capabilities_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CapabilitiesResponse _$CapabilitiesResponseFromJson( + Map json, +) => CapabilitiesResponse( + viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false, +); + +Map _$CapabilitiesResponseToJson( + CapabilitiesResponse instance, +) => {'viewForeignTimetables': instance.viewForeignTimetables}; diff --git a/lib/api/marianumconnect/queries/timetable_get_classes/timetable_get_classes.dart b/lib/api/marianumconnect/queries/timetable_get_classes/timetable_get_classes.dart new file mode 100644 index 0000000..e067779 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_classes/timetable_get_classes.dart @@ -0,0 +1,26 @@ +import 'package:dio/dio.dart'; + +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; +import 'timetable_get_classes_response.dart'; + +class TimetableGetClasses { + final Dio _dio; + + TimetableGetClasses({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); + + Future run() async { + try { + final response = await _dio.get>( + MarianumConnectEndpoint.resolve('timetable/elements/classes'), + ); + final list = response.data! + .map((e) => McTimetableClass.fromJson(e as Map)) + .toList(); + return TimetableGetClassesResponse(result: list); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/timetable_get_classes/timetable_get_classes_response.dart b/lib/api/marianumconnect/queries/timetable_get_classes/timetable_get_classes_response.dart new file mode 100644 index 0000000..45ccc81 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_classes/timetable_get_classes_response.dart @@ -0,0 +1,33 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../../api_response.dart'; + +part 'timetable_get_classes_response.g.dart'; + +@JsonSerializable(explicitToJson: true) +class McTimetableClass { + final int id; + final String shortName; + final String longName; + + McTimetableClass({ + required this.id, + required this.shortName, + required this.longName, + }); + + factory McTimetableClass.fromJson(Map json) => + _$McTimetableClassFromJson(json); + Map toJson() => _$McTimetableClassToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class TimetableGetClassesResponse extends ApiResponse { + final List result; + + TimetableGetClassesResponse({required this.result}); + + factory TimetableGetClassesResponse.fromJson(Map json) => + _$TimetableGetClassesResponseFromJson(json); + Map toJson() => _$TimetableGetClassesResponseToJson(this); +} diff --git a/lib/api/marianumconnect/queries/timetable_get_classes/timetable_get_classes_response.g.dart b/lib/api/marianumconnect/queries/timetable_get_classes/timetable_get_classes_response.g.dart new file mode 100644 index 0000000..86f23c2 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_classes/timetable_get_classes_response.g.dart @@ -0,0 +1,40 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'timetable_get_classes_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +McTimetableClass _$McTimetableClassFromJson(Map json) => + McTimetableClass( + id: (json['id'] as num).toInt(), + shortName: json['shortName'] as String, + longName: json['longName'] as String, + ); + +Map _$McTimetableClassToJson(McTimetableClass instance) => + { + 'id': instance.id, + 'shortName': instance.shortName, + 'longName': instance.longName, + }; + +TimetableGetClassesResponse _$TimetableGetClassesResponseFromJson( + Map json, +) => + TimetableGetClassesResponse( + result: (json['result'] as List) + .map((e) => McTimetableClass.fromJson(e as Map)) + .toList(), + ) + ..headers = (json['headers'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ); + +Map _$TimetableGetClassesResponseToJson( + TimetableGetClassesResponse instance, +) => { + 'headers': ?instance.headers, + 'result': instance.result.map((e) => e.toJson()).toList(), +}; diff --git a/lib/api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart b/lib/api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart new file mode 100644 index 0000000..2688cb8 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart @@ -0,0 +1,43 @@ +/// A concrete, selectable timetable element: its type, WebUntis element id and +/// a human-readable label (room short name, abbreviated student name, …). Used +/// to hand a picker selection back to the timetable view and to drive the +/// inline foreign-plan rendering. +typedef TimetableElementRef = ({TimetableElementType type, int id, String label}); + +/// The four kinds of timetable elements whose schedule can be requested via +/// `timetable/{type}/{id}`. `schoolClass` is named to avoid the reserved Dart +/// keyword `class`; its [pathSegment] maps back to the backend's `class`. +enum TimetableElementType { + student, + teacher, + room, + schoolClass; + + /// Path segment used in the backend timetable endpoint URL. + String get pathSegment { + switch (this) { + case TimetableElementType.student: + return 'student'; + case TimetableElementType.teacher: + return 'teacher'; + case TimetableElementType.room: + return 'room'; + case TimetableElementType.schoolClass: + return 'class'; + } + } + + /// Singular German label for the UI (picker segments, hints). + String get label { + switch (this) { + case TimetableElementType.student: + return 'Schüler'; + case TimetableElementType.teacher: + return 'Lehrer'; + case TimetableElementType.room: + return 'Raum'; + case TimetableElementType.schoolClass: + return 'Klasse'; + } + } +} diff --git a/lib/api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.dart b/lib/api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.dart new file mode 100644 index 0000000..c21e4a9 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.dart @@ -0,0 +1,36 @@ +import 'package:dio/dio.dart'; + +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; +import '../timetable_get_week/timetable_get_week_response.dart'; +import 'timetable_element_type.dart'; + +/// Fetches a foreign element's weekly timetable from +/// `timetable/{student|teacher|room|class}/{id}`. The response shape is +/// identical to `timetable/me`, so [TimetableGetWeekResponse] is reused. +class TimetableGetElementWeek { + final Dio _dio; + + TimetableGetElementWeek({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); + + Future run({ + required TimetableElementType type, + required int id, + required DateTime from, + required DateTime until, + }) async { + try { + final response = await _dio.get>( + MarianumConnectEndpoint.resolve('timetable/${type.pathSegment}/$id'), + queryParameters: {'from': _format(from), 'until': _format(until)}, + ); + return TimetableGetWeekResponse.fromJson(response.data!); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } + + String _format(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; +} diff --git a/lib/api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart b/lib/api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart new file mode 100644 index 0000000..ab28251 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart @@ -0,0 +1,26 @@ +import 'package:dio/dio.dart'; + +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; +import 'timetable_get_students_response.dart'; + +class TimetableGetStudents { + final Dio _dio; + + TimetableGetStudents({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); + + Future run() async { + try { + final response = await _dio.get>( + MarianumConnectEndpoint.resolve('timetable/elements/students'), + ); + final list = response.data! + .map((e) => McTimetableStudent.fromJson(e as Map)) + .toList(); + return TimetableGetStudentsResponse(result: list); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/timetable_get_students/timetable_get_students_response.dart b/lib/api/marianumconnect/queries/timetable_get_students/timetable_get_students_response.dart new file mode 100644 index 0000000..c3255a7 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_students/timetable_get_students_response.dart @@ -0,0 +1,35 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../../api_response.dart'; + +part 'timetable_get_students_response.g.dart'; + +@JsonSerializable(explicitToJson: true) +class McTimetableStudent { + final int id; + final String firstName; + final String lastName; + final String displayName; + + McTimetableStudent({ + required this.id, + required this.firstName, + required this.lastName, + required this.displayName, + }); + + factory McTimetableStudent.fromJson(Map json) => + _$McTimetableStudentFromJson(json); + Map toJson() => _$McTimetableStudentToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class TimetableGetStudentsResponse extends ApiResponse { + final List result; + + TimetableGetStudentsResponse({required this.result}); + + factory TimetableGetStudentsResponse.fromJson(Map json) => + _$TimetableGetStudentsResponseFromJson(json); + Map toJson() => _$TimetableGetStudentsResponseToJson(this); +} diff --git a/lib/api/marianumconnect/queries/timetable_get_students/timetable_get_students_response.g.dart b/lib/api/marianumconnect/queries/timetable_get_students/timetable_get_students_response.g.dart new file mode 100644 index 0000000..8588de3 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_students/timetable_get_students_response.g.dart @@ -0,0 +1,42 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'timetable_get_students_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +McTimetableStudent _$McTimetableStudentFromJson(Map json) => + McTimetableStudent( + id: (json['id'] as num).toInt(), + firstName: json['firstName'] as String, + lastName: json['lastName'] as String, + displayName: json['displayName'] as String, + ); + +Map _$McTimetableStudentToJson(McTimetableStudent instance) => + { + 'id': instance.id, + 'firstName': instance.firstName, + 'lastName': instance.lastName, + 'displayName': instance.displayName, + }; + +TimetableGetStudentsResponse _$TimetableGetStudentsResponseFromJson( + Map json, +) => + TimetableGetStudentsResponse( + result: (json['result'] as List) + .map((e) => McTimetableStudent.fromJson(e as Map)) + .toList(), + ) + ..headers = (json['headers'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ); + +Map _$TimetableGetStudentsResponseToJson( + TimetableGetStudentsResponse instance, +) => { + 'headers': ?instance.headers, + 'result': instance.result.map((e) => e.toJson()).toList(), +}; diff --git a/lib/api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart b/lib/api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart new file mode 100644 index 0000000..4027808 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart @@ -0,0 +1,29 @@ +import 'package:dio/dio.dart'; + +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; +import 'timetable_get_teachers_response.dart'; + +class TimetableGetTeachers { + final Dio _dio; + + TimetableGetTeachers({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); + + Future run() async { + try { + final response = await _dio.get>( + MarianumConnectEndpoint.resolve('timetable/elements/teachers'), + ); + final list = response.data! + .map( + (e) => + McTimetableTeacherElement.fromJson(e as Map), + ) + .toList(); + return TimetableGetTeachersResponse(result: list); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers_response.dart b/lib/api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers_response.dart new file mode 100644 index 0000000..b7ca860 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers_response.dart @@ -0,0 +1,36 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../../api_response.dart'; + +part 'timetable_get_teachers_response.g.dart'; + +/// Picker list entry for a teacher. Named `...Element` to avoid colliding with +/// `McTimetableteacher` from the week response, which models the teacher *of a +/// lesson* (with substitution fields) rather than a selectable element. +@JsonSerializable(explicitToJson: true) +class McTimetableTeacherElement { + final int id; + final String shortName; + final String displayName; + + McTimetableTeacherElement({ + required this.id, + required this.shortName, + required this.displayName, + }); + + factory McTimetableTeacherElement.fromJson(Map json) => + _$McTimetableTeacherElementFromJson(json); + Map toJson() => _$McTimetableTeacherElementToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class TimetableGetTeachersResponse extends ApiResponse { + final List result; + + TimetableGetTeachersResponse({required this.result}); + + factory TimetableGetTeachersResponse.fromJson(Map json) => + _$TimetableGetTeachersResponseFromJson(json); + Map toJson() => _$TimetableGetTeachersResponseToJson(this); +} diff --git a/lib/api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers_response.g.dart b/lib/api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers_response.g.dart new file mode 100644 index 0000000..ca72e62 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers_response.g.dart @@ -0,0 +1,45 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'timetable_get_teachers_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +McTimetableTeacherElement _$McTimetableTeacherElementFromJson( + Map json, +) => McTimetableTeacherElement( + id: (json['id'] as num).toInt(), + shortName: json['shortName'] as String, + displayName: json['displayName'] as String, +); + +Map _$McTimetableTeacherElementToJson( + McTimetableTeacherElement instance, +) => { + 'id': instance.id, + 'shortName': instance.shortName, + 'displayName': instance.displayName, +}; + +TimetableGetTeachersResponse _$TimetableGetTeachersResponseFromJson( + Map json, +) => + TimetableGetTeachersResponse( + result: (json['result'] as List) + .map( + (e) => + McTimetableTeacherElement.fromJson(e as Map), + ) + .toList(), + ) + ..headers = (json['headers'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ); + +Map _$TimetableGetTeachersResponseToJson( + TimetableGetTeachersResponse instance, +) => { + 'headers': ?instance.headers, + 'result': instance.result.map((e) => e.toJson()).toList(), +}; diff --git a/lib/main.dart b/lib/main.dart index 96db3e0..75b0ebd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,6 +30,7 @@ import 'share_intent/share_intent_listener.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/capabilities/bloc/capabilities_cubit.dart'; import 'state/app/modules/chat/bloc/chat_bloc.dart'; import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; @@ -159,6 +160,7 @@ Future main() async { ), ), BlocProvider(create: (_) => BreakerBloc()), + BlocProvider(create: (_) => CapabilitiesCubit()), BlocProvider(create: (_) => ChatListBloc()), BlocProvider( create: (ctx) => ChatBloc(chatListBloc: ctx.read()), @@ -193,7 +195,13 @@ class _MainState extends State
{ accountBloc.setStatus( value ? AccountStatus.loggedIn : AccountStatus.loggedOut, ); - if (value) _scheduleSessionValidation(accountBloc); + if (value) { + _scheduleSessionValidation(accountBloc); + // Cold start while already logged in: the account status doesn't + // change, so the loggedIn listener below never fires — refresh + // capabilities here. + unawaited(context.read().load()); + } }); } @@ -262,6 +270,11 @@ class _MainState extends State
{ listenWhen: (previous, current) => previous.status != current.status, listener: (context, accountState) { + // Fresh login (loggedOut -> loggedIn): pull capability flags + // for the newly authenticated user. + if (accountState.status == AccountStatus.loggedIn) { + unawaited(context.read().load()); + } if (accountState.status != AccountStatus.loggedOut) return; // A pending share would otherwise survive logout and be // re-applied after re-login with file paths the OS may @@ -283,6 +296,7 @@ class _MainState extends State
{ final chatListBloc = context.read(); final chatBloc = context.read(); final breakerBloc = context.read(); + final capabilitiesCubit = 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 @@ -295,6 +309,7 @@ class _MainState extends State
{ chatListBloc: chatListBloc, chatBloc: chatBloc, breakerBloc: breakerBloc, + capabilitiesCubit: capabilitiesCubit, ), ); }); @@ -339,6 +354,7 @@ Future _wipeUserState({ required ChatListBloc chatListBloc, required ChatBloc chatBloc, required BreakerBloc breakerBloc, + required CapabilitiesCubit capabilitiesCubit, }) async { try { // Reset user-data blocs whose tree is no longer mounted after the @@ -351,6 +367,7 @@ Future _wipeUserState({ chatListBloc.reset(), chatBloc.reset(), breakerBloc.reset(), + capabilitiesCubit.reset(), ]); final prefs = await SharedPreferences.getInstance(); await prefs.clear(); diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 0eeb663..277ac4a 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -4,6 +4,7 @@ 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/get_room_response.dart'; +import '../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; import '../main.dart'; import '../model/account_data.dart'; import '../notification/notification_tasks.dart'; @@ -14,6 +15,7 @@ import '../state/app/modules/chat/bloc/chat_bloc.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/foreign_timetable/element_picker_page.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'; @@ -71,6 +73,24 @@ class AppRoutes { pushScreen(context, withNavBar: false, screen: const CustomEventsView()); } + /// Opens the picker for choosing a foreign timetable element and resolves to + /// the selected element (or null if dismissed). The timetable view renders + /// the chosen plan inline. Gated behind the `viewForeignTimetables` + /// capability at the call site. + static Future openElementPicker( + BuildContext context, + ) async { + // pushScreen casts its internal MaterialPageRoute to `Route`, which + // blows up for a concrete (record) T — so push untyped (T = dynamic, like + // every other caller) and cast the popped result ourselves. + final result = await pushScreen( + context, + withNavBar: false, + screen: const ElementPickerPage(), + ); + return result as TimetableElementRef?; + } + static void openMarianumMessage( BuildContext context, String basePath, diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart new file mode 100644 index 0000000..667ae45 --- /dev/null +++ b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart @@ -0,0 +1,48 @@ +import 'dart:developer'; + +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +import '../../../../../api/marianumconnect/queries/get_capabilities/get_capabilities.dart'; +import 'capabilities_state.dart'; + +/// Holds the current user's mobile capability flags. Hydrated so the last +/// known state is available immediately on cold start (no feature-flicker) +/// and offline. [load] refreshes it from the server after login. +class CapabilitiesCubit extends HydratedCubit { + CapabilitiesCubit() : super(const CapabilitiesState()); + + bool get canViewForeignTimetables => state.viewForeignTimetables; + + /// Refreshes capabilities from the server. On any failure (endpoint not yet + /// live, network error, 4xx) the previously hydrated flags are kept but the + /// state is marked `loaded` — a failed fetch never silently grants a + /// capability, and an offline launch keeps whatever was cached. + Future load() async { + try { + final response = await GetCapabilities().run(); + emit( + CapabilitiesState( + viewForeignTimetables: response.viewForeignTimetables, + loaded: true, + ), + ); + } catch (e) { + log('Failed to load capabilities: $e'); + emit(state.copyWith(loaded: true)); + } + } + + Future reset() async => emit(const CapabilitiesState()); + + @override + CapabilitiesState fromJson(Map json) { + try { + return CapabilitiesState.fromJson(json); + } catch (_) { + return const CapabilitiesState(); + } + } + + @override + Map? toJson(CapabilitiesState state) => state.toJson(); +} diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_state.dart b/lib/state/app/modules/capabilities/bloc/capabilities_state.dart new file mode 100644 index 0000000..0216c7f --- /dev/null +++ b/lib/state/app/modules/capabilities/bloc/capabilities_state.dart @@ -0,0 +1,18 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'capabilities_state.freezed.dart'; +part 'capabilities_state.g.dart'; + +@freezed +abstract class CapabilitiesState with _$CapabilitiesState { + const factory CapabilitiesState({ + @Default(false) bool viewForeignTimetables, + // Whether a capability response (or a definitive failure) has been + // observed at least once this session. Lets the UI distinguish "still + // unknown" from "confirmed not allowed". + @Default(false) bool loaded, + }) = _CapabilitiesState; + + factory CapabilitiesState.fromJson(Map json) => + _$CapabilitiesStateFromJson(json); +} diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart b/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart new file mode 100644 index 0000000..713f14b --- /dev/null +++ b/lib/state/app/modules/capabilities/bloc/capabilities_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 'capabilities_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$CapabilitiesState { + + bool get viewForeignTimetables;// Whether a capability response (or a definitive failure) has been +// observed at least once this session. Lets the UI distinguish "still +// unknown" from "confirmed not allowed". + bool get loaded; +/// Create a copy of CapabilitiesState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CapabilitiesStateCopyWith get copyWith => _$CapabilitiesStateCopyWithImpl(this as CapabilitiesState, _$identity); + + /// Serializes this CapabilitiesState to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.loaded, loaded) || other.loaded == loaded)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,viewForeignTimetables,loaded); + +@override +String toString() { + return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, loaded: $loaded)'; +} + + +} + +/// @nodoc +abstract mixin class $CapabilitiesStateCopyWith<$Res> { + factory $CapabilitiesStateCopyWith(CapabilitiesState value, $Res Function(CapabilitiesState) _then) = _$CapabilitiesStateCopyWithImpl; +@useResult +$Res call({ + bool viewForeignTimetables, bool loaded +}); + + + + +} +/// @nodoc +class _$CapabilitiesStateCopyWithImpl<$Res> + implements $CapabilitiesStateCopyWith<$Res> { + _$CapabilitiesStateCopyWithImpl(this._self, this._then); + + final CapabilitiesState _self; + final $Res Function(CapabilitiesState) _then; + +/// Create a copy of CapabilitiesState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? loaded = null,}) { + return _then(_self.copyWith( +viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable +as bool,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CapabilitiesState]. +extension CapabilitiesStatePatterns on CapabilitiesState { +/// 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( _CapabilitiesState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CapabilitiesState() 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( _CapabilitiesState value) $default,){ +final _that = this; +switch (_that) { +case _CapabilitiesState(): +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( _CapabilitiesState value)? $default,){ +final _that = this; +switch (_that) { +case _CapabilitiesState() 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 viewForeignTimetables, bool loaded)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CapabilitiesState() when $default != null: +return $default(_that.viewForeignTimetables,_that.loaded);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 viewForeignTimetables, bool loaded) $default,) {final _that = this; +switch (_that) { +case _CapabilitiesState(): +return $default(_that.viewForeignTimetables,_that.loaded);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 viewForeignTimetables, bool loaded)? $default,) {final _that = this; +switch (_that) { +case _CapabilitiesState() when $default != null: +return $default(_that.viewForeignTimetables,_that.loaded);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _CapabilitiesState implements CapabilitiesState { + const _CapabilitiesState({this.viewForeignTimetables = false, this.loaded = false}); + factory _CapabilitiesState.fromJson(Map json) => _$CapabilitiesStateFromJson(json); + +@override@JsonKey() final bool viewForeignTimetables; +// Whether a capability response (or a definitive failure) has been +// observed at least once this session. Lets the UI distinguish "still +// unknown" from "confirmed not allowed". +@override@JsonKey() final bool loaded; + +/// Create a copy of CapabilitiesState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CapabilitiesStateCopyWith<_CapabilitiesState> get copyWith => __$CapabilitiesStateCopyWithImpl<_CapabilitiesState>(this, _$identity); + +@override +Map toJson() { + return _$CapabilitiesStateToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.loaded, loaded) || other.loaded == loaded)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,viewForeignTimetables,loaded); + +@override +String toString() { + return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, loaded: $loaded)'; +} + + +} + +/// @nodoc +abstract mixin class _$CapabilitiesStateCopyWith<$Res> implements $CapabilitiesStateCopyWith<$Res> { + factory _$CapabilitiesStateCopyWith(_CapabilitiesState value, $Res Function(_CapabilitiesState) _then) = __$CapabilitiesStateCopyWithImpl; +@override @useResult +$Res call({ + bool viewForeignTimetables, bool loaded +}); + + + + +} +/// @nodoc +class __$CapabilitiesStateCopyWithImpl<$Res> + implements _$CapabilitiesStateCopyWith<$Res> { + __$CapabilitiesStateCopyWithImpl(this._self, this._then); + + final _CapabilitiesState _self; + final $Res Function(_CapabilitiesState) _then; + +/// Create a copy of CapabilitiesState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? loaded = null,}) { + return _then(_CapabilitiesState( +viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable +as bool,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart b/lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart new file mode 100644 index 0000000..66447a6 --- /dev/null +++ b/lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'capabilities_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_CapabilitiesState _$CapabilitiesStateFromJson(Map json) => + _CapabilitiesState( + viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false, + loaded: json['loaded'] as bool? ?? false, + ); + +Map _$CapabilitiesStateToJson(_CapabilitiesState instance) => + { + 'viewForeignTimetables': instance.viewForeignTimetables, + 'loaded': instance.loaded, + }; diff --git a/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart b/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart new file mode 100644 index 0000000..0892344 --- /dev/null +++ b/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart @@ -0,0 +1,212 @@ +import 'dart:developer'; + +import 'package:intl/intl.dart'; + +import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; +import '../../../../../extensions/date_time.dart'; +import '../../../infrastructure/loadable_state/loadable_state.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; +import '../../timetable/bloc/timetable_event.dart'; +import '../../timetable/bloc/timetable_state.dart'; +import '../repository/foreign_timetable_repository.dart'; + +/// Drives a foreign element's timetable. Mirrors the week-loading and +/// week-navigation logic of `TimetableBloc` but (a) loads weeks from the +/// element endpoint, (b) carries no custom events, and (c) does not persist — +/// it is created per opened page and recreated for every selected element. +/// +/// It reuses [TimetableState] verbatim so the existing render pipeline works +/// unchanged; `customEvents` simply stays null (the foreign view uses an +/// `isReady` predicate that ignores it). +class ForeignTimetableBloc + extends + LoadableHydratedBloc< + TimetableEvent, + TimetableState, + ForeignTimetableRepository + > { + static final DateFormat _weekKeyFormat = DateFormat('yyyyMMdd'); + + final TimetableElementType type; + // Named `elementId` rather than `id` to avoid shadowing HydratedMixin's + // `String get id` (the storage key), which a plain `int id` would illegally + // override. + final int elementId; + final String title; + + DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0); + + ForeignTimetableBloc({ + required this.type, + required this.elementId, + required this.title, + }); + + @override + ForeignTimetableRepository repository() => ForeignTimetableRepository(); + + @override + TimetableState fromNothing() { + final reference = DateTime.now().addDays(2); + return TimetableState( + startDate: _startOfWeek(reference), + endDate: _endOfWeek(reference), + ); + } + + // Persistence is disabled: this bloc is page-scoped and element-specific, so + // there is nothing worth restoring across launches. Returning null from + // toJson means HydratedBloc never writes anything; fromJson ignores any + // legacy payload and starts fresh. + @override + Map? toJson(LoadableState state) => null; + + @override + LoadableState fromJson(Map json) => + const LoadableState( + isLoading: true, + data: null, + lastFetch: null, + reFetch: null, + error: null, + ); + + @override + TimetableState fromStorage(Map json) => fromNothing(); + + @override + Map? toStorage(TimetableState state) => null; + + @override + Future gatherData() async { + final initial = innerState ?? fromNothing(); + + Object? firstError; + void recordError(Object e) { + firstError ??= e; + } + + await Future.wait([ + _loadCurrentWeek(initial.startDate, initial.endDate, onError: recordError), + _loadStaticReferenceData(onError: recordError), + ]); + + if (firstError != null) throw firstError!; + + add(DataGathered((s) => s)); + _prefetchAdjacentWeeks(initial.startDate, initial.endDate); + } + + void changeWeek(DateTime startDate, DateTime endDate) { + final current = innerState ?? fromNothing(); + if (current.startDate == startDate && current.endDate == endDate) return; + add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate))); + _loadCurrentWeek(startDate, endDate); + _prefetchAdjacentWeeks(startDate, endDate); + } + + void resetWeek() { + final reference = DateTime.now().addDays(2); + changeWeek(_startOfWeek(reference), _endOfWeek(reference)); + } + + void refresh() => fetch(); + + Future _loadCurrentWeek( + DateTime startDate, + DateTime endDate, { + void Function(Object)? onError, + }) async { + final requestStart = DateTime.now(); + _lastWeekRequestStart = requestStart; + try { + final week = await repo.data.getElementWeek( + type, + elementId, + startDate, + endDate, + onError: onError, + ); + if (_lastWeekRequestStart.isAfter(requestStart)) return; + _writeWeekToCache(startDate, week); + } catch (e) { + log('getElementWeek error for $startDate–$endDate: $e'); + onError?.call(e); + } + } + + Future _loadStaticReferenceData({ + void Function(Object)? onError, + }) async { + try { + final (rooms, subjects, schoolHolidays, schoolyear) = await ( + repo.data.getRooms(onError: onError), + repo.data.getSubjects(onError: onError), + repo.data.getSchoolHolidays(onError: onError), + repo.data.getCurrentSchoolyear(onError: onError), + ).wait; + + add( + Emit( + (s) => s.copyWith( + rooms: rooms, + subjects: subjects, + schoolHolidays: schoolHolidays, + schoolyear: schoolyear, + dataVersion: s.dataVersion + 1, + ), + ), + ); + } catch (e) { + onError?.call(e); + } + + try { + final timegrid = await repo.data.getTimegrid(); + add( + Emit( + (s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1), + ), + ); + } catch (_) { + // Timegrid load failure falls back to a hardcoded schedule in the UI. + } + } + + void _prefetchAdjacentWeeks(DateTime start, DateTime end) { + _prefetchWeek(start.subtractDays(7), end.subtractDays(7)); + _prefetchWeek(start.addDays(7), end.addDays(7)); + } + + void _prefetchWeek(DateTime start, DateTime end) { + repo.data + .getElementWeek(type, elementId, start, end) + .then((week) => _writeWeekToCache(start, week)) + .catchError((_) {}); + } + + void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) { + 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); + }), + ); + } + + static DateTime _startOfWeek(DateTime reference) { + final monday = reference.subtractDays(reference.weekday - 1); + return DateTime(monday.year, monday.month, monday.day); + } + + static DateTime _endOfWeek(DateTime reference) { + final friday = reference.addDays( + DateTime.daysPerWeek - reference.weekday - 2, + ); + return DateTime(friday.year, friday.month, friday.day); + } +} diff --git a/lib/state/app/modules/foreign_timetable/data_provider/foreign_timetable_data_provider.dart b/lib/state/app/modules/foreign_timetable/data_provider/foreign_timetable_data_provider.dart new file mode 100644 index 0000000..83eb638 --- /dev/null +++ b/lib/state/app/modules/foreign_timetable/data_provider/foreign_timetable_data_provider.dart @@ -0,0 +1,64 @@ +import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; +import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; +import '../../timetable/data_provider/timetable_data_provider.dart'; + +/// Data access for a foreign element's timetable. The week comes from the +/// element-specific endpoint; all reference data (rooms/subjects/holidays/ +/// school year/timegrid) is school-wide and identical to the user's own plan, +/// so it is delegated to the existing [TimetableDataProvider] (which already +/// caches it). Custom events are intentionally absent — they are user-private. +class ForeignTimetableDataProvider { + final TimetableDataProvider _base; + + ForeignTimetableDataProvider([TimetableDataProvider? base]) + : _base = base ?? TimetableDataProvider(); + + Future getElementWeek( + TimetableElementType type, + int id, + DateTime startDate, + DateTime endDate, { + void Function(Object)? onError, + }) async { + try { + return await TimetableGetElementWeek().run( + type: type, + id: id, + from: startDate, + until: endDate, + ); + } catch (e) { + onError?.call(e); + rethrow; + } + } + + Future getRooms({ + void Function(Object)? onError, + bool renew = false, + }) => _base.getRooms(onError: onError, renew: renew); + + Future getSubjects({ + void Function(Object)? onError, + bool renew = false, + }) => _base.getSubjects(onError: onError, renew: renew); + + Future getSchoolHolidays({ + void Function(Object)? onError, + bool renew = false, + }) => _base.getSchoolHolidays(onError: onError, renew: renew); + + Future getCurrentSchoolyear({ + void Function(Object)? onError, + bool renew = false, + }) => _base.getCurrentSchoolyear(onError: onError, renew: renew); + + Future getTimegrid({bool renew = false}) => + _base.getTimegrid(renew: renew); +} diff --git a/lib/state/app/modules/foreign_timetable/repository/foreign_timetable_repository.dart b/lib/state/app/modules/foreign_timetable/repository/foreign_timetable_repository.dart new file mode 100644 index 0000000..f8f6c4a --- /dev/null +++ b/lib/state/app/modules/foreign_timetable/repository/foreign_timetable_repository.dart @@ -0,0 +1,12 @@ +import '../../../infrastructure/repository/repository.dart'; +import '../../timetable/bloc/timetable_state.dart'; +import '../data_provider/foreign_timetable_data_provider.dart'; + +class ForeignTimetableRepository extends Repository { + final ForeignTimetableDataProvider _provider; + + ForeignTimetableRepository([ForeignTimetableDataProvider? provider]) + : _provider = provider ?? ForeignTimetableDataProvider(); + + ForeignTimetableDataProvider get data => _provider; +} diff --git a/lib/storage/settings.dart b/lib/storage/settings.dart index fb3b028..8c149e7 100644 --- a/lib/storage/settings.dart +++ b/lib/storage/settings.dart @@ -10,6 +10,7 @@ import 'holidays_settings.dart'; import 'modules_settings.dart'; import 'notification_settings.dart'; import 'talk_settings.dart'; +import 'timetable_favorites_settings.dart'; import 'timetable_settings.dart'; part 'settings.g.dart'; @@ -22,6 +23,7 @@ class Settings { ModulesSettings modulesSettings; TimetableSettings timetableSettings; + TimetableFavoritesSettings timetableFavoritesSettings; TalkSettings talkSettings; ChatBackgroundSettings chatBackgroundSettings; FileSettings fileSettings; @@ -36,6 +38,7 @@ class Settings { required this.devToolsEnabled, required this.modulesSettings, required this.timetableSettings, + required this.timetableFavoritesSettings, required this.talkSettings, required this.chatBackgroundSettings, required this.fileSettings, diff --git a/lib/storage/settings.g.dart b/lib/storage/settings.g.dart index 1ace484..76c739f 100644 --- a/lib/storage/settings.g.dart +++ b/lib/storage/settings.g.dart @@ -15,6 +15,9 @@ Settings _$SettingsFromJson(Map json) => Settings( timetableSettings: TimetableSettings.fromJson( json['timetableSettings'] as Map, ), + timetableFavoritesSettings: TimetableFavoritesSettings.fromJson( + json['timetableFavoritesSettings'] as Map, + ), talkSettings: TalkSettings.fromJson( json['talkSettings'] as Map, ), @@ -46,6 +49,7 @@ Map _$SettingsToJson(Settings instance) => { 'devToolsEnabled': instance.devToolsEnabled, 'modulesSettings': instance.modulesSettings.toJson(), 'timetableSettings': instance.timetableSettings.toJson(), + 'timetableFavoritesSettings': instance.timetableFavoritesSettings.toJson(), 'talkSettings': instance.talkSettings.toJson(), 'chatBackgroundSettings': instance.chatBackgroundSettings.toJson(), 'fileSettings': instance.fileSettings.toJson(), diff --git a/lib/storage/timetable_favorites_settings.dart b/lib/storage/timetable_favorites_settings.dart new file mode 100644 index 0000000..9cba86d --- /dev/null +++ b/lib/storage/timetable_favorites_settings.dart @@ -0,0 +1,49 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; + +part 'timetable_favorites_settings.g.dart'; + +/// A timetable element the user starred for quick access from the picker. +@JsonSerializable(explicitToJson: true) +class FavoriteTimetableElement { + final TimetableElementType type; + final int id; + final String label; + + FavoriteTimetableElement({ + required this.type, + required this.id, + required this.label, + }); + + factory FavoriteTimetableElement.fromJson(Map json) => + _$FavoriteTimetableElementFromJson(json); + Map toJson() => _$FavoriteTimetableElementToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class TimetableFavoritesSettings { + List favorites; + + TimetableFavoritesSettings({required this.favorites}); + + bool isFavorite(TimetableElementType type, int id) => + favorites.any((f) => f.type == type && f.id == id); + + /// Adds or removes the element in place. Returns the new favorite state + /// (`true` = now starred). Callers persist via `SettingsCubit.val(write: true)`. + bool toggle(TimetableElementType type, int id, String label) { + final existing = favorites.where((f) => f.type == type && f.id == id); + if (existing.isNotEmpty) { + favorites.removeWhere((f) => f.type == type && f.id == id); + return false; + } + favorites.add(FavoriteTimetableElement(type: type, id: id, label: label)); + return true; + } + + factory TimetableFavoritesSettings.fromJson(Map json) => + _$TimetableFavoritesSettingsFromJson(json); + Map toJson() => _$TimetableFavoritesSettingsToJson(this); +} diff --git a/lib/storage/timetable_favorites_settings.g.dart b/lib/storage/timetable_favorites_settings.g.dart new file mode 100644 index 0000000..54f0d0e --- /dev/null +++ b/lib/storage/timetable_favorites_settings.g.dart @@ -0,0 +1,44 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'timetable_favorites_settings.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FavoriteTimetableElement _$FavoriteTimetableElementFromJson( + Map json, +) => FavoriteTimetableElement( + type: $enumDecode(_$TimetableElementTypeEnumMap, json['type']), + id: (json['id'] as num).toInt(), + label: json['label'] as String, +); + +Map _$FavoriteTimetableElementToJson( + FavoriteTimetableElement instance, +) => { + 'type': _$TimetableElementTypeEnumMap[instance.type]!, + 'id': instance.id, + 'label': instance.label, +}; + +const _$TimetableElementTypeEnumMap = { + TimetableElementType.student: 'student', + TimetableElementType.teacher: 'teacher', + TimetableElementType.room: 'room', + TimetableElementType.schoolClass: 'schoolClass', +}; + +TimetableFavoritesSettings _$TimetableFavoritesSettingsFromJson( + Map json, +) => TimetableFavoritesSettings( + favorites: (json['favorites'] as List) + .map((e) => FavoriteTimetableElement.fromJson(e as Map)) + .toList(), +); + +Map _$TimetableFavoritesSettingsToJson( + TimetableFavoritesSettings instance, +) => { + 'favorites': instance.favorites.map((e) => e.toJson()).toList(), +}; diff --git a/lib/view/pages/foreign_timetable/element_picker_page.dart b/lib/view/pages/foreign_timetable/element_picker_page.dart new file mode 100644 index 0000000..7a942b7 --- /dev/null +++ b/lib/view/pages/foreign_timetable/element_picker_page.dart @@ -0,0 +1,396 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../api/errors/error_mapper.dart'; +import '../../../api/marianumconnect/queries/timetable_get_classes/timetable_get_classes.dart'; +import '../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; +import '../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart'; +import '../../../api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart'; +import '../../../api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart'; +import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../storage/timetable_favorites_settings.dart'; +import '../../../utils/haptics.dart'; +import '../../../widget/app_progress_indicator.dart'; + +/// Full-screen picker: choose an element type (or "Alle" to search across all +/// types), filter the (potentially large) list client-side, and pick an element +/// whose timetable is then shown inline. Starred elements appear at the top for +/// quick access. +class ElementPickerPage extends StatefulWidget { + const ElementPickerPage({super.key}); + + @override + State createState() => _ElementPickerPageState(); +} + +class _ElementPickerPageState extends State { + /// `null` = the "Alle" tab (search across every type). Default. + TimetableElementType? _selectedType; + String _query = ''; + final TextEditingController _searchController = TextEditingController(); + + // One in-flight/resolved future per type so switching tabs (or rebuilds) + // never re-fetches a list that's already loaded. + final Map>> _futures = {}; + // Memoised combined future for the "Alle" tab; rebuilt on retry. + Future>? _allFuture; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future> _currentFuture() { + final type = _selectedType; + if (type != null) return _loadFor(type); + return _allFuture ??= _loadAll(); + } + + Future> _loadFor(TimetableElementType type) => + _futures.putIfAbsent(type, () => _fetch(type)); + + Future> _loadAll() async { + final lists = await Future.wait(TimetableElementType.values.map(_loadFor)); + return lists.expand((e) => e).toList(); + } + + Future> _fetch(TimetableElementType type) async { + switch (type) { + case TimetableElementType.student: + final r = await TimetableGetStudents().run(); + return r.result + .map( + (s) => _PickerItem( + type: type, + id: s.id, + primary: s.displayName, + secondary: '${s.lastName}, ${s.firstName}', + search: '${s.displayName} ${s.firstName} ${s.lastName}' + .toLowerCase(), + ), + ) + .toList(); + case TimetableElementType.teacher: + final r = await TimetableGetTeachers().run(); + return r.result + .map( + (t) => _PickerItem( + type: type, + id: t.id, + primary: t.displayName, + secondary: t.shortName, + search: '${t.displayName} ${t.shortName}'.toLowerCase(), + ), + ) + .toList(); + case TimetableElementType.room: + final r = await TimetableGetRooms().run(); + return r.result + .map( + (rm) => _PickerItem( + type: type, + id: rm.id, + primary: rm.shortName, + secondary: rm.longName, + search: '${rm.shortName} ${rm.longName}'.toLowerCase(), + ), + ) + .toList(); + case TimetableElementType.schoolClass: + final r = await TimetableGetClasses().run(); + return r.result + .map( + (c) => _PickerItem( + type: type, + id: c.id, + primary: c.shortName, + secondary: c.longName, + search: '${c.shortName} ${c.longName}'.toLowerCase(), + ), + ) + .toList(); + } + } + + void _open(_PickerItem item) { + Haptics.selection(); + // Hand the selection back to the timetable view, which renders the foreign + // plan inline. We do not navigate to a new page. + Navigator.of(context).pop(( + type: item.type, + id: item.id, + label: item.primary, + )); + } + + void _openFavorite(FavoriteTimetableElement favorite) { + Haptics.selection(); + Navigator.of(context).pop(( + type: favorite.type, + id: favorite.id, + label: favorite.label, + )); + } + + void _toggleFavorite(TimetableElementType type, int id, String label) { + Haptics.selection(); + context + .read() + .val(write: true) + .timetableFavoritesSettings + .toggle(type, id, label); + } + + @override + Widget build(BuildContext context) { + final favorites = context + .watch() + .val() + .timetableFavoritesSettings + .favorites; + + return Scaffold( + appBar: AppBar(title: const Text('Stundenplan öffnen')), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: TextField( + controller: _searchController, + onChanged: (value) => setState(() => _query = value.trim()), + decoration: InputDecoration( + hintText: 'Suchen…', + prefixIcon: const Icon(Icons.search), + suffixIcon: _query.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + setState(() => _query = ''); + }, + ), + border: const OutlineInputBorder(), + isDense: true, + ), + ), + ), + SizedBox(height: 40, child: _typeSelector()), + const SizedBox(height: 8), + Expanded(child: _list(favorites)), + ], + ), + ); + } + + Widget _typeSelector() { + return ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + _typeChip(null), + ...TimetableElementType.values.map(_typeChip), + ], + ); + } + + Widget _typeChip(TimetableElementType? type) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + avatar: Icon(type == null ? Icons.apps : _iconFor(type), size: 18), + showCheckmark: false, + label: Text(type?.label ?? 'Alle'), + selected: _selectedType == type, + onSelected: (_) { + Haptics.selection(); + setState(() => _selectedType = type); + }, + ), + ); + } + + Widget _list(List favorites) { + return FutureBuilder>( + future: _currentFuture(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: AppProgressIndicator.medium()); + } + if (snapshot.hasError) { + return _ErrorBody( + message: errorToUserMessage(snapshot.error), + onRetry: () => setState(() { + _futures.clear(); + _allFuture = null; + }), + ); + } + + final items = snapshot.data ?? const <_PickerItem>[]; + final query = _query.toLowerCase(); + final filtered = query.isEmpty + ? items + : items.where((i) => i.search.contains(query)).toList(); + + // On a specific type tab only that type's favorites are relevant; the + // "Alle" tab shows them all. + final visibleFavorites = _selectedType == null + ? favorites + : favorites.where((f) => f.type == _selectedType).toList(); + + // Favorites only make sense while not actively searching. + final showFavorites = _query.isEmpty && visibleFavorites.isNotEmpty; + + if (filtered.isEmpty && !showFavorites) { + return const Center(child: Text('Keine Einträge gefunden.')); + } + + // Leading rows when favorites are shown: a "Favoriten" header, one row + // per favorite, then a divider + "Weitere" header that separates them + // from the regular results. + final headerCount = showFavorites ? visibleFavorites.length + 2 : 0; + + return ListView.builder( + itemCount: filtered.length + headerCount, + itemBuilder: (context, index) { + if (showFavorites) { + if (index == 0) return _sectionHeader('Favoriten'); + if (index <= visibleFavorites.length) { + return _favoriteTile(visibleFavorites[index - 1]); + } + if (index == visibleFavorites.length + 1) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Divider(height: 1), + _sectionHeader('Weitere'), + ], + ); + } + } + final item = filtered[index - headerCount]; + return _itemTile(item); + }, + ); + }, + ); + } + + Widget _sectionHeader(String label) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 4), + child: Text( + label, + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + Widget _favoriteTile(FavoriteTimetableElement favorite) { + return ListTile( + leading: Icon(_iconFor(favorite.type)), + title: Text(favorite.label), + subtitle: Text(favorite.type.label), + trailing: IconButton( + icon: const Icon(Icons.star), + tooltip: 'Favorit entfernen', + onPressed: () => + _toggleFavorite(favorite.type, favorite.id, favorite.label), + ), + onTap: () => _openFavorite(favorite), + ); + } + + Widget _itemTile(_PickerItem item) { + final isFavorite = context + .watch() + .val() + .timetableFavoritesSettings + .isFavorite(item.type, item.id); + + // In the "Alle" tab the type is otherwise ambiguous, so surface it. + final subtitleParts = [ + if (item.secondary.isNotEmpty && item.secondary != item.primary) + item.secondary, + if (_selectedType == null) item.type.label, + ]; + + return ListTile( + leading: Icon(_iconFor(item.type)), + title: Text(item.primary), + subtitle: subtitleParts.isEmpty + ? null + : Text(subtitleParts.join(' · ')), + trailing: IconButton( + icon: Icon(isFavorite ? Icons.star : Icons.star_border), + tooltip: isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren', + onPressed: () => _toggleFavorite(item.type, item.id, item.primary), + ), + onTap: () => _open(item), + ); + } + + static IconData _iconFor(TimetableElementType type) { + switch (type) { + case TimetableElementType.student: + return Icons.person_outline; + case TimetableElementType.teacher: + return Icons.school_outlined; + case TimetableElementType.room: + return Icons.meeting_room_outlined; + case TimetableElementType.schoolClass: + return Icons.groups_outlined; + } + } +} + +class _PickerItem { + final TimetableElementType type; + final int id; + final String primary; + final String secondary; + final String search; + + _PickerItem({ + required this.type, + required this.id, + required this.primary, + required this.secondary, + required this.search, + }); +} + +class _ErrorBody extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const _ErrorBody({required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton.tonal( + onPressed: onRetry, + child: const Text('Erneut versuchen'), + ), + ], + ), + ), + ); + } +} 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 86ef73a..ed8e483 100644 --- a/lib/view/pages/marianum_dates/widgets/event_list_tile.dart +++ b/lib/view/pages/marianum_dates/widgets/event_list_tile.dart @@ -9,7 +9,7 @@ class MarianumDateRow extends StatelessWidget { final MarianumDate event; const MarianumDateRow({required this.event, super.key}); - String _dayLabel() => event.start.day.toString().padLeft(2, '0'); + String _dayLabel() => "${event.start.day.toString().padLeft(2, '0')}."; String _monthYearLabel() => '${event.start.month.toString().padLeft(2, '0')}.${event.start.year}'; diff --git a/lib/view/pages/settings/data/default_settings.dart b/lib/view/pages/settings/data/default_settings.dart index 4bedd1a..683e935 100644 --- a/lib/view/pages/settings/data/default_settings.dart +++ b/lib/view/pages/settings/data/default_settings.dart @@ -13,6 +13,7 @@ import '../../../../storage/modules_settings.dart'; import '../../../../storage/notification_settings.dart'; import '../../../../storage/settings.dart'; import '../../../../storage/talk_settings.dart'; +import '../../../../storage/timetable_favorites_settings.dart'; import '../../../../storage/timetable_settings.dart'; import '../../../../view/pages/timetable/data/timetable_name_mode.dart'; import '../../files/data/sort_options.dart'; @@ -40,6 +41,7 @@ class DefaultSettings { connectDoubleLessons: true, timetableNameMode: TimetableNameMode.name, ), + timetableFavoritesSettings: TimetableFavoritesSettings(favorites: []), talkSettings: TalkSettings( sortFavoritesToTop: true, sortUnreadToTop: false, diff --git a/lib/view/pages/talk/details/message_reactions.dart b/lib/view/pages/talk/details/message_reactions.dart index e39cc36..b5d036c 100644 --- a/lib/view/pages/talk/details/message_reactions.dart +++ b/lib/view/pages/talk/details/message_reactions.dart @@ -4,6 +4,7 @@ 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/emoji_text.dart'; import '../../../../widget/loading_spinner.dart'; import '../../../../widget/placeholder_view.dart'; import '../../../../widget/user_avatar.dart'; @@ -59,7 +60,7 @@ class _MessageReactionsState extends State { collapsedIconColor: Theme.of(context).colorScheme.onSurface, subtitle: const Text('Tippe für mehr'), - leading: CenteredLeading(Text(entry.key)), + leading: CenteredLeading(EmojiText(entry.key)), title: Text('${entry.value.length} mal reagiert'), children: entry.value.map((e) { final isSelf = AccountData().getUsername() == e.actorId; diff --git a/lib/view/pages/talk/widgets/chat_bubble_reactions.dart b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart index 02e80ac..34831b0 100644 --- a/lib/view/pages/talk/widgets/chat_bubble_reactions.dart +++ b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart @@ -7,6 +7,7 @@ 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'; +import '../../../../widget/emoji_text.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]. @@ -42,7 +43,14 @@ class ChatBubbleReactions extends StatelessWidget { return Container( margin: const EdgeInsets.only(right: 2.5, left: 2.5), child: ActionChip( - label: Text('${e.key} ${e.value}'), + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + EmojiText(e.key, size: EmojiText.sizeInline), + const SizedBox(width: 4), + Text('${e.value}'), + ], + ), visualDensity: const VisualDensity( vertical: VisualDensity.minimumDensity, horizontal: VisualDensity.minimumDensity, 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 7fcc6c1..eccf7e9 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -1,4 +1,3 @@ -import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emojis; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -16,6 +15,8 @@ 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/emoji_picker_dialog.dart'; +import '../../../../widget/emoji_text.dart'; import '../data/open_direct_chat.dart'; const _commonReactions = ['👍', '👎', '😆', '❤️', '👀']; @@ -222,7 +223,7 @@ class _ReactionsRowState extends State<_ReactionsRow> { minimumSize: const Size(40, 40), ), onPressed: busy ? null : () => _react(emoji), - child: Text(emoji), + child: EmojiText(emoji, size: EmojiText.sizeLarge), ), ), IconButton( @@ -256,56 +257,8 @@ class _ReactionsRowState extends State<_ReactionsRow> { }, ); - 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); - }, - ), - ), - ), - ); + Future _showEmojiPicker(BuildContext rowContext) async { + final emoji = await showEmojiPicker(rowContext, title: 'Reagieren'); + if (emoji != null && mounted) await _react(emoji); } } diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index 135b9de..6fd3f3a 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -13,6 +13,7 @@ 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/emoji_picker_dialog.dart'; import '../../../../widget/file_pick.dart'; import '../../../../widget/focus_behaviour.dart'; import '../../files/files_upload_dialog.dart'; @@ -32,6 +33,7 @@ class _ChatTextfieldState extends State { late SettingsCubit settings; final TextEditingController _textBoxController = TextEditingController(); final AsyncActionController _sendController = AsyncActionController(); + final FocusNode _focusNode = FocusNode(); String? _sendError; void share(List uploadedRemotePaths) { @@ -103,9 +105,71 @@ class _ChatTextfieldState extends State { @override void dispose() { _sendController.dispose(); + _focusNode.dispose(); super.dispose(); } + void _showAttachmentSheet() { + 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(); + }, + ), + ], + ); + } + + Future _pickEmoji() async { + final emoji = await showEmojiPicker(context); + if (emoji == null || !mounted) return; + _insertEmoji(emoji); + // Keep the field focused so the user can keep typing after inserting. + _focusNode.requestFocus(); + } + + void _insertEmoji(String emoji) { + final selection = _textBoxController.selection; + final text = _textBoxController.text; + // Selection is invalid (-1) until the field was focused once — append then. + final start = selection.start < 0 ? text.length : selection.start; + final end = selection.end < 0 ? text.length : selection.end; + final newText = text.replaceRange(start, end, emoji); + _textBoxController.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: start + emoji.length), + ); + // Programmatic edits skip the TextField's onChanged, so persist manually. + _setDraft(newText); + } + Future _sendMessage(ChatBloc chatBloc) async { if (_textBoxController.text.isEmpty) return; final text = _textBoxController.text; @@ -199,94 +263,89 @@ class _ChatTextfieldState extends State { ), ), Row( + // Outer row centers the pill against the (taller) send FAB. + // The inner row keeps end-alignment so the icon buttons drop + // to the bottom once the text wraps to multiple lines. + crossAxisAlignment: CrossAxisAlignment.center, 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(); - }, + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(24), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + IconButton( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + tooltip: 'Emoji einfügen', + icon: Icon( + Icons.emoji_emotions_outlined, + color: Theme.of(context).hintColor, + ), + onPressed: _pickEmoji, ), - 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(); - }, + Expanded( + child: Padding( + // 7px keeps a single line as tall as the + // 32px icon buttons, so end-alignment reads as + // centered for one line but drops the buttons + // to the bottom once the text wraps. + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 7, + ), + child: TextField( + autocorrect: true, + textCapitalization: + TextCapitalization.sentences, + controller: _textBoxController, + focusNode: _focusNode, + maxLines: 7, + minLines: 1, + decoration: const InputDecoration( + hintText: 'Nachricht schreiben...', + border: InputBorder.none, + isCollapsed: true, + ), + 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, + ), + ), + ), ), - 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(); - }, + IconButton( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + tooltip: 'Anhang', + icon: Icon( + Icons.attach_file_outlined, + color: Theme.of(context).hintColor, + ), + onPressed: _showAttachmentSheet, ), ], - ); - }, - 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), + const SizedBox(width: 8), ValueListenableBuilder( valueListenable: _textBoxController, builder: (context, value, _) => AsyncFab( diff --git a/lib/view/pages/timetable/details/appointment_details_dispatcher.dart b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart index 512cfe7..404c793 100644 --- a/lib/view/pages/timetable/details/appointment_details_dispatcher.dart +++ b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; -import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../data/arbitrary_appointment.dart'; import 'custom_event_sheet.dart'; import 'lesson_sheet.dart'; @@ -9,15 +9,14 @@ import 'lesson_sheet.dart'; class AppointmentDetailsDispatcher { static void show( BuildContext context, - TimetableBloc bloc, + TimetableState? state, Appointment appointment, ) { final id = appointment.id; if (id is! ArbitraryAppointment) return; id.when( - lesson: (entry) => - LessonSheet.show(context, bloc, appointment, entry), + lesson: (entry) => LessonSheet.show(context, state, appointment, entry), custom: (event) => CustomEventSheet.show(context, event), ); } diff --git a/lib/view/pages/timetable/details/lesson_sheet.dart b/lib/view/pages/timetable/details/lesson_sheet.dart index 9bc731a..bb91c46 100644 --- a/lib/view/pages/timetable/details/lesson_sheet.dart +++ b/lib/view/pages/timetable/details/lesson_sheet.dart @@ -5,7 +5,7 @@ import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get 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 '../data/lesson_type_label.dart'; @@ -13,11 +13,10 @@ import '../data/lesson_type_label.dart'; class LessonSheet { static void show( BuildContext context, - TimetableBloc bloc, + TimetableState? state, Appointment appointment, McTimetableEntry lesson, ) { - final state = bloc.state.data; if (state == null) return; final subjectShort = lesson.subjects.firstOrNull; diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index b771b39..6b12a72 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -1,21 +1,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:syncfusion_flutter_calendar/calendar.dart'; +import '../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; import '../../../extensions/date_time.dart'; import '../../../routing/app_routes.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart'; +import '../../../state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.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 '../../../storage/timetable_settings.dart'; +import '../../../utils/haptics.dart'; import 'custom_events/custom_event_edit_dialog.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/custom_workweek_calendar.dart'; -import 'widgets/special_regions_builder.dart'; +import 'widgets/timetable_calendar_view.dart'; enum _CalendarAction { addEvent, viewEvents } @@ -27,17 +25,30 @@ class Timetable extends StatefulWidget { } class _TimetableState extends State { - final GlobalKey _calendarKey = - GlobalKey(); + final GlobalKey _calendarKey = + GlobalKey(); - List? _cachedAppointments; - int? _lastDataVersion; - TimetableSettings? _lastTimetableSettings; + /// When non-null the view shows this element's plan inline instead of the + /// user's own. Cleared (back to own plan) via the viewing banner. + TimetableElementRef? _selected; DateTime _initialDisplayDate() => DateTime.now().addDays(2); void _jumpToToday() { - _calendarKey.currentState?.jumpToDate(_initialDisplayDate()); + _calendarKey.currentState?.jumpToToday(); + } + + bool _isOnInitialWeek(TimetableState state) => + state.startDate == _mondayOf(_initialDisplayDate()); + + Future _openPicker() async { + final ref = await AppRoutes.openElementPicker(context); + if (!mounted || ref == null) return; + setState(() => _selected = ref); + } + + void _backToOwnPlan() { + setState(() => _selected = null); } void _onAction(_CalendarAction action) { @@ -53,43 +64,44 @@ class _TimetableState extends State { } } - List _appointments(TimetableState state) { - final timetableSettings = context - .watch() - .val() - .timetableSettings; - if (_cachedAppointments != null && - _lastDataVersion == state.dataVersion && - identical(_lastTimetableSettings, timetableSettings)) { - return _cachedAppointments!; - } - _lastDataVersion = state.dataVersion; - _lastTimetableSettings = timetableSettings; - - return _cachedAppointments = TimetableAppointmentFactory( - lessons: state.getAllKnownLessons().toList(), - customEvents: state.customEvents?.events ?? const [], - subjects: state.subjects?.result ?? const [], - settings: timetableSettings, - now: DateTime.now(), - ).build(); + void _onCreateEventAt(DateTime start, DateTime end) { + showDialog( + context: context, + builder: (_) => + CustomEventEditDialog(initialStart: start, initialEnd: end), + barrierDismissible: false, + ); } - bool _isCrossedOut(Appointment appointment) { - final id = appointment.id; - if (id is LessonAppointment) return id.entry.status == 'CANCELLED'; - return false; - } - - bool _isOnInitialWeek(TimetableState state) => - state.startDate == _mondayOf(_initialDisplayDate()); - @override Widget build(BuildContext context) { + final selected = _selected; + if (selected == null) return _buildOwnPlan(context); + // Scope the foreign bloc to the current selection so switching elements + // (or back to the own plan) tears it down and builds a fresh one. + return BlocProvider( + key: ValueKey('${selected.type.name}-${selected.id}'), + create: (_) => ForeignTimetableBloc( + type: selected.type, + elementId: selected.id, + title: selected.label, + ), + // Builder gives us a context *below* the provider so the foreign bloc is + // resolvable inside _buildForeignPlan. + child: Builder( + builder: (context) => _buildForeignPlan(context, selected), + ), + ); + } + + Widget _buildOwnPlan(BuildContext context) { final bloc = context.read(); final loadableState = context.watch().state; final innerState = loadableState.data; final atToday = innerState != null && _isOnInitialWeek(innerState); + final canViewForeign = context + .watch() + .canViewForeignTimetables; return Scaffold( appBar: AppBar( title: const Text('Stunden & Vertretungsplan'), @@ -118,125 +130,178 @@ class _TimetableState extends State { ), ], ), + if (canViewForeign) + IconButton( + icon: const Icon(Icons.person_search), + tooltip: 'Anderen Stundenplan öffnen', + onPressed: _openPicker, + ), ], ), body: LoadableStateConsumer( // Without this predicate the consumer treats the freshly-initialised // empty TimetableState as "has content" and only shows the error bar - // on top — but `_calendar` collapses to `SizedBox.shrink()` while the - // reference data is missing, leaving the user with a blank screen. - // Telling the consumer that "ready" means having reference data - // flips it into the proper error-screen path instead. + // on top — but the calendar view collapses to `SizedBox.shrink()` + // while the reference data is missing, leaving the user with a blank + // screen. Telling the consumer that "ready" means having reference + // data flips it into the proper error-screen path instead. isReady: (state) => state.hasReferenceData, - child: (state, _) => _calendar(state, bloc), + child: (state, _) => TimetableCalendarView( + key: _calendarKey, + state: state, + onWeekChanged: bloc.changeWeek, + onAppointmentTap: (apt) => + AppointmentDetailsDispatcher.show(context, state, apt), + onCreateEvent: _onCreateEventAt, + customEvents: state.customEvents?.events ?? const [], + ), ), ); } - Widget _calendar(TimetableState state, TimetableBloc bloc) { - if (!state.hasReferenceData) return const SizedBox.shrink(); - - 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(); - - // Scroll bounds follow the Webuntis school-year API: the calendar lets - // the user navigate every week the server has data for. A two-week - // fallback is used only while the school-year payload hasn't loaded yet - // (first launch / offline), so the calendar still mounts. - final (minDate, maxDate) = _scrollBounds(state); - - return CustomWorkWeekCalendar( - key: _calendarKey, - schedule: schedule, - appointments: appointments, - timeRegions: regions, - initialDate: _initialDisplayDate(), - minDate: minDate, - maxDate: maxDate, - onAppointmentTap: (apt) => - AppointmentDetailsDispatcher.show(context, bloc, apt), - onWeekChanged: (start, end) => bloc.changeWeek(start, end), - isCrossedOut: _isCrossedOut, - onCreateEvent: _onCreateEventAt, + Widget _buildForeignPlan(BuildContext context, TimetableElementRef selected) { + final bloc = context.read(); + final loadableState = context.watch().state; + final innerState = loadableState.data; + final atToday = innerState != null && _isOnInitialWeek(innerState); + final canViewForeign = context + .watch() + .canViewForeignTimetables; + return Scaffold( + appBar: AppBar( + title: const Text('Stunden & Vertretungsplan'), + actions: [ + IconButton( + icon: const Icon(Icons.home_outlined), + onPressed: atToday ? null : _jumpToToday, + ), + if (canViewForeign) + IconButton( + icon: const Icon(Icons.person_search), + tooltip: 'Anderen Stundenplan öffnen', + onPressed: _openPicker, + ), + ], + ), + body: Column( + children: [ + _ViewingBanner(element: selected, onClose: _backToOwnPlan), + Expanded( + child: LoadableStateConsumer( + // Foreign plans never carry custom events, so unlike the own-plan + // view we must not require `customEvents` here. + isReady: (state) => + state.rooms != null && + state.subjects != null && + state.schoolHolidays != null, + child: (state, _) => TimetableCalendarView( + key: _calendarKey, + state: state, + onWeekChanged: bloc.changeWeek, + onAppointmentTap: (apt) => + AppointmentDetailsDispatcher.show(context, state, apt), + customEvents: const [], + ), + ), + ), + ], + ), ); } - void _onCreateEventAt(DateTime start, DateTime end) { - showDialog( - context: context, - builder: (_) => - CustomEventEditDialog(initialStart: start, initialEnd: end), - barrierDismissible: false, - ); - } - - /// Hard caps applied on top of whatever Webuntis would allow. Even if the - /// school year (or a stale persisted bound) would let the user scroll - /// further, we never expose more than this much around the current week — - /// containment for any future date-math bug that might otherwise teleport - /// the user months away from today. - static const int _maxWeeksBack = 4; - static const int _maxWeeksForward = 2; - - /// Returns the (minDate, maxDate) the user is allowed to scroll between. - /// Starts from the Webuntis school year (or a tight window when that - /// hasn't loaded yet), tightens by anything the bloc has learned from - /// `-7004 no allowed date` errors during scroll — so the user can't - /// slide off into territory Webuntis would refuse anyway — and finally - /// clamps to a fixed window around today. - /// - /// minDate is snapped *forward* to the next Monday because the calendar's - /// internal `_mondayOf()` would otherwise pull a mid-week minDate back - /// into the just-rejected week. maxDate is passed through unsnapped — - /// `_mondayOf()` correctly walks back to the Monday of its own week, - /// which is the last fully-allowed week. - (DateTime, DateTime) _scrollBounds(TimetableState state) { - final year = state.schoolyear; - final DateTime baseMin; - final DateTime baseMax; - if (year != null) { - baseMin = year.startDate; - baseMax = year.endDate; - } else { - final now = DateTime.now(); - baseMin = now.subtractDays(14); - baseMax = now.addDays(7); - } - final effectiveMin = state.accessibleStartDate != null - ? (state.accessibleStartDate!.isAfter(baseMin) - ? state.accessibleStartDate! - : baseMin) - : baseMin; - final effectiveMax = state.accessibleEndDate != null - ? (state.accessibleEndDate!.isBefore(baseMax) - ? state.accessibleEndDate! - : baseMax) - : baseMax; - final todayMonday = _mondayOf(DateTime.now()); - final cappedMin = effectiveMin.isBefore( - todayMonday.subtractDays(_maxWeeksBack * 7), - ) - ? todayMonday.subtractDays(_maxWeeksBack * 7) - : effectiveMin; - final cappedMax = effectiveMax.isAfter( - todayMonday.addDays(_maxWeeksForward * 7 + 6), - ) - ? todayMonday.addDays(_maxWeeksForward * 7 + 6) - : effectiveMax; - final daysToMonday = - (DateTime.monday - cappedMin.weekday) % DateTime.daysPerWeek; - final mondayMin = cappedMin.addDays(daysToMonday); - return (mondayMin, cappedMax); - } - static DateTime _mondayOf(DateTime d) { final monday = d.subtractDays(d.weekday - 1); return DateTime(monday.year, monday.month, monday.day); } } + +/// Slim banner shown at the top of the timetable while a foreign element's plan +/// is being viewed. Displays which element is shown, lets the user star it, and +/// offers a one-tap return to the own plan. +class _ViewingBanner extends StatelessWidget { + final TimetableElementRef element; + final VoidCallback onClose; + + const _ViewingBanner({required this.element, required this.onClose}); + + void _toggleFavorite(BuildContext context) { + Haptics.selection(); + context + .read() + .val(write: true) + .timetableFavoritesSettings + .toggle(element.type, element.id, element.label); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isFavorite = context + .watch() + .val() + .timetableFavoritesSettings + .isFavorite(element.type, element.id); + + final onColor = theme.colorScheme.onSecondaryContainer; + // Compact icon button: ~32px square, no extra padding, so the banner stays + // slim instead of inheriting the default 48px touch target height. + Widget compactButton({ + required IconData icon, + required String tooltip, + required VoidCallback onPressed, + }) => IconButton( + icon: Icon(icon), + iconSize: 18, + color: onColor, + tooltip: tooltip, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + onPressed: onPressed, + ); + + return Material( + color: theme.colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 2, 6, 2), + child: Row( + children: [ + Icon(_iconFor(element.type), size: 16, color: onColor), + const SizedBox(width: 10), + Expanded( + child: Text( + element.label, + style: theme.textTheme.labelLarge?.copyWith(color: onColor), + overflow: TextOverflow.ellipsis, + ), + ), + compactButton( + icon: isFavorite ? Icons.star : Icons.star_border, + tooltip: isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren', + onPressed: () => _toggleFavorite(context), + ), + const SizedBox(width: 4), + compactButton( + icon: Icons.close, + tooltip: 'Zurück zum eigenen Plan', + onPressed: onClose, + ), + ], + ), + ), + ); + } + + static IconData _iconFor(TimetableElementType type) { + switch (type) { + case TimetableElementType.student: + return Icons.person_outline; + case TimetableElementType.teacher: + return Icons.school_outlined; + case TimetableElementType.room: + return Icons.meeting_room_outlined; + case TimetableElementType.schoolClass: + return Icons.groups_outlined; + } + } +} diff --git a/lib/view/pages/timetable/widgets/timetable_calendar_view.dart b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart new file mode 100644 index 0000000..203a739 --- /dev/null +++ b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../../../../extensions/date_time.dart'; +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; +import '../../../../storage/timetable_settings.dart'; +import '../data/arbitrary_appointment.dart'; +import '../data/lesson_period_schedule.dart'; +import '../data/timetable_appointment_factory.dart'; +import 'custom_workweek_calendar.dart'; +import 'special_regions_builder.dart'; + +/// Renders a weekly timetable from a [TimetableState]. Shared by the user's own +/// plan and the foreign-element view; the only differences are which custom +/// events to overlay (none for foreign plans) and whether tapping an empty slot +/// can create an event ([onCreateEvent] is null for read-only foreign plans). +/// +/// The week navigation and appointment-tap callbacks are supplied by the host +/// page so each can route them to its own bloc. +class TimetableCalendarView extends StatefulWidget { + final TimetableState state; + final void Function(DateTime weekStart, DateTime weekEnd) onWeekChanged; + final void Function(Appointment appointment) onAppointmentTap; + final void Function(DateTime start, DateTime end)? onCreateEvent; + final List customEvents; + + const TimetableCalendarView({ + super.key, + required this.state, + required this.onWeekChanged, + required this.onAppointmentTap, + this.onCreateEvent, + this.customEvents = const [], + }); + + @override + State createState() => TimetableCalendarViewState(); +} + +class TimetableCalendarViewState extends State { + final GlobalKey _calendarKey = + GlobalKey(); + + List? _cachedAppointments; + int? _lastDataVersion; + TimetableSettings? _lastTimetableSettings; + List? _lastCustomEvents; + + DateTime _initialDisplayDate() => DateTime.now().addDays(2); + + /// Snaps the calendar back to the current week. Exposed so host pages can + /// wire it to a "today" AppBar action. + void jumpToToday() { + _calendarKey.currentState?.jumpToDate(_initialDisplayDate()); + } + + bool isOnInitialWeek() => + widget.state.startDate == _mondayOf(_initialDisplayDate()); + + List _appointments(TimetableState state) { + final timetableSettings = context + .watch() + .val() + .timetableSettings; + if (_cachedAppointments != null && + _lastDataVersion == state.dataVersion && + identical(_lastTimetableSettings, timetableSettings) && + identical(_lastCustomEvents, widget.customEvents)) { + return _cachedAppointments!; + } + _lastDataVersion = state.dataVersion; + _lastTimetableSettings = timetableSettings; + _lastCustomEvents = widget.customEvents; + + return _cachedAppointments = TimetableAppointmentFactory( + lessons: state.getAllKnownLessons().toList(), + customEvents: widget.customEvents, + subjects: state.subjects?.result ?? const [], + settings: timetableSettings, + now: DateTime.now(), + ).build(); + } + + bool _isCrossedOut(Appointment appointment) { + final id = appointment.id; + if (id is LessonAppointment) return id.entry.status == 'CANCELLED'; + return false; + } + + @override + Widget build(BuildContext context) { + final state = widget.state; + // Reference data is gated by the host's LoadableStateConsumer isReady + // predicate, but guard the one hard dereference (schoolHolidays) so a + // transient null can never crash the build. + if (state.schoolHolidays == null) return const SizedBox.shrink(); + + 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(); + + final (minDate, maxDate) = _scrollBounds(state); + + return CustomWorkWeekCalendar( + key: _calendarKey, + schedule: schedule, + appointments: appointments, + timeRegions: regions, + initialDate: _initialDisplayDate(), + minDate: minDate, + maxDate: maxDate, + onAppointmentTap: widget.onAppointmentTap, + onWeekChanged: widget.onWeekChanged, + isCrossedOut: _isCrossedOut, + onCreateEvent: widget.onCreateEvent, + ); + } + + /// Hard caps applied on top of whatever Webuntis would allow. Even if the + /// school year (or a stale persisted bound) would let the user scroll + /// further, we never expose more than this much around the current week. + static const int _maxWeeksBack = 4; + static const int _maxWeeksForward = 2; + + /// Returns the (minDate, maxDate) the user is allowed to scroll between. + /// Starts from the Webuntis school year (or a tight window when that hasn't + /// loaded yet), tightens by anything the bloc has learned from past denials, + /// and finally clamps to a fixed window around today. + (DateTime, DateTime) _scrollBounds(TimetableState state) { + final year = state.schoolyear; + final DateTime baseMin; + final DateTime baseMax; + if (year != null) { + baseMin = year.startDate; + baseMax = year.endDate; + } else { + final now = DateTime.now(); + baseMin = now.subtractDays(14); + baseMax = now.addDays(7); + } + final effectiveMin = state.accessibleStartDate != null + ? (state.accessibleStartDate!.isAfter(baseMin) + ? state.accessibleStartDate! + : baseMin) + : baseMin; + final effectiveMax = state.accessibleEndDate != null + ? (state.accessibleEndDate!.isBefore(baseMax) + ? state.accessibleEndDate! + : baseMax) + : baseMax; + final todayMonday = _mondayOf(DateTime.now()); + final cappedMin = effectiveMin.isBefore( + todayMonday.subtractDays(_maxWeeksBack * 7), + ) + ? todayMonday.subtractDays(_maxWeeksBack * 7) + : effectiveMin; + final cappedMax = effectiveMax.isAfter( + todayMonday.addDays(_maxWeeksForward * 7 + 6), + ) + ? todayMonday.addDays(_maxWeeksForward * 7 + 6) + : effectiveMax; + final daysToMonday = + (DateTime.monday - cappedMin.weekday) % DateTime.daysPerWeek; + final mondayMin = cappedMin.addDays(daysToMonday); + return (mondayMin, cappedMax); + } + + static DateTime _mondayOf(DateTime d) { + final monday = d.subtractDays(d.weekday - 1); + return DateTime(monday.year, monday.month, monday.day); + } +} diff --git a/lib/widget/emoji_picker_dialog.dart b/lib/widget/emoji_picker_dialog.dart new file mode 100644 index 0000000..73456df --- /dev/null +++ b/lib/widget/emoji_picker_dialog.dart @@ -0,0 +1,64 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emojis; +import 'package:flutter/material.dart'; + +import 'emoji_text.dart'; + +/// Shows the app-wide emoji picker and resolves with the chosen emoji, or +/// `null` if the dialog was dismissed without a selection. +/// +/// Single source of truth for the picker styling so every entry point (message +/// reactions, the compose field, …) looks and behaves the same. +Future showEmojiPicker( + BuildContext context, { + String title = 'Emoji wählen', +}) { + return showDialog( + context: context, + 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), + Text(title), + ], + ), + content: SizedBox( + width: double.maxFinite, + height: 360, + child: emojis.EmojiPicker( + config: emojis.Config( + height: 360, + emojiViewConfig: emojis.EmojiViewConfig( + backgroundColor: Theme.of(pickerCtx).canvasColor, + recentsLimit: 67, + emojiSizeMax: EmojiText.sizeLarge, + 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(emoji.emoji); + }, + ), + ), + ), + ); +} diff --git a/lib/widget/emoji_text.dart b/lib/widget/emoji_text.dart new file mode 100644 index 0000000..47f2584 --- /dev/null +++ b/lib/widget/emoji_text.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +/// Central place for rendering emoji glyphs (reactions, pickers, …). +/// +/// Emojis used to be drawn as bare `Text(emoji)` widgets all over the Talk UI, +/// which meant they inherited the small default body text size and looked +/// inconsistent. [EmojiText] gives every emoji a uniform, comfortably large +/// size and forces the platform's color-emoji font so the rendering is the same +/// everywhere. +class EmojiText extends StatelessWidget { + /// Size for emojis shown inline next to other text, e.g. reaction chips. + static const double sizeInline = 15; + + /// Default size for standalone emojis, e.g. list leadings. + static const double sizeStandard = 20; + + /// Size for primary tap targets, e.g. the quick-reaction buttons. + static const double sizeLarge = 24; + + final String emoji; + final double size; + + const EmojiText(this.emoji, {this.size = sizeStandard, super.key}); + + @override + Widget build(BuildContext context) => Text( + emoji, + style: TextStyle( + fontSize: size, + height: 1.0, + // Render emojis with the platform color-emoji font instead of the app + // font, so they look identical across all usages and devices. + fontFamilyFallback: const ['Noto Color Emoji', 'Apple Color Emoji'], + ), + ); +} diff --git a/test/api/timetable_element_type_test.dart b/test/api/timetable_element_type_test.dart new file mode 100644 index 0000000..8949cb0 --- /dev/null +++ b/test/api/timetable_element_type_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; + +void main() { + group('TimetableElementType.pathSegment', () { + test('maps each type to the backend URL segment', () { + expect(TimetableElementType.student.pathSegment, 'student'); + expect(TimetableElementType.teacher.pathSegment, 'teacher'); + expect(TimetableElementType.room.pathSegment, 'room'); + // `schoolClass` exists only because `class` is a reserved word; the + // backend segment must still be `class`. + expect(TimetableElementType.schoolClass.pathSegment, 'class'); + }); + }); + + group('TimetableElementType.label', () { + test('provides a German singular label for each type', () { + expect(TimetableElementType.student.label, 'Schüler'); + expect(TimetableElementType.teacher.label, 'Lehrer'); + expect(TimetableElementType.room.label, 'Raum'); + expect(TimetableElementType.schoolClass.label, 'Klasse'); + }); + }); +}