implemented custom subject colors for the timetable and unified the color palette system

This commit is contained in:
2026-07-12 21:30:08 +02:00
parent a7111844b1
commit fe2b3c43b2
23 changed files with 916 additions and 89 deletions
@@ -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<String, dynamic> json) =>
@@ -10,12 +10,14 @@ McSubject _$McSubjectFromJson(Map<String, dynamic> json) => McSubject(
id: (json['id'] as num).toInt(),
shortName: json['shortName'] as String,
longName: json['longName'] as String,
color: json['color'] as String?,
);
Map<String, dynamic> _$McSubjectToJson(McSubject instance) => <String, dynamic>{
'id': instance.id,
'shortName': instance.shortName,
'longName': instance.longName,
'color': instance.color,
};
TimetableGetSubjectsResponse _$TimetableGetSubjectsResponseFromJson(
@@ -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<void> run(String subjectShort) async {
try {
await _dio.delete<void>(
MarianumConnectEndpoint.resolve('timetable/subject-colors'),
queryParameters: {'subject': subjectShort},
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -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<void> run(String subjectShort, String color) async {
try {
await _dio.put<void>(
MarianumConnectEndpoint.resolve('timetable/subject-colors'),
data: {'subject': subjectShort, 'color': color},
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
+5
View File
@@ -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`
@@ -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<void> setSubjectColor(String subjectShort, String color) async {
await repo.data.setSubjectColor(subjectShort, color);
await _refreshSubjects();
}
Future<void> clearSubjectColor(String subjectShort) async {
await repo.data.removeSubjectColor(subjectShort);
await _refreshSubjects();
}
Future<void> _refreshSubjects() async {
final subjects = await repo.data.getSubjects(renew: true);
add(
DataGathered(
(s) => s.copyWith(subjects: subjects, dataVersion: s.dataVersion + 1),
),
);
}
Future<void> _loadCurrentWeek(
DateTime startDate,
DateTime endDate, {
@@ -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<void> setSubjectColor(String subjectShort, String color) {
if (DemoMode.active) return Future.value();
return TimetableSubjectColorSet().run(subjectShort, color);
}
Future<void> removeSubjectColor(String subjectShort) {
if (DemoMode.active) return Future.value();
return TimetableSubjectColorRemove().run(subjectShort);
}
}
@@ -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),
),
],
);
}
@@ -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});
}
@@ -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<CustomEventEditDialog> {
// 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<CustomEventEditDialog> {
ListTile(
leading: const Icon(Icons.color_lens_outlined),
title: const Text('Farbgebung'),
trailing: DropdownButton<CustomTimetableColors>(
value: _color,
icon: const Icon(Icons.arrow_drop_down),
items: CustomTimetableColors.values
.map(
(e) => DropdownMenuItem<CustomTimetableColors>(
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(),
@@ -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<String, SubjectColor> _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});
}
@@ -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.
@@ -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),
);
}
@@ -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<TimetableBloc>();
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(),
);
}
@@ -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<TimetableBloc>();
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: (_) => <Widget>[
ListTile(
@@ -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<void> {
@override
List<Widget>? 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<TimetableBloc, TimetableState>(
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]),
);
},
);
}
}
@@ -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<String> 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<McSubject> filterAndSortSubjects(
List<McSubject> all,
Set<String> 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<McSubject> searchSubjects(List<McSubject> 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();
}
@@ -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<void> 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<void> showTimetableColorPicker(
BuildContext context, {
required String title,
String? subtitle,
SubjectColor? current,
required ColorPickCallback onSelected,
Future<void> 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<void> showSubjectColorPicker(
BuildContext context, {
required String subjectShort,
required String subjectLabel,
String? currentColorName,
}) {
final bloc = context.read<TimetableBloc>();
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<void> 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<void> _run(
Future<void> 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<void> Function(SubjectColor option) onSelected;
final Future<void> Function()? onReset;
const TimetableColorHeaderButton({
required this.initialColorName,
required this.pickerTitle,
required this.onSelected,
this.pickerSubtitle,
this.onReset,
super.key,
});
@override
State<TimetableColorHeaderButton> createState() =>
_TimetableColorHeaderButtonState();
}
class _TimetableColorHeaderButtonState
extends State<TimetableColorHeaderButton> {
late SubjectColor? _current = SubjectColorPalette.fromName(
widget.initialColorName,
);
Future<void> _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),
),
),
);
}
}
@@ -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,
),
);
}
}
@@ -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<TimetableBloc, TimetableState>(
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]),
);
},
),
);
}
+6 -2
View File
@@ -152,8 +152,12 @@ class _TimetableState extends State<Timetable> {
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 [],
),
@@ -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);
});
});
}
@@ -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);
});
});
}