implemented custom subject colors for the timetable and unified the color palette system
This commit is contained in:
+5
@@ -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) =>
|
||||
|
||||
+2
@@ -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(
|
||||
|
||||
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user