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 shortName;
|
||||||
final String longName;
|
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({
|
McSubject({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.shortName,
|
required this.shortName,
|
||||||
required this.longName,
|
required this.longName,
|
||||||
|
this.color,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory McSubject.fromJson(Map<String, dynamic> json) =>
|
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(),
|
id: (json['id'] as num).toInt(),
|
||||||
shortName: json['shortName'] as String,
|
shortName: json['shortName'] as String,
|
||||||
longName: json['longName'] as String,
|
longName: json['longName'] as String,
|
||||||
|
color: json['color'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$McSubjectToJson(McSubject instance) => <String, dynamic>{
|
Map<String, dynamic> _$McSubjectToJson(McSubject instance) => <String, dynamic>{
|
||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
'shortName': instance.shortName,
|
'shortName': instance.shortName,
|
||||||
'longName': instance.longName,
|
'longName': instance.longName,
|
||||||
|
'color': instance.color,
|
||||||
};
|
};
|
||||||
|
|
||||||
TimetableGetSubjectsResponse _$TimetableGetSubjectsResponseFromJson(
|
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/talk/talk_navigator.dart';
|
||||||
import '../view/pages/ticker/ticker_page_view.dart';
|
import '../view/pages/ticker/ticker_page_view.dart';
|
||||||
import '../view/pages/timetable/custom_events/custom_events_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/debug/cache_view.dart';
|
||||||
import '../widget/file_viewer.dart';
|
import '../widget/file_viewer.dart';
|
||||||
import '../widget/user_avatar.dart';
|
import '../widget/user_avatar.dart';
|
||||||
@@ -90,6 +91,10 @@ class AppRoutes {
|
|||||||
pushScreen(context, withNavBar: false, screen: const CustomEventsView());
|
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
|
/// Opens the picker for choosing a foreign timetable element and resolves to
|
||||||
/// the selected element (or null if dismissed). The timetable view renders
|
/// the selected element (or null if dismissed). The timetable view renders
|
||||||
/// the chosen plan inline. Gated behind the `viewForeignTimetables`
|
/// the chosen plan inline. Gated behind the `viewForeignTimetables`
|
||||||
|
|||||||
@@ -121,6 +121,27 @@ class TimetableBloc
|
|||||||
await _refreshCustomEvents();
|
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(
|
Future<void> _loadCurrentWeek(
|
||||||
DateTime startDate,
|
DateTime startDate,
|
||||||
DateTime endDate, {
|
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_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.dart';
|
||||||
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.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/custom_timetable_event.dart';
|
||||||
import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
|
import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
|
||||||
import '../../../../../api/request_cache.dart';
|
import '../../../../../api/request_cache.dart';
|
||||||
@@ -119,4 +121,14 @@ class TimetableDataProvider {
|
|||||||
if (DemoMode.active) return Future.value();
|
if (DemoMode.active) return Future.value();
|
||||||
return TimetableCustomEventsRemove().run(id);
|
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/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
import '../../../../routing/app_routes.dart';
|
||||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../../utils/haptics.dart';
|
import '../../../../utils/haptics.dart';
|
||||||
import '../../../../view/pages/timetable/data/timetable_name_mode.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/async_action_button.dart';
|
||||||
import '../../../../widget/demo_restricted.dart';
|
import '../../../../widget/demo_restricted.dart';
|
||||||
import '../../../../widget/focus_behaviour.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 {
|
class CustomEventEditDialog extends StatefulWidget {
|
||||||
final CustomTimetableEvent? existingEvent;
|
final CustomTimetableEvent? existingEvent;
|
||||||
@@ -61,10 +62,9 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
|||||||
// mhsl-Termine tragen ausgeschaltet (Modell-Default), da sie nie ein Serien-
|
// mhsl-Termine tragen ausgeschaltet (Modell-Default), da sie nie ein Serien-
|
||||||
// Ferien-Flag hatten.
|
// Ferien-Flag hatten.
|
||||||
late bool _skipHolidays = widget.existingEvent?.skipHolidays ?? true;
|
late bool _skipHolidays = widget.existingEvent?.skipHolidays ?? true;
|
||||||
late CustomTimetableColors _color = CustomTimetableColors.values.firstWhere(
|
late SubjectColor _color =
|
||||||
(e) => e.name == widget.existingEvent?.color,
|
SubjectColorPalette.fromName(widget.existingEvent?.color) ??
|
||||||
orElse: () => TimetableColors.defaultColor,
|
SubjectColorPalette.defaultColor;
|
||||||
);
|
|
||||||
|
|
||||||
bool get _isEditing => widget.existingEvent != null;
|
bool get _isEditing => widget.existingEvent != null;
|
||||||
|
|
||||||
@@ -261,30 +261,18 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.color_lens_outlined),
|
leading: const Icon(Icons.color_lens_outlined),
|
||||||
title: const Text('Farbgebung'),
|
title: const Text('Farbgebung'),
|
||||||
trailing: DropdownButton<CustomTimetableColors>(
|
subtitle: Text(SubjectColorPalette.displayOf(_color).displayName),
|
||||||
value: _color,
|
trailing: Icon(
|
||||||
icon: const Icon(Icons.arrow_drop_down),
|
Icons.circle,
|
||||||
items: CustomTimetableColors.values
|
color: SubjectColorPalette.displayOf(_color).color,
|
||||||
.map(
|
),
|
||||||
(e) => DropdownMenuItem<CustomTimetableColors>(
|
onTap: () => showTimetableColorPicker(
|
||||||
value: e,
|
context,
|
||||||
enabled: e != _color,
|
title: 'Farbe wählen',
|
||||||
child: Row(
|
current: _color,
|
||||||
children: [
|
// Rein lokal (kein Netzwerk) — Sheet schließt sofort, gespeichert
|
||||||
Icon(
|
// wird erst beim „Speichern" des Termins.
|
||||||
Icons.circle,
|
onSelected: (option) async => setState(() => _color = option),
|
||||||
color: TimetableColors.getDisplayOptions(e).color,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(
|
|
||||||
TimetableColors.getDisplayOptions(e).displayName,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
onChanged: (e) => setState(() => _color = e!),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
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 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||||
|
|
||||||
import '../../../../api/marianumconnect/models/mc_holiday.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/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||||
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
||||||
import '../../../../storage/timetable_settings.dart';
|
import '../../../../storage/timetable_settings.dart';
|
||||||
import '../custom_events/custom_event_colors.dart';
|
|
||||||
import 'arbitrary_appointment.dart';
|
import 'arbitrary_appointment.dart';
|
||||||
import 'lesson_color.dart';
|
import 'lesson_color.dart';
|
||||||
import 'lesson_status.dart';
|
import 'lesson_status.dart';
|
||||||
import 'lesson_type_label.dart';
|
import 'lesson_type_label.dart';
|
||||||
import 'rrule_with_exceptions.dart';
|
import 'rrule_with_exceptions.dart';
|
||||||
|
import 'subject_color_palette.dart';
|
||||||
import 'timetable_name_mode.dart';
|
import 'timetable_name_mode.dart';
|
||||||
|
|
||||||
class TimetableAppointmentFactory {
|
class TimetableAppointmentFactory {
|
||||||
@@ -65,7 +66,7 @@ class TimetableAppointmentFactory {
|
|||||||
endTime: endTime,
|
endTime: endTime,
|
||||||
subject: _subjectName(subjectShortName, lesson),
|
subject: _subjectName(subjectShortName, lesson),
|
||||||
location: _locationLabel(lesson),
|
location: _locationLabel(lesson),
|
||||||
color: LessonColor.forStatus(status),
|
color: _lessonColor(status, subjectShortName),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return Appointment(
|
return Appointment(
|
||||||
@@ -131,9 +132,9 @@ class TimetableAppointmentFactory {
|
|||||||
subject: _collapseWhitespace(event.title) ?? event.title,
|
subject: _collapseWhitespace(event.title) ?? event.title,
|
||||||
recurrenceRule: parsed.rule,
|
recurrenceRule: parsed.rule,
|
||||||
recurrenceExceptionDates: exceptionDates.isEmpty ? null : exceptionDates,
|
recurrenceExceptionDates: exceptionDates.isEmpty ? null : exceptionDates,
|
||||||
color: TimetableColors.getColorFromString(
|
color:
|
||||||
event.color ?? TimetableColors.defaultColor.name,
|
SubjectColorPalette.colorFromName(event.color) ??
|
||||||
),
|
SubjectColorPalette.displayOf(SubjectColorPalette.defaultColor).color,
|
||||||
startTimeZone: '',
|
startTimeZone: '',
|
||||||
endTimeZone: '',
|
endTimeZone: '',
|
||||||
);
|
);
|
||||||
@@ -187,10 +188,32 @@ class TimetableAppointmentFactory {
|
|||||||
e.second == 0;
|
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) {
|
String _subjectName(String? subjectShort, McTimetableEntry lesson) {
|
||||||
if (subjectShort != null) {
|
if (subjectShort != null) {
|
||||||
final lookup =
|
final lookup = _findSubject(subjectShort);
|
||||||
subjects.where((s) => s.shortName == subjectShort).firstOrNull;
|
|
||||||
final name = switch (settings.timetableNameMode) {
|
final name = switch (settings.timetableNameMode) {
|
||||||
// Backend liefert nur shortName + longName; alternateName fällt auf
|
// Backend liefert nur shortName + longName; alternateName fällt auf
|
||||||
// longName zurück.
|
// longName zurück.
|
||||||
|
|||||||
@@ -10,13 +10,20 @@ class AppointmentDetailsDispatcher {
|
|||||||
static void show(
|
static void show(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
TimetableState? state,
|
TimetableState? state,
|
||||||
Appointment appointment,
|
Appointment appointment, {
|
||||||
) {
|
bool canEditSubjectColor = false,
|
||||||
|
}) {
|
||||||
final id = appointment.id;
|
final id = appointment.id;
|
||||||
if (id is! ArbitraryAppointment) return;
|
if (id is! ArbitraryAppointment) return;
|
||||||
|
|
||||||
id.when(
|
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),
|
custom: (event) => CustomEventSheet.show(context, event),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:rrule/rrule.dart';
|
import 'package:rrule/rrule.dart';
|
||||||
|
|
||||||
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
||||||
import '../../../../extensions/date_time.dart';
|
import '../../../../extensions/date_time.dart';
|
||||||
|
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||||
import '../../../../widget/centered_leading.dart';
|
import '../../../../widget/centered_leading.dart';
|
||||||
import '../../../../widget/debug/debug_tile.dart';
|
import '../../../../widget/debug/debug_tile.dart';
|
||||||
import '../../../../widget/details_bottom_sheet.dart';
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
import '../custom_events/custom_event_edit_dialog.dart';
|
import '../custom_events/custom_event_edit_dialog.dart';
|
||||||
import '../data/rrule_with_exceptions.dart';
|
import '../data/rrule_with_exceptions.dart';
|
||||||
|
import '../subject_colors/subject_color_picker.dart';
|
||||||
import 'delete_custom_event.dart';
|
import 'delete_custom_event.dart';
|
||||||
|
|
||||||
class CustomEventSheet {
|
class CustomEventSheet {
|
||||||
static void show(BuildContext context, CustomTimetableEvent event) {
|
static void show(BuildContext context, CustomTimetableEvent event) {
|
||||||
final timeRange = event.startDate.timeRangeTo(event.endDate);
|
final timeRange = event.startDate.timeRangeTo(event.endDate);
|
||||||
|
final bloc = context.read<TimetableBloc>();
|
||||||
|
|
||||||
showDetailsBottomSheet(
|
showDetailsBottomSheet(
|
||||||
context,
|
context,
|
||||||
@@ -23,6 +27,12 @@ class CustomEventSheet {
|
|||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
subtitle: Text(timeRange),
|
subtitle: Text(timeRange),
|
||||||
|
trailing: TimetableColorHeaderButton(
|
||||||
|
initialColorName: event.color,
|
||||||
|
pickerTitle: 'Farbe wählen',
|
||||||
|
onSelected: (option) =>
|
||||||
|
bloc.updateCustomEvent(event.id, _withColor(event, option.name)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
children: (sheetCtx) => [
|
children: (sheetCtx) => [
|
||||||
Padding(
|
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/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||||
|
|
||||||
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||||
import '../../../../extensions/date_time.dart';
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../../extensions/text.dart';
|
import '../../../../extensions/text.dart';
|
||||||
import '../../../../routing/app_routes.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 '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||||
import '../../../../widget/debug/debug_tile.dart';
|
import '../../../../widget/debug/debug_tile.dart';
|
||||||
import '../../../../widget/details_bottom_sheet.dart';
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
import '../data/lesson_type_label.dart';
|
import '../data/lesson_type_label.dart';
|
||||||
|
import '../subject_colors/subject_color_picker.dart';
|
||||||
|
|
||||||
class LessonSheet {
|
class LessonSheet {
|
||||||
static void show(
|
static void show(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
TimetableState? state,
|
TimetableState? state,
|
||||||
Appointment appointment,
|
Appointment appointment,
|
||||||
McTimetableEntry lesson,
|
McTimetableEntry lesson, {
|
||||||
) {
|
bool canEditSubjectColor = false,
|
||||||
|
}) {
|
||||||
if (state == null) return;
|
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 subjectShort = lesson.subjects.firstOrNull;
|
||||||
final headerLong = subjectShort == null
|
final subjectEntry = subjectShort == null
|
||||||
? null
|
? null
|
||||||
: state.subjects?.result
|
: state.subjects?.result
|
||||||
.where((s) => s.shortName == subjectShort)
|
.where((s) => s.shortName == subjectShort)
|
||||||
.firstOrNull
|
.firstOrNull;
|
||||||
?.longName;
|
final headerLong = subjectEntry?.longName;
|
||||||
// Bei Stunden ohne Fach (Pausenaufsicht etc.) den Lesson-Type-Titel
|
// Bei Stunden ohne Fach (Pausenaufsicht etc.) den Lesson-Type-Titel
|
||||||
// einsetzen — sonst stünde im Header nur ein generisches "?".
|
// einsetzen — sonst stünde im Header nur ein generisches "?".
|
||||||
final headerTitle = subjectShort != null
|
final headerTitle = subjectShort != null
|
||||||
@@ -50,6 +58,18 @@ class LessonSheet {
|
|||||||
headerLongName.isNotEmpty ? '$timeRange\n$headerLongName' : timeRange,
|
headerLongName.isNotEmpty ? '$timeRange\n$headerLongName' : timeRange,
|
||||||
),
|
),
|
||||||
isThreeLine: headerLongName.isNotEmpty,
|
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>[
|
children: (_) => <Widget>[
|
||||||
ListTile(
|
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,
|
key: _calendarKey,
|
||||||
state: state,
|
state: state,
|
||||||
onWeekChanged: bloc.changeWeek,
|
onWeekChanged: bloc.changeWeek,
|
||||||
onAppointmentTap: (apt) =>
|
onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show(
|
||||||
AppointmentDetailsDispatcher.show(context, state, apt),
|
context,
|
||||||
|
state,
|
||||||
|
apt,
|
||||||
|
canEditSubjectColor: true,
|
||||||
|
),
|
||||||
onCreateEvent: _onCreateEventAt,
|
onCreateEvent: _onCreateEventAt,
|
||||||
customEvents: state.customEvents?.events ?? const [],
|
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