diff --git a/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart b/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart index a61da36..6fc5614 100644 --- a/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart +++ b/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart @@ -10,10 +10,15 @@ class McSubject { final String shortName; final String longName; + /// Persönliche Fach-Farbe (Palette-Name aus [SubjectColor]), serverseitig pro + /// Nutzer gepflegt. `null`, wenn für dieses Fach keine Farbe gesetzt ist. + final String? color; + McSubject({ required this.id, required this.shortName, required this.longName, + this.color, }); factory McSubject.fromJson(Map json) => diff --git a/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.g.dart b/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.g.dart index 78e77b5..d7672af 100644 --- a/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.g.dart +++ b/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.g.dart @@ -10,12 +10,14 @@ McSubject _$McSubjectFromJson(Map json) => McSubject( id: (json['id'] as num).toInt(), shortName: json['shortName'] as String, longName: json['longName'] as String, + color: json['color'] as String?, ); Map _$McSubjectToJson(McSubject instance) => { 'id': instance.id, 'shortName': instance.shortName, 'longName': instance.longName, + 'color': instance.color, }; TimetableGetSubjectsResponse _$TimetableGetSubjectsResponseFromJson( diff --git a/lib/api/marianumconnect/queries/timetable_subject_colors/timetable_subject_color_remove.dart b/lib/api/marianumconnect/queries/timetable_subject_colors/timetable_subject_color_remove.dart new file mode 100644 index 0000000..44b7631 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_subject_colors/timetable_subject_color_remove.dart @@ -0,0 +1,25 @@ +import 'package:dio/dio.dart'; + +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; + +/// Entfernt die persönliche Farbe eines Fachs (Fach fällt auf die Status-Farbe +/// zurück). Schlüssel ist das Fach-Kürzel (`shortName`). +class TimetableSubjectColorRemove { + final Dio _dio; + + TimetableSubjectColorRemove({Dio? dio}) + : _dio = dio ?? MarianumConnectApi.dio(); + + Future run(String subjectShort) async { + try { + await _dio.delete( + MarianumConnectEndpoint.resolve('timetable/subject-colors'), + queryParameters: {'subject': subjectShort}, + ); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/timetable_subject_colors/timetable_subject_color_set.dart b/lib/api/marianumconnect/queries/timetable_subject_colors/timetable_subject_color_set.dart new file mode 100644 index 0000000..674359a --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_subject_colors/timetable_subject_color_set.dart @@ -0,0 +1,24 @@ +import 'package:dio/dio.dart'; + +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; + +/// Setzt (oder überschreibt) die persönliche Farbe eines Fachs. Der Schlüssel +/// ist das Fach-Kürzel (`shortName`); die Farbe ist ein Palette-Name. +class TimetableSubjectColorSet { + final Dio _dio; + + TimetableSubjectColorSet({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); + + Future run(String subjectShort, String color) async { + try { + await _dio.put( + MarianumConnectEndpoint.resolve('timetable/subject-colors'), + data: {'subject': subjectShort, 'color': color}, + ); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 4a47240..143bf52 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -36,6 +36,7 @@ import '../view/pages/talk/details/message_reactions.dart'; import '../view/pages/talk/talk_navigator.dart'; import '../view/pages/ticker/ticker_page_view.dart'; import '../view/pages/timetable/custom_events/custom_events_view.dart'; +import '../view/pages/timetable/subject_colors/subject_colors_view.dart'; import '../widget/debug/cache_view.dart'; import '../widget/file_viewer.dart'; import '../widget/user_avatar.dart'; @@ -90,6 +91,10 @@ class AppRoutes { pushScreen(context, withNavBar: false, screen: const CustomEventsView()); } + static void openSubjectColors(BuildContext context) { + pushScreen(context, withNavBar: false, screen: const SubjectColorsView()); + } + /// 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` diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart index a9c848c..3e9160f 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -121,6 +121,27 @@ class TimetableBloc await _refreshCustomEvents(); } + /// Setzt die persönliche Farbe eines Fachs (Schlüssel: Fach-Kürzel) und lädt + /// die Fächer neu, damit die eingebettete Farbe im Kalender wirkt. + Future setSubjectColor(String subjectShort, String color) async { + await repo.data.setSubjectColor(subjectShort, color); + await _refreshSubjects(); + } + + Future clearSubjectColor(String subjectShort) async { + await repo.data.removeSubjectColor(subjectShort); + await _refreshSubjects(); + } + + Future _refreshSubjects() async { + final subjects = await repo.data.getSubjects(renew: true); + add( + DataGathered( + (s) => s.copyWith(subjects: subjects, dataVersion: s.dataVersion + 1), + ), + ); + } + Future _loadCurrentWeek( DateTime startDate, DateTime endDate, { diff --git a/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart index 2ada19f..dd38d2c 100644 --- a/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart +++ b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart @@ -16,6 +16,8 @@ import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timeta import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; +import '../../../../../api/marianumconnect/queries/timetable_subject_colors/timetable_subject_color_remove.dart'; +import '../../../../../api/marianumconnect/queries/timetable_subject_colors/timetable_subject_color_set.dart'; import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../../../../../api/request_cache.dart'; @@ -119,4 +121,14 @@ class TimetableDataProvider { if (DemoMode.active) return Future.value(); return TimetableCustomEventsRemove().run(id); } + + Future setSubjectColor(String subjectShort, String color) { + if (DemoMode.active) return Future.value(); + return TimetableSubjectColorSet().run(subjectShort, color); + } + + Future removeSubjectColor(String subjectShort) { + if (DemoMode.active) return Future.value(); + return TimetableSubjectColorRemove().run(subjectShort); + } } diff --git a/lib/view/pages/settings/sections/timetable_section.dart b/lib/view/pages/settings/sections/timetable_section.dart index e63917b..f5ddfc6 100644 --- a/lib/view/pages/settings/sections/timetable_section.dart +++ b/lib/view/pages/settings/sections/timetable_section.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../utils/haptics.dart'; import '../../../../view/pages/timetable/data/timetable_name_mode.dart'; @@ -56,6 +57,12 @@ class TimetableSection extends StatelessWidget { }, ), ), + ListTile( + leading: const Icon(Icons.palette_outlined), + title: const Text('Fach-Farben'), + trailing: const Icon(Icons.chevron_right), + onTap: () => AppRoutes.openSubjectColors(context), + ), ], ); } diff --git a/lib/view/pages/timetable/custom_events/custom_event_colors.dart b/lib/view/pages/timetable/custom_events/custom_event_colors.dart deleted file mode 100644 index 3a74984..0000000 --- a/lib/view/pages/timetable/custom_events/custom_event_colors.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../../theming/dark_app_theme.dart'; - -enum CustomTimetableColors { orange, red, green, blue } - -class TimetableColors { - static const CustomTimetableColors defaultColor = - CustomTimetableColors.orange; - - static ColorModeDisplay getDisplayOptions(CustomTimetableColors color) { - switch (color) { - case CustomTimetableColors.green: - return ColorModeDisplay(color: Colors.green, displayName: 'Grün'); - case CustomTimetableColors.blue: - return ColorModeDisplay(color: Colors.blue, displayName: 'Blau'); - case CustomTimetableColors.orange: - return ColorModeDisplay( - color: Colors.orange.shade800, - displayName: 'Orange', - ); - case CustomTimetableColors.red: - return ColorModeDisplay( - color: DarkAppTheme.marianumRed, - displayName: 'Rot', - ); - } - } - - static Color getColorFromString(String color) => getDisplayOptions( - CustomTimetableColors.values.firstWhere( - (e) => e.name == color, - orElse: () => defaultColor, - ), - ).color; -} - -class ColorModeDisplay { - final Color color; - final String displayName; - - ColorModeDisplay({required this.color, required this.displayName}); -} diff --git a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart index 544293d..afdb8ea 100644 --- a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart +++ b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart @@ -12,7 +12,8 @@ import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/demo_restricted.dart'; import '../../../../widget/focus_behaviour.dart'; -import 'custom_event_colors.dart'; +import '../data/subject_color_palette.dart'; +import '../subject_colors/subject_color_picker.dart'; class CustomEventEditDialog extends StatefulWidget { final CustomTimetableEvent? existingEvent; @@ -61,10 +62,9 @@ class _CustomEventEditDialogState extends State { // mhsl-Termine tragen ausgeschaltet (Modell-Default), da sie nie ein Serien- // Ferien-Flag hatten. late bool _skipHolidays = widget.existingEvent?.skipHolidays ?? true; - late CustomTimetableColors _color = CustomTimetableColors.values.firstWhere( - (e) => e.name == widget.existingEvent?.color, - orElse: () => TimetableColors.defaultColor, - ); + late SubjectColor _color = + SubjectColorPalette.fromName(widget.existingEvent?.color) ?? + SubjectColorPalette.defaultColor; bool get _isEditing => widget.existingEvent != null; @@ -261,30 +261,18 @@ class _CustomEventEditDialogState extends State { ListTile( leading: const Icon(Icons.color_lens_outlined), title: const Text('Farbgebung'), - trailing: DropdownButton( - value: _color, - icon: const Icon(Icons.arrow_drop_down), - items: CustomTimetableColors.values - .map( - (e) => DropdownMenuItem( - value: e, - enabled: e != _color, - child: Row( - children: [ - Icon( - Icons.circle, - color: TimetableColors.getDisplayOptions(e).color, - ), - const SizedBox(width: 10), - Text( - TimetableColors.getDisplayOptions(e).displayName, - ), - ], - ), - ), - ) - .toList(), - onChanged: (e) => setState(() => _color = e!), + subtitle: Text(SubjectColorPalette.displayOf(_color).displayName), + trailing: Icon( + Icons.circle, + color: SubjectColorPalette.displayOf(_color).color, + ), + onTap: () => showTimetableColorPicker( + context, + title: 'Farbe wählen', + current: _color, + // Rein lokal (kein Netzwerk) — Sheet schließt sofort, gespeichert + // wird erst beim „Speichern" des Termins. + onSelected: (option) async => setState(() => _color = option), ), ), const Divider(), diff --git a/lib/view/pages/timetable/data/subject_color_palette.dart b/lib/view/pages/timetable/data/subject_color_palette.dart new file mode 100644 index 0000000..ad9b1b6 --- /dev/null +++ b/lib/view/pages/timetable/data/subject_color_palette.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +/// Kuratierte Palette für Timetable-Farben (Fächer **und** eigene Termine). Die +/// Werte sind bewusst von den Status-Signalfarben in [lesson_color.dart] +/// abgesetzt (Marianum-Rot, Schwarz, Signallila, Signalblau, Event-Grün, +/// Duty-Petrol), damit gefärbte Regelstunden nicht mit Entfall/Vertretung +/// verwechselt werden. Der Server speichert nur den Enum-Namen; die konkrete +/// [Color] wird hier gemappt. +enum SubjectColor { + amber, + deepOrange, + pink, + purple, + indigo, + cyan, + teal, + lime, + brown, + blueGrey, +} + +class SubjectColorPalette { + const SubjectColorPalette._(); + + /// Standardfarbe für neue eigene Termine (Fächer haben keinen Default — + /// dort bedeutet „keine Farbe" das Status-Rot). + static const SubjectColor defaultColor = SubjectColor.amber; + + /// Altbestand: eigene Termine wurden früher mit der 4er-Palette + /// (orange/red/green/blue) gespeichert. Diese Namen bleiben auflösbar und + /// bilden auf die nächstliegende neue Farbe ab, damit bestehende Termine + /// nicht ihre Farbe verlieren. + static const Map _legacyAliases = { + 'orange': SubjectColor.amber, + 'red': SubjectColor.deepOrange, + 'green': SubjectColor.teal, + 'blue': SubjectColor.indigo, + }; + + static ColorModeDisplay displayOf(SubjectColor color) => switch (color) { + SubjectColor.amber => ColorModeDisplay( + color: Colors.amber.shade700, + displayName: 'Bernstein', + ), + SubjectColor.deepOrange => ColorModeDisplay( + color: Colors.deepOrange.shade600, + displayName: 'Dunkelorange', + ), + SubjectColor.pink => ColorModeDisplay( + color: Colors.pink.shade500, + displayName: 'Pink', + ), + SubjectColor.purple => ColorModeDisplay( + color: Colors.deepPurple.shade400, + displayName: 'Violett', + ), + SubjectColor.indigo => ColorModeDisplay( + color: Colors.indigo.shade400, + displayName: 'Indigo', + ), + SubjectColor.cyan => ColorModeDisplay( + color: Colors.cyan.shade700, + displayName: 'Cyan', + ), + SubjectColor.teal => ColorModeDisplay( + color: Colors.teal.shade400, + displayName: 'Türkis', + ), + SubjectColor.lime => ColorModeDisplay( + color: Colors.lime.shade800, + displayName: 'Limette', + ), + SubjectColor.brown => ColorModeDisplay( + color: Colors.brown.shade400, + displayName: 'Braun', + ), + SubjectColor.blueGrey => ColorModeDisplay( + color: Colors.blueGrey.shade400, + displayName: 'Blaugrau', + ), + }; + + /// Löst einen gespeicherten Namen (neue Palette oder Legacy-Alias) zum + /// [SubjectColor] auf. `null`, wenn der Name leer/unbekannt ist. + static SubjectColor? fromName(String? name) { + if (name == null || name.isEmpty) return null; + final match = SubjectColor.values.where((e) => e.name == name).firstOrNull; + return match ?? _legacyAliases[name]; + } + + /// Wie [fromName], liefert aber die konkrete [Color]. `null`, wenn der Name + /// leer/unbekannt ist — der Aufrufer fällt dann auf die Status-Farbe zurück. + static Color? colorFromName(String? name) { + final option = fromName(name); + return option == null ? null : displayOf(option).color; + } +} + +class ColorModeDisplay { + final Color color; + final String displayName; + + ColorModeDisplay({required this.color, required this.displayName}); +} diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index baccbb4..0bf4d55 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../../api/marianumconnect/models/mc_holiday.dart'; @@ -5,12 +6,12 @@ import '../../../../api/marianumconnect/queries/timetable_get_subjects/timetable import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../storage/timetable_settings.dart'; -import '../custom_events/custom_event_colors.dart'; import 'arbitrary_appointment.dart'; import 'lesson_color.dart'; import 'lesson_status.dart'; import 'lesson_type_label.dart'; import 'rrule_with_exceptions.dart'; +import 'subject_color_palette.dart'; import 'timetable_name_mode.dart'; class TimetableAppointmentFactory { @@ -65,7 +66,7 @@ class TimetableAppointmentFactory { endTime: endTime, subject: _subjectName(subjectShortName, lesson), location: _locationLabel(lesson), - color: LessonColor.forStatus(status), + color: _lessonColor(status, subjectShortName), ); } catch (_) { return Appointment( @@ -131,9 +132,9 @@ class TimetableAppointmentFactory { subject: _collapseWhitespace(event.title) ?? event.title, recurrenceRule: parsed.rule, recurrenceExceptionDates: exceptionDates.isEmpty ? null : exceptionDates, - color: TimetableColors.getColorFromString( - event.color ?? TimetableColors.defaultColor.name, - ), + color: + SubjectColorPalette.colorFromName(event.color) ?? + SubjectColorPalette.displayOf(SubjectColorPalette.defaultColor).color, startTimeZone: '', endTimeZone: '', ); @@ -187,10 +188,32 @@ class TimetableAppointmentFactory { e.second == 0; } + /// Regelstunden (regulär/laufend/vergangen) übernehmen die persönliche + /// Fach-Farbe, sofern gesetzt. Sonderstatus (Entfall, Vertretung, Aufsicht, + /// Event) behalten immer ihre Signalfarbe, damit die Abgrenzung erhalten + /// bleibt. + Color _lessonColor(LessonStatus status, String? subjectShort) { + final isRegular = + status == LessonStatus.regular || + status == LessonStatus.ongoing || + status == LessonStatus.past; + if (isRegular) { + final custom = SubjectColorPalette.colorFromName( + _findSubject(subjectShort)?.color, + ); + if (custom != null) return custom; + } + return LessonColor.forStatus(status); + } + + McSubject? _findSubject(String? subjectShort) { + if (subjectShort == null) return null; + return subjects.where((s) => s.shortName == subjectShort).firstOrNull; + } + String _subjectName(String? subjectShort, McTimetableEntry lesson) { if (subjectShort != null) { - final lookup = - subjects.where((s) => s.shortName == subjectShort).firstOrNull; + final lookup = _findSubject(subjectShort); final name = switch (settings.timetableNameMode) { // Backend liefert nur shortName + longName; alternateName fällt auf // longName zurück. diff --git a/lib/view/pages/timetable/details/appointment_details_dispatcher.dart b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart index 404c793..de8edd0 100644 --- a/lib/view/pages/timetable/details/appointment_details_dispatcher.dart +++ b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart @@ -10,13 +10,20 @@ class AppointmentDetailsDispatcher { static void show( BuildContext context, TimetableState? state, - Appointment appointment, - ) { + Appointment appointment, { + bool canEditSubjectColor = false, + }) { final id = appointment.id; if (id is! ArbitraryAppointment) return; id.when( - lesson: (entry) => LessonSheet.show(context, state, appointment, entry), + lesson: (entry) => LessonSheet.show( + context, + state, + appointment, + entry, + canEditSubjectColor: canEditSubjectColor, + ), custom: (event) => CustomEventSheet.show(context, event), ); } diff --git a/lib/view/pages/timetable/details/custom_event_sheet.dart b/lib/view/pages/timetable/details/custom_event_sheet.dart index a9de7ab..cd73ad7 100644 --- a/lib/view/pages/timetable/details/custom_event_sheet.dart +++ b/lib/view/pages/timetable/details/custom_event_sheet.dart @@ -1,18 +1,22 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:rrule/rrule.dart'; import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../extensions/date_time.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/details_bottom_sheet.dart'; import '../custom_events/custom_event_edit_dialog.dart'; import '../data/rrule_with_exceptions.dart'; +import '../subject_colors/subject_color_picker.dart'; import 'delete_custom_event.dart'; class CustomEventSheet { static void show(BuildContext context, CustomTimetableEvent event) { final timeRange = event.startDate.timeRangeTo(event.endDate); + final bloc = context.read(); showDetailsBottomSheet( context, @@ -23,6 +27,12 @@ class CustomEventSheet { style: const TextStyle(fontWeight: FontWeight.bold), ), subtitle: Text(timeRange), + trailing: TimetableColorHeaderButton( + initialColorName: event.color, + pickerTitle: 'Farbe wählen', + onSelected: (option) => + bloc.updateCustomEvent(event.id, _withColor(event, option.name)), + ), ), children: (sheetCtx) => [ Padding( @@ -104,4 +114,18 @@ class CustomEventSheet { ], ); } + + static CustomTimetableEvent _withColor(CustomTimetableEvent e, String color) => + CustomTimetableEvent( + id: e.id, + title: e.title, + description: e.description, + startDate: e.startDate, + endDate: e.endDate, + color: color, + rrule: e.rrule, + skipHolidays: e.skipHolidays, + createdAt: e.createdAt, + updatedAt: DateTime.now(), + ); } diff --git a/lib/view/pages/timetable/details/lesson_sheet.dart b/lib/view/pages/timetable/details/lesson_sheet.dart index bb91c46..b0dfb1c 100644 --- a/lib/view/pages/timetable/details/lesson_sheet.dart +++ b/lib/view/pages/timetable/details/lesson_sheet.dart @@ -1,31 +1,39 @@ 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_week/timetable_get_week_response.dart'; import '../../../../extensions/date_time.dart'; import '../../../../extensions/text.dart'; import '../../../../routing/app_routes.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/details_bottom_sheet.dart'; import '../data/lesson_type_label.dart'; +import '../subject_colors/subject_color_picker.dart'; class LessonSheet { static void show( BuildContext context, TimetableState? state, Appointment appointment, - McTimetableEntry lesson, - ) { + McTimetableEntry lesson, { + bool canEditSubjectColor = false, + }) { if (state == null) return; + // In Fremdansichten würde der Farb-Button die eigene, globale Fach-Farbe + // ändern, ohne dass sich der fremde Plan aktualisiert — daher nur im + // eigenen Plan anbieten. Die Einfärbung (eigene Farben) bleibt. + final bloc = context.read(); final subjectShort = lesson.subjects.firstOrNull; - final headerLong = subjectShort == null + final subjectEntry = subjectShort == null ? null : state.subjects?.result .where((s) => s.shortName == subjectShort) - .firstOrNull - ?.longName; + .firstOrNull; + final headerLong = subjectEntry?.longName; // Bei Stunden ohne Fach (Pausenaufsicht etc.) den Lesson-Type-Titel // einsetzen — sonst stünde im Header nur ein generisches "?". final headerTitle = subjectShort != null @@ -50,6 +58,18 @@ class LessonSheet { headerLongName.isNotEmpty ? '$timeRange\n$headerLongName' : timeRange, ), isThreeLine: headerLongName.isNotEmpty, + trailing: canEditSubjectColor && subjectShort != null + ? TimetableColorHeaderButton( + initialColorName: subjectEntry?.color, + pickerTitle: (headerLong != null && headerLong.isNotEmpty) + ? 'Farbe: $headerLong' + : 'Farbe: $subjectShort', + pickerSubtitle: 'Nur reguläre Stunden werden eingefärbt', + onSelected: (option) => + bloc.setSubjectColor(subjectShort, option.name), + onReset: () => bloc.clearSubjectColor(subjectShort), + ) + : null, ), children: (_) => [ ListTile( diff --git a/lib/view/pages/timetable/subject_colors/search_subject_colors.dart b/lib/view/pages/timetable/subject_colors/search_subject_colors.dart new file mode 100644 index 0000000..0b94608 --- /dev/null +++ b/lib/view/pages/timetable/subject_colors/search_subject_colors.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; +import '../../../../widget/placeholder_view.dart'; +import 'subject_color_filter.dart'; +import 'subject_color_tile.dart'; + +/// AppBar-Suche für die Fach-Farben (gleiches Muster wie die übrigen Bereiche). +/// Über [BlocBuilder] reaktiv, damit sich Farbe/Sortierung nach einer Änderung +/// sofort aktualisieren. +class SearchSubjectColors extends SearchDelegate { + @override + List? buildActions(BuildContext context) => [ + if (query.isNotEmpty) + IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)), + ]; + + @override + Widget? buildLeading(BuildContext context) => IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => close(context, null), + ); + + @override + Widget buildResults(BuildContext context) => _results(context); + + @override + Widget buildSuggestions(BuildContext context) => _results(context); + + Widget _results(BuildContext context) { + return LoadableStateConsumer( + child: (state, _) { + final relevant = filterAndSortSubjects( + state.subjects?.result ?? const [], + planSubjectShortNames(state), + ); + final matches = searchSubjects(relevant, query); + if (matches.isEmpty) { + return const PlaceholderView( + icon: Icons.search_off_outlined, + text: 'Keine Treffer', + ); + } + return ListView.builder( + itemCount: matches.length, + itemBuilder: (_, index) => SubjectColorTile(matches[index]), + ); + }, + ); + } +} diff --git a/lib/view/pages/timetable/subject_colors/subject_color_filter.dart b/lib/view/pages/timetable/subject_colors/subject_color_filter.dart new file mode 100644 index 0000000..2c33f6e --- /dev/null +++ b/lib/view/pages/timetable/subject_colors/subject_color_filter.dart @@ -0,0 +1,52 @@ +import '../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; + +/// Fach-Kürzel, die im geladenen Stundenplan des Nutzers vorkommen. +Set planSubjectShortNames(TimetableState state) => state + .getAllKnownLessons() + .expand((lesson) => lesson.subjects) + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toSet(); + +/// Anzeigename eines Fachs: langer Name, sonst Kürzel. +String subjectDisplayLabel(McSubject s) => + s.longName.trim().isEmpty ? s.shortName : s.longName; + +/// Fächer, die im Stundenplan vorkommen ([planShorts]) oder bereits eine Farbe +/// haben — gefärbte zuerst, sonst alphabetisch nach Anzeigename. So verschwindet +/// ein gefärbtes Fach nicht, nur weil es in den geladenen Wochen gerade nicht +/// stattfindet. Fächer ohne Kürzel werden verworfen. +List filterAndSortSubjects( + List all, + Set planShorts, +) { + return all + .where( + (s) => + s.shortName.trim().isNotEmpty && + (planShorts.contains(s.shortName) || s.color != null), + ) + .toList() + ..sort((a, b) { + final aColored = a.color != null; + final bColored = b.color != null; + if (aColored != bColored) return aColored ? -1 : 1; + return subjectDisplayLabel( + a, + ).toLowerCase().compareTo(subjectDisplayLabel(b).toLowerCase()); + }); +} + +/// Filtert nach Kürzel oder langem Namen (case-insensitive). Leere Query → alle. +List searchSubjects(List subjects, String query) { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return subjects; + return subjects + .where( + (s) => + s.shortName.toLowerCase().contains(q) || + s.longName.toLowerCase().contains(q), + ) + .toList(); +} diff --git a/lib/view/pages/timetable/subject_colors/subject_color_picker.dart b/lib/view/pages/timetable/subject_colors/subject_color_picker.dart new file mode 100644 index 0000000..ee5911d --- /dev/null +++ b/lib/view/pages/timetable/subject_colors/subject_color_picker.dart @@ -0,0 +1,303 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../../widget/app_progress_indicator.dart'; +import '../../../../widget/async_action_button.dart'; +import '../../../../widget/details_bottom_sheet.dart'; +import '../data/subject_color_palette.dart'; + +typedef ColorPickCallback = Future Function(SubjectColor option); + +/// Gemeinsames Farbauswahl-Sheet für Fächer und eigene Termine. Beim Antippen +/// einer Farbe läuft [onSelected] (bzw. [onReset] für die Standardfarbe); +/// solange zeigt der gewählte Kreis einen Spinner und blockiert weitere +/// Eingaben. Bei Erfolg schließt das Sheet, bei Fehler bleibt es offen und +/// zeigt die Meldung inline — konsistent mit [AsyncListTile]/[AsyncDialogAction]. +/// Der Aufrufer entscheidet, ob der Wert lokal übernommen oder direkt +/// persistiert wird. +Future showTimetableColorPicker( + BuildContext context, { + required String title, + String? subtitle, + SubjectColor? current, + required ColorPickCallback onSelected, + Future Function()? onReset, +}) { + return showDetailsBottomSheet( + context, + header: ListTile( + leading: const Icon(Icons.palette_outlined, size: 32), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: subtitle == null ? null : Text(subtitle), + ), + children: (sheetCtx) => [ + _ColorChoices( + current: current, + onSelected: onSelected, + onReset: onReset, + sheetContext: sheetCtx, + ), + ], + ); +} + +/// Fach-spezifischer Wrapper: öffnet den Picker und persistiert die Auswahl +/// direkt über den [TimetableBloc]. +Future showSubjectColorPicker( + BuildContext context, { + required String subjectShort, + required String subjectLabel, + String? currentColorName, +}) { + final bloc = context.read(); + return showTimetableColorPicker( + context, + title: 'Farbe: $subjectLabel', + subtitle: 'Nur reguläre Stunden werden eingefärbt', + current: SubjectColorPalette.fromName(currentColorName), + onSelected: (option) => bloc.setSubjectColor(subjectShort, option.name), + onReset: () => bloc.clearSubjectColor(subjectShort), + ); +} + +class _ColorChoices extends StatefulWidget { + final SubjectColor? current; + final ColorPickCallback onSelected; + final Future Function()? onReset; + final BuildContext sheetContext; + + const _ColorChoices({ + required this.current, + required this.onSelected, + required this.onReset, + required this.sheetContext, + }); + + @override + State<_ColorChoices> createState() => _ColorChoicesState(); +} + +class _ColorChoicesState extends State<_ColorChoices> { + final AsyncActionController _controller = AsyncActionController(); + SubjectColor? _pendingColor; + bool _pendingReset = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _run( + Future Function() action, { + SubjectColor? color, + bool reset = false, + }) async { + if (_controller.busy) return; + setState(() { + _pendingColor = color; + _pendingReset = reset; + }); + final ok = await _controller.run(action); + if (!mounted) return; + setState(() { + _pendingColor = null; + _pendingReset = false; + }); + if (ok && widget.sheetContext.mounted) { + Navigator.of(widget.sheetContext).pop(); + } + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final busy = _controller.busy; + final err = _controller.error; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Wrap( + spacing: 14, + runSpacing: 14, + children: [ + for (final option in SubjectColor.values) + _ColorDot( + display: SubjectColorPalette.displayOf(option), + selected: option == widget.current, + loading: _pendingColor == option, + onTap: busy + ? null + : () => _run( + () => widget.onSelected(option), + color: option, + ), + ), + ], + ), + ), + if (widget.onReset != null) ...[ + const Divider(height: 1), + ListTile( + leading: _pendingReset + ? const SizedBox( + width: 24, + height: 24, + child: AppProgressIndicator.small(), + ) + : const Icon(Icons.format_color_reset_outlined), + title: const Text('Standardfarbe'), + subtitle: const Text('Farbe entfernen'), + enabled: widget.current != null && !busy, + onTap: () => _run(widget.onReset!, reset: true), + ), + ], + if (err != null) + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), + child: Text( + err, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 13, + ), + ), + ), + ], + ); + }, + ); + } +} + +/// Kompakter Header-Button (Palettensymbol mit rundem Farb-Badge) für die +/// Detail-Sheets. Öffnet den gemeinsamen [showTimetableColorPicker]; das Badge +/// zeigt die aktuelle Farbe (bzw. nichts) und wird erst nach erfolgreichem +/// Speichern aktualisiert. [onSelected]/[onReset] persistieren und werfen bei +/// Fehler (die Meldung erscheint dann inline im Picker-Sheet). +class TimetableColorHeaderButton extends StatefulWidget { + final String? initialColorName; + final String pickerTitle; + final String? pickerSubtitle; + final Future Function(SubjectColor option) onSelected; + final Future Function()? onReset; + + const TimetableColorHeaderButton({ + required this.initialColorName, + required this.pickerTitle, + required this.onSelected, + this.pickerSubtitle, + this.onReset, + super.key, + }); + + @override + State createState() => + _TimetableColorHeaderButtonState(); +} + +class _TimetableColorHeaderButtonState + extends State { + late SubjectColor? _current = SubjectColorPalette.fromName( + widget.initialColorName, + ); + + Future _pick() { + return showTimetableColorPicker( + context, + title: widget.pickerTitle, + subtitle: widget.pickerSubtitle, + current: _current, + onSelected: (option) async { + await widget.onSelected(option); + if (mounted) setState(() => _current = option); + }, + onReset: widget.onReset == null + ? null + : () async { + await widget.onReset!(); + if (mounted) setState(() => _current = null); + }, + ); + } + + @override + Widget build(BuildContext context) { + final color = _current == null + ? null + : SubjectColorPalette.displayOf(_current!).color; + return IconButton( + tooltip: 'Farbe', + onPressed: _pick, + icon: Stack( + clipBehavior: Clip.none, + children: [ + const Icon(Icons.palette_outlined), + if (color != null) + // Größe und Position wie ein Label-Badge (Material largeSize), + // nur leer und rund. + Positioned( + top: -6, + right: -6, + child: Container( + width: 16, + height: 16, + decoration: BoxDecoration(shape: BoxShape.circle, color: color), + ), + ), + ], + ), + ); + } +} + +class _ColorDot extends StatelessWidget { + final ColorModeDisplay display; + final bool selected; + final bool loading; + final VoidCallback? onTap; + + const _ColorDot({ + required this.display, + required this.selected, + required this.loading, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: display.displayName, + child: InkWell( + onTap: onTap, + customBorder: const CircleBorder(), + child: Container( + width: 48, + height: 48, + alignment: Alignment.center, + decoration: BoxDecoration( + color: display.color, + shape: BoxShape.circle, + border: selected + ? Border.all( + color: Theme.of(context).colorScheme.onSurface, + width: 3, + ) + : null, + ), + child: loading + ? const AppProgressIndicator.small(color: Colors.white) + : (selected + ? const Icon(Icons.check, color: Colors.white) + : null), + ), + ), + ); + } +} diff --git a/lib/view/pages/timetable/subject_colors/subject_color_tile.dart b/lib/view/pages/timetable/subject_colors/subject_color_tile.dart new file mode 100644 index 0000000..755bc17 --- /dev/null +++ b/lib/view/pages/timetable/subject_colors/subject_color_tile.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import '../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; +import '../../../../widget/centered_leading.dart'; +import '../data/subject_color_palette.dart'; +import 'subject_color_filter.dart'; +import 'subject_color_picker.dart'; + +/// Listeneintrag eines Fachs mit Farb-Vorschau; Tap öffnet den Farbpicker. +/// Von der Verwaltungsseite und der Suche gemeinsam genutzt. +class SubjectColorTile extends StatelessWidget { + final McSubject subject; + + const SubjectColorTile(this.subject, {super.key}); + + @override + Widget build(BuildContext context) { + final color = SubjectColorPalette.colorFromName(subject.color); + final label = subjectDisplayLabel(subject); + return ListTile( + leading: CenteredLeading( + color == null + ? Icon( + Icons.circle_outlined, + color: Theme.of(context).colorScheme.outline, + ) + : Icon(Icons.circle, color: color), + ), + title: Text(label), + subtitle: Text(subject.shortName), + trailing: const Icon(Icons.chevron_right), + onTap: () => showSubjectColorPicker( + context, + subjectShort: subject.shortName, + subjectLabel: label, + currentColorName: subject.color, + ), + ); + } +} diff --git a/lib/view/pages/timetable/subject_colors/subject_colors_view.dart b/lib/view/pages/timetable/subject_colors/subject_colors_view.dart new file mode 100644 index 0000000..603381a --- /dev/null +++ b/lib/view/pages/timetable/subject_colors/subject_colors_view.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; +import '../../../../widget/placeholder_view.dart'; +import 'search_subject_colors.dart'; +import 'subject_color_filter.dart'; +import 'subject_color_tile.dart'; + +/// Zentrale Verwaltungsseite: listet die Fächer aus dem eigenen Stundenplan und +/// lässt pro Fach eine Farbe zuweisen oder zurücksetzen. Bereits gefärbte +/// Fächer stehen oben; die Suche läuft über die AppBar. +class SubjectColorsView extends StatelessWidget { + const SubjectColorsView({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('Fach-Farben'), + actions: [ + IconButton( + icon: const Icon(Icons.search), + onPressed: () => + showSearch(context: context, delegate: SearchSubjectColors()), + ), + ], + ), + body: LoadableStateConsumer( + child: (state, _) { + final subjects = filterAndSortSubjects( + state.subjects?.result ?? const [], + planSubjectShortNames(state), + ); + if (subjects.isEmpty) { + return const PlaceholderView( + icon: Icons.palette_outlined, + text: 'Keine Fächer in deinem Stundenplan', + ); + } + return ListView.builder( + itemCount: subjects.length, + itemBuilder: (_, index) => SubjectColorTile(subjects[index]), + ); + }, + ), + ); +} diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index d3da3c7..226f2cf 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -152,8 +152,12 @@ class _TimetableState extends State { key: _calendarKey, state: state, onWeekChanged: bloc.changeWeek, - onAppointmentTap: (apt) => - AppointmentDetailsDispatcher.show(context, state, apt), + onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show( + context, + state, + apt, + canEditSubjectColor: true, + ), onCreateEvent: _onCreateEventAt, customEvents: state.customEvents?.events ?? const [], ), diff --git a/test/view/timetable/subject_color_filter_test.dart b/test/view/timetable/subject_color_filter_test.dart new file mode 100644 index 0000000..4f9cea3 --- /dev/null +++ b/test/view/timetable/subject_color_filter_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; +import 'package:marianum_mobile/view/pages/timetable/subject_colors/subject_color_filter.dart'; + +McSubject _sub(String short, String long, {String? color}) => + McSubject(id: short.hashCode, shortName: short, longName: long, color: color); + +void main() { + group('filterAndSortSubjects', () { + test('keeps only subjects in the plan or already coloured', () { + final all = [ + _sub('M', 'Mathematik'), // im Plan + _sub('D', 'Deutsch'), // nicht im Plan, keine Farbe -> raus + _sub('SP', 'Sport', color: 'teal'), // nicht im Plan, aber gefärbt + ]; + final result = filterAndSortSubjects(all, {'M'}); + expect(result.map((s) => s.shortName), containsAll(['M', 'SP'])); + expect(result.map((s) => s.shortName), isNot(contains('D'))); + }); + + test('sorts coloured subjects first, then alphabetically by label', () { + final all = [ + _sub('E', 'Englisch'), + _sub('M', 'Mathematik', color: 'amber'), + _sub('BIO', 'Biologie'), + _sub('D', 'Deutsch', color: 'indigo'), + ]; + final result = filterAndSortSubjects(all, {'E', 'M', 'BIO', 'D'}); + // Gefärbte zuerst (Deutsch, Mathematik alphabetisch), dann der Rest. + expect( + result.map((s) => s.shortName).toList(), + ['D', 'M', 'BIO', 'E'], + ); + }); + + test('drops subjects without a short name', () { + final all = [_sub('', 'Leer'), _sub('M', 'Mathematik')]; + final result = filterAndSortSubjects(all, {'M', ''}); + expect(result.map((s) => s.shortName), ['M']); + }); + }); + + group('searchSubjects', () { + final subjects = [ + _sub('M', 'Mathematik'), + _sub('D', 'Deutsch'), + _sub('SP', 'Sport'), + ]; + + test('empty query returns everything unchanged', () { + expect(searchSubjects(subjects, ''), subjects); + expect(searchSubjects(subjects, ' '), subjects); + }); + + test('matches short name or long name, case-insensitively', () { + expect(searchSubjects(subjects, 'mathe').single.shortName, 'M'); + expect(searchSubjects(subjects, 'sp').single.shortName, 'SP'); + expect(searchSubjects(subjects, 'DEUTSCH').single.shortName, 'D'); + }); + + test('no match returns empty', () { + expect(searchSubjects(subjects, 'chemie'), isEmpty); + }); + }); +} diff --git a/test/view/timetable/subject_color_palette_test.dart b/test/view/timetable/subject_color_palette_test.dart new file mode 100644 index 0000000..7e71290 --- /dev/null +++ b/test/view/timetable/subject_color_palette_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/view/pages/timetable/data/subject_color_palette.dart'; + +void main() { + group('SubjectColorPalette.colorFromName', () { + test('resolves every palette name to a concrete colour', () { + for (final option in SubjectColor.values) { + expect( + SubjectColorPalette.colorFromName(option.name), + SubjectColorPalette.displayOf(option).color, + reason: '${option.name} should round-trip to its display colour', + ); + } + }); + + test('returns null for null, empty or unknown names', () { + expect(SubjectColorPalette.colorFromName(null), isNull); + expect(SubjectColorPalette.colorFromName(''), isNull); + expect(SubjectColorPalette.colorFromName('not-a-colour'), isNull); + }); + }); + + group('SubjectColorPalette.fromName legacy aliases', () { + test('maps the old 4-colour custom-event names to palette colours', () { + // Bestehende eigene Termine wurden mit diesen Namen gespeichert und + // dürfen nicht ihre Farbe verlieren. + expect(SubjectColorPalette.fromName('orange'), SubjectColor.amber); + expect(SubjectColorPalette.fromName('red'), SubjectColor.deepOrange); + expect(SubjectColorPalette.fromName('green'), SubjectColor.teal); + expect(SubjectColorPalette.fromName('blue'), SubjectColor.indigo); + }); + + test('new palette names take precedence and resolve directly', () { + expect(SubjectColorPalette.fromName('teal'), SubjectColor.teal); + expect(SubjectColorPalette.fromName('amber'), SubjectColor.amber); + }); + }); +}