claude refactor

This commit is contained in:
2026-05-04 13:54:39 +02:00
parent 9973f12733
commit 551c1bf1fa
125 changed files with 4484 additions and 2544 deletions
@@ -1,92 +0,0 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import 'CrossPainter.dart';
class AppointmentComponent extends StatefulWidget {
final CalendarAppointmentDetails details;
final bool crossedOut;
const AppointmentComponent({super.key, required this.details, this.crossedOut = false});
@override
State<AppointmentComponent> createState() => _AppointmentComponentState();
}
class _AppointmentComponentState extends State<AppointmentComponent> {
@override
Widget build(BuildContext context) {
final Appointment meeting = widget.details.appointments.first;
final appointmentHeight = widget.details.bounds.height;
return Stack(
children: [
Column(
children: [
Container(
padding: const EdgeInsets.all(3),
height: appointmentHeight,
alignment: Alignment.topLeft,
decoration: BoxDecoration(
shape: BoxShape.rectangle,
borderRadius: const BorderRadius.all(Radius.circular(5)),
color: meeting.color.withAlpha(meeting.endTime.isBefore(DateTime.now()) ? 100 : 255),
),
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FittedBox(
fit: BoxFit.fitWidth,
child: Text(
meeting.subject,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w500,
),
maxLines: 1,
softWrap: false,
),
),
FittedBox(
fit: BoxFit.fitWidth,
child: Text(
(meeting.location == null || meeting.location!.isEmpty ? ' ' : meeting.location!),
maxLines: 3,
overflow: TextOverflow.ellipsis,
softWrap: true,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
)
],
),
),
),
],
),
Visibility(
visible: widget.crossedOut,
child: Positioned.fill(
child: Container(
decoration: BoxDecoration(
border: Border.all(
width: 2,
color: Colors.red.withAlpha(200),
),
borderRadius: const BorderRadius.all(Radius.circular(5)),
),
child: CustomPaint(
painter: CrossPainter(),
),
)
),
),
],
);
}
}
@@ -1,226 +0,0 @@
import 'dart:async';
import 'package:bottom_sheet/bottom_sheet.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import 'package:provider/provider.dart';
import 'package:rrule/rrule.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart';
import '../../../api/mhsl/customTimetableEvent/remove/removeCustomTimetableEvent.dart';
import '../../../api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.dart';
import '../../../api/webuntis/queries/getRooms/getRoomsResponse.dart';
import '../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart';
import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
import '../../../model/timetable/timetableProps.dart';
import '../../../widget/centeredLeading.dart';
import '../../../widget/confirmDialog.dart';
import '../../../widget/debug/debugTile.dart';
import '../../../widget/unimplementedDialog.dart';
import '../more/roomplan/roomplan.dart';
import 'arbitraryAppointment.dart';
import 'customTimetableEventEditDialog.dart';
class AppointmentDetails {
static String _getEventPrefix(String? code) {
if(code == 'cancelled') return 'Entfällt: ';
if(code == 'irregular') return 'Änderung: ';
return code ?? '';
}
static void show(BuildContext context, TimetableProps webuntisData, Appointment appointment) {
(appointment.id! as ArbitraryAppointment).handlers(
(webuntis) => _webuntis(context, webuntisData, appointment, webuntis),
(customData) => _custom(context, webuntisData, customData)
);
}
static void _bottomSheet(
BuildContext context,
Widget Function(BuildContext context) header,
SliverChildListDelegate Function(BuildContext context) body
) {
showStickyFlexibleBottomSheet(
minHeight: 0,
initHeight: 0.4,
maxHeight: 0.7,
anchors: [0, 0.4, 0.7],
isSafeArea: true,
maxHeaderHeight: 100,
context: context,
headerBuilder: (context, bottomSheetOffset) => header(context),
bodyBuilder: (context, bottomSheetOffset) => body(context)
);
}
static void _webuntis(BuildContext context, TimetableProps webuntisData, Appointment appointment, GetTimetableResponseObject timetableData) {
GetSubjectsResponseObject subject;
GetRoomsResponseObject room;
try {
subject = webuntisData.getSubjectsResponse.result.firstWhere((subject) => subject.id == timetableData.su[0].id);
} catch(e) {
subject = GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true);
}
try {
room = webuntisData.getRoomsResponse.result.firstWhere((room) => room.id == timetableData.ro[0].id);
} catch(e) {
room = GetRoomsResponseObject(0, '?', 'Unbekannt', true, '?');
}
_bottomSheet(
context,
(context) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('${_getEventPrefix(timetableData.code)}${subject.alternateName}', textAlign: TextAlign.center, style: const TextStyle(fontSize: 25), overflow: TextOverflow.ellipsis),
Text(subject.longName),
Text("${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: "HH:mm")} - ${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: "HH:mm")}", style: const TextStyle(fontSize: 15)),
],
),
),
(context) => SliverChildListDelegate(
[
const Divider(),
ListTile(
leading: const Icon(Icons.notifications_active),
title: Text("Status: ${timetableData.code != null ? "Geändert" : "Regulär"}"),
),
ListTile(
leading: const Icon(Icons.room),
title: Text('Raum: ${room.name} (${room.longName})'),
trailing: IconButton(
icon: const Icon(Icons.house_outlined),
onPressed: () {
pushScreen(context, withNavBar: false, screen: const Roomplan());
},
),
),
ListTile(
leading: const Icon(Icons.person),
title: timetableData.te.isNotEmpty
? Text("Lehrkraft: ${timetableData.te[0].name} ${timetableData.te[0].longname.isNotEmpty ? "(${timetableData.te[0].longname})" : ""}")
: const Text('?'),
trailing: Visibility(
visible: !kReleaseMode,
child: IconButton(
icon: const Icon(Icons.textsms_outlined),
onPressed: () {
UnimplementedDialog.show(context);
},
),
),
),
ListTile(
leading: const Icon(Icons.abc),
title: Text('Typ: ${timetableData.activityType}'),
),
ListTile(
leading: const Icon(Icons.people),
title: Text("Klasse(n): ${timetableData.kl.map((e) => e.name).join(", ")}"),
),
DebugTile(context).jsonData(timetableData.toJson()),
],
)
);
}
static Completer deleteCustomEvent(BuildContext context, CustomTimetableEvent appointment) {
var future = Completer();
ConfirmDialog(
title: 'Termin löschen',
content: "Der ${appointment.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.",
confirmButton: 'Löschen',
onConfirm: () {
RemoveCustomTimetableEvent(
RemoveCustomTimetableEventParams(
appointment.id
)
).run().then((value) {
Provider.of<TimetableProps>(context, listen: false).run(renew: true);
future.complete();
});
},
).asDialog(context);
return future;
}
static void _custom(BuildContext context, TimetableProps webuntisData, CustomTimetableEvent appointment) {
_bottomSheet(
context,
(context) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(appointment.title, style: const TextStyle(fontSize: 25, overflow: TextOverflow.ellipsis)),
Text("${Jiffy.parseFromDateTime(appointment.startDate).format(pattern: "HH:mm")} - ${Jiffy.parseFromDateTime(appointment.endDate).format(pattern: "HH:mm")}", style: const TextStyle(fontSize: 15)),
],
),
),
(context) => SliverChildListDelegate(
[
const Divider(),
Center(
child: Wrap(
children: [
TextButton.icon(
onPressed: () {
Navigator.of(context).pop();
showDialog(
context: context,
builder: (context) => CustomTimetableEventEditDialog(existingEvent: appointment),
);
},
label: const Text('Bearbeiten'),
icon: const Icon(Icons.edit_outlined),
),
TextButton.icon(
onPressed: () {
deleteCustomEvent(context, appointment).future.then((value) => Navigator.of(context).pop());
},
label: const Text('Löschen'),
icon: const Icon(Icons.delete_outline),
),
],
),
),
const Divider(),
ListTile(
leading: const Icon(Icons.info_outline),
title: Text(appointment.description.isEmpty ? 'Keine Beschreibung' : appointment.description),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.repeat_outlined)),
title: Text("Serie: ${appointment.rrule.isNotEmpty ? "Wiederholend" : "Einmailg"}"),
subtitle: FutureBuilder(
future: RruleL10nEn.create(),
builder: (context, snapshot) {
if(appointment.rrule.isEmpty) return const Text('Keine weiteren vorkomnisse');
if(snapshot.data == null) return const Text('...');
var rrule = RecurrenceRule.fromString(appointment.rrule);
if(!rrule.canFullyConvertToText) return const Text('Keine genauere Angabe möglich.');
return Text(rrule.toText(l10n: snapshot.data!));
},
)
),
DebugTile(context).child(
ListTile(
leading: const CenteredLeading(Icon(Icons.rule)),
title: const Text('RRule'),
subtitle: Text(appointment.rrule.isEmpty ? 'Keine' : appointment.rrule),
)
),
DebugTile(context).jsonData(appointment.toJson()),
]
)
);
}
}
@@ -1,19 +0,0 @@
import '../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart';
import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
class ArbitraryAppointment {
GetTimetableResponseObject? webuntis;
CustomTimetableEvent? custom;
ArbitraryAppointment({this.webuntis, this.custom});
bool hasWebuntis() => webuntis != null;
bool hasCustom() => custom != null;
void handlers(void Function(GetTimetableResponseObject webuntisData) webuntis, void Function(CustomTimetableEvent customData) custom) {
if(hasWebuntis()) webuntis(this.webuntis!);
if(hasCustom()) custom(this.custom!);
}
}
@@ -1,233 +0,0 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import '../../../extensions/dateTime.dart';
import 'package:provider/provider.dart';
import 'package:rrule_generator/rrule_generator.dart';
import 'package:time_range_picker/time_range_picker.dart';
import '../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart';
import '../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.dart';
import '../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart';
import '../../../api/mhsl/customTimetableEvent/update/updateCustomTimetableEvent.dart';
import '../../../api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.dart';
import '../../../model/accountData.dart';
import '../../../model/timetable/timetableProps.dart';
import '../../../widget/focusBehaviour.dart';
import '../../../widget/infoDialog.dart';
import 'customTimetableColors.dart';
class CustomTimetableEventEditDialog extends StatefulWidget {
final CustomTimetableEvent? existingEvent;
const CustomTimetableEventEditDialog({this.existingEvent, super.key});
@override
State<CustomTimetableEventEditDialog> createState() => _AddCustomTimetableEventDialogState();
}
class _AddCustomTimetableEventDialogState extends State<CustomTimetableEventEditDialog> {
late DateTime _date = widget.existingEvent?.startDate ?? DateTime.now();
late TimeOfDay _startTime = widget.existingEvent?.startDate.toTimeOfDay() ?? const TimeOfDay(hour: 08, minute: 00);
late TimeOfDay _endTime = widget.existingEvent?.endDate.toTimeOfDay() ?? const TimeOfDay(hour: 09, minute: 30);
late final TextEditingController _eventName = TextEditingController(text: widget.existingEvent?.title);
late final TextEditingController _eventDescription = TextEditingController(text: widget.existingEvent?.description);
late String _recurringRule = widget.existingEvent?.rrule ?? '';
late CustomTimetableColors _customTimetableColor = CustomTimetableColors.values.firstWhere(
(element) => element.name == widget.existingEvent?.color,
orElse: () => TimetableColors.defaultColor
);
late bool isEditingExisting = widget.existingEvent != null;
bool validate() {
if(_eventName.text.isEmpty) return false;
return true;
}
void fetchTimetable() {
Provider.of<TimetableProps>(context, listen: false).run(renew: true);
}
@override
Widget build(BuildContext context) => AlertDialog(
insetPadding: const EdgeInsets.all(20),
contentPadding: const EdgeInsets.all(10),
title: const Text('Termin hinzufügen'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ListTile(
title: TextField(
controller: _eventName,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Terminname',
border: OutlineInputBorder()
),
onTapOutside: (PointerDownEvent event) => FocusBehaviour.textFieldTapOutside(context),
),
),
ListTile(
title: TextField(
controller: _eventDescription,
maxLines: 2,
minLines: 2,
decoration: const InputDecoration(
labelText: 'Beschreibung',
border: OutlineInputBorder()
),
onTapOutside: (PointerDownEvent event) => FocusBehaviour.textFieldTapOutside(context),
),
),
const Divider(),
ListTile(
leading: const Icon(Icons.date_range_outlined),
title: Text(Jiffy.parseFromDateTime(_date).yMMMd),
subtitle: const Text('Datum'),
onTap: () async {
final pickedDate = await showDatePicker(
context: context,
initialDate: _date,
firstDate: DateTime.now().subtract(const Duration(days: 30)),
lastDate: DateTime.now().add(const Duration(days: 30)),
);
if (pickedDate != null && pickedDate != _date) {
setState(() {
_date = pickedDate;
});
}
},
),
ListTile(
leading: const Icon(Icons.access_time_outlined),
title: Text('${_startTime.format(context).toString()} - ${_endTime.format(context).toString()}'),
subtitle: const Text('Zeitraum'),
onTap: () async {
TimeRange timeRange = await showTimeRangePicker(
context: context,
start: _startTime,
end: _endTime,
disabledTime: TimeRange(startTime: const TimeOfDay(hour: 16, minute: 30), endTime: const TimeOfDay(hour: 08, minute: 00)),
disabledColor: Colors.grey,
paintingStyle: PaintingStyle.fill,
interval: const Duration(minutes: 5),
fromText: 'Beginnend',
toText: 'Endend',
strokeColor: Theme.of(context).colorScheme.secondary,
minDuration: const Duration(minutes: 15),
selectedColor: Theme.of(context).primaryColor,
ticks: 24,
);
setState(() {
_startTime = timeRange.startTime;
_endTime = timeRange.endTime;
});
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.color_lens_outlined),
title: const Text('Farbgebung'),
trailing: DropdownButton<CustomTimetableColors>(
value: _customTimetableColor,
icon: const Icon(Icons.arrow_drop_down),
items: CustomTimetableColors.values.map((e) => DropdownMenuItem<CustomTimetableColors>(
value: e,
enabled: e != _customTimetableColor,
child: Row(
children: [
Icon(Icons.circle, color: TimetableColors.getDisplayOptions(e).color),
const SizedBox(width: 10),
Text(TimetableColors.getDisplayOptions(e).displayName),
],
),
)).toList(),
onChanged: (e) {
setState(() {
_customTimetableColor = e!;
});
},
),
),
const Divider(),
RRuleGenerator(
config: RRuleGeneratorConfig(
headerEnabled: true,
weekdayBackgroundColor: Theme.of(context).colorScheme.secondary,
weekdaySelectedBackgroundColor: Theme.of(context).primaryColor,
weekdayColor: Colors.black,
),
initialRRule: _recurringRule,
textDelegate: const GermanRRuleTextDelegate(),
onChange: (String newValue) {
log('Rule: $newValue');
setState(() {
_recurringRule = newValue;
});
},
)
],
),
),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Abbrechen'),
),
TextButton(
onPressed: () {
if(!validate()) return;
var editedEvent = CustomTimetableEvent(
id: '',
title: _eventName.text,
description: _eventDescription.text,
startDate: _date.withTime(_startTime),
endDate: _date.withTime(_endTime),
color: _customTimetableColor.name,
rrule: _recurringRule,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
if(!isEditingExisting) {
AddCustomTimetableEvent(
AddCustomTimetableEventParams(
AccountData().getUserSecret(),
editedEvent
)
).run().then((value) {
Navigator.of(context).pop();
fetchTimetable();
})
.catchError((error, stack) {
InfoDialog.show(context, error.toString());
});
} else {
UpdateCustomTimetableEvent(
UpdateCustomTimetableEventParams(
widget.existingEvent?.id ?? '',
editedEvent
)
).run().then((value) {
Navigator.of(context).pop();
fetchTimetable();
})
.catchError((error, stack) {
InfoDialog.show(context, error.toString());
});
}
},
child: Text(isEditingExisting ? 'Speichern' : 'Erstellen'),
),
],
);
}
@@ -1,35 +1,30 @@
import 'package:flutter/material.dart';
import '../../../theming/darkAppTheme.dart';
import '../../../../theming/darkAppTheme.dart';
enum CustomTimetableColors {
orange,
red,
green,
blue
}
enum CustomTimetableColors { orange, red, green, blue }
class TimetableColors {
static const CustomTimetableColors defaultColor = CustomTimetableColors.orange;
static ColorModeDisplay getDisplayOptions(CustomTimetableColors color) {
switch(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((element) => element.name == color, orElse: () => TimetableColors.defaultColor)).color;
static Color getColorFromString(String color) =>
getDisplayOptions(CustomTimetableColors.values.firstWhere(
(e) => e.name == color,
orElse: () => defaultColor,
)).color;
}
class ColorModeDisplay {
@@ -0,0 +1,190 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:jiffy/jiffy.dart';
import 'package:rrule_generator/rrule_generator.dart';
import 'package:time_range_picker/time_range_picker.dart';
import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart';
import '../../../../extensions/dateTime.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../widget/focusBehaviour.dart';
import '../../../../widget/infoDialog.dart';
import 'custom_event_colors.dart';
class CustomEventEditDialog extends StatefulWidget {
final CustomTimetableEvent? existingEvent;
const CustomEventEditDialog({this.existingEvent, super.key});
@override
State<CustomEventEditDialog> createState() => _CustomEventEditDialogState();
}
class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
late DateTime _date = widget.existingEvent?.startDate ?? DateTime.now();
late TimeOfDay _startTime = widget.existingEvent?.startDate.toTimeOfDay() ?? const TimeOfDay(hour: 8, minute: 0);
late TimeOfDay _endTime = widget.existingEvent?.endDate.toTimeOfDay() ?? const TimeOfDay(hour: 9, minute: 30);
late final TextEditingController _name = TextEditingController(text: widget.existingEvent?.title);
late final TextEditingController _description = TextEditingController(text: widget.existingEvent?.description);
late String _rrule = widget.existingEvent?.rrule ?? '';
late CustomTimetableColors _color = CustomTimetableColors.values.firstWhere(
(e) => e.name == widget.existingEvent?.color,
orElse: () => TimetableColors.defaultColor,
);
bool get _isEditing => widget.existingEvent != null;
bool _validate() => _name.text.isNotEmpty;
void _save() {
if (!_validate()) return;
final edited = CustomTimetableEvent(
id: widget.existingEvent?.id ?? '',
title: _name.text,
description: _description.text,
startDate: _date.withTime(_startTime),
endDate: _date.withTime(_endTime),
color: _color.name,
rrule: _rrule,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
final bloc = context.read<TimetableBloc>();
final future = _isEditing
? bloc.updateCustomEvent(widget.existingEvent!.id, edited)
: bloc.addCustomEvent(edited);
future.then((_) {
if (!mounted) return;
Navigator.of(context).pop();
}).catchError((Object error) {
if (!mounted) return;
InfoDialog.show(context, error.toString());
});
}
Future<void> _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _date,
firstDate: DateTime.now().subtract(const Duration(days: 30)),
lastDate: DateTime.now().add(const Duration(days: 30)),
);
if (picked != null && picked != _date) setState(() => _date = picked);
}
Future<void> _pickTimeRange() async {
final range = await showTimeRangePicker(
context: context,
start: _startTime,
end: _endTime,
disabledTime: TimeRange(
startTime: const TimeOfDay(hour: 16, minute: 30),
endTime: const TimeOfDay(hour: 8, minute: 0),
),
disabledColor: Colors.grey,
paintingStyle: PaintingStyle.fill,
interval: const Duration(minutes: 5),
fromText: 'Beginnend',
toText: 'Endend',
strokeColor: Theme.of(context).colorScheme.secondary,
minDuration: const Duration(minutes: 15),
selectedColor: Theme.of(context).primaryColor,
ticks: 24,
);
setState(() {
_startTime = range.startTime;
_endTime = range.endTime;
});
}
@override
Widget build(BuildContext context) => AlertDialog(
insetPadding: const EdgeInsets.all(20),
contentPadding: const EdgeInsets.all(10),
title: Text(_isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: TextField(
controller: _name,
autofocus: true,
decoration: const InputDecoration(labelText: 'Terminname', border: OutlineInputBorder()),
onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context),
),
),
ListTile(
title: TextField(
controller: _description,
maxLines: 2,
minLines: 2,
decoration: const InputDecoration(labelText: 'Beschreibung', border: OutlineInputBorder()),
onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context),
),
),
const Divider(),
ListTile(
leading: const Icon(Icons.date_range_outlined),
title: Text(Jiffy.parseFromDateTime(_date).yMMMd),
subtitle: const Text('Datum'),
onTap: _pickDate,
),
ListTile(
leading: const Icon(Icons.access_time_outlined),
title: Text('${_startTime.format(context)} - ${_endTime.format(context)}'),
subtitle: const Text('Zeitraum'),
onTap: _pickTimeRange,
),
const Divider(),
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!),
),
),
const Divider(),
RRuleGenerator(
config: RRuleGeneratorConfig(
headerEnabled: true,
weekdayBackgroundColor: Theme.of(context).colorScheme.secondary,
weekdaySelectedBackgroundColor: Theme.of(context).primaryColor,
weekdayColor: Colors.black,
),
initialRRule: _rrule,
textDelegate: const GermanRRuleTextDelegate(),
onChange: (newValue) {
log('Rule: $newValue');
setState(() => _rrule = newValue);
},
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')),
TextButton(onPressed: _save, child: Text(_isEditing ? 'Speichern' : 'Erstellen')),
],
);
}
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import '../../../../state/app/infrastructure/loadableState/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/centeredLeading.dart';
import '../../../../widget/placeholderView.dart';
import '../details/delete_custom_event.dart';
import 'custom_event_edit_dialog.dart';
class CustomEventsView extends StatelessWidget {
const CustomEventsView({super.key});
void _openCreateDialog(BuildContext context) {
showDialog(
context: context,
builder: (_) => const CustomEventEditDialog(),
barrierDismissible: false,
);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Eigene Termine'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _openCreateDialog(context),
),
],
),
body: LoadableStateConsumer<TimetableBloc, TimetableState>(
child: (state, _) {
final events = state.customEvents?.events ?? const [];
if (events.isEmpty) {
return PlaceholderView(
icon: Icons.calendar_today_outlined,
text: 'Keine Einträge vorhanden',
button: TextButton(
onPressed: () => _openCreateDialog(context),
child: const Text('Termin erstellen'),
),
);
}
return ListView(
children: events.map((e) => ListTile(
title: Text(e.title),
subtitle: Text(
'${e.rrule.isNotEmpty ? "wiederholend, " : ""}'
'beginnend ${Jiffy.parseFromDateTime(e.startDate).fromNow()}',
),
leading: CenteredLeading(Icon(e.rrule.isEmpty ? Icons.event_outlined : Icons.event_repeat_outlined)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => showDialog(
context: context,
builder: (_) => CustomEventEditDialog(existingEvent: e),
),
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => showDeleteCustomEventDialog(context, e),
),
],
),
)).toList(),
);
},
),
);
}
@@ -0,0 +1,24 @@
import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart';
import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
sealed class ArbitraryAppointment {
const ArbitraryAppointment();
T when<T>({
required T Function(GetTimetableResponseObject lesson) webuntis,
required T Function(CustomTimetableEvent event) custom,
}) => switch (this) {
WebuntisAppointment(:final lesson) => webuntis(lesson),
CustomAppointment(:final event) => custom(event),
};
}
class WebuntisAppointment extends ArbitraryAppointment {
final GetTimetableResponseObject lesson;
const WebuntisAppointment(this.lesson);
}
class CustomAppointment extends ArbitraryAppointment {
final CustomTimetableEvent event;
const CustomAppointment(this.event);
}
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'lesson_status.dart';
class LessonColor {
static const Color cancelled = Color(0xff000000);
static const Color irregular = Color(0xff8F19B3);
static const Color teacherChanged = Color(0xFF29639B);
static const Color parseFallback = Color(0xff404040);
static Color forStatus(LessonStatus status, ColorScheme scheme) {
switch (status) {
case LessonStatus.cancelled:
return cancelled;
case LessonStatus.irregular:
return irregular;
case LessonStatus.teacherChanged:
return teacherChanged;
case LessonStatus.past:
case LessonStatus.regular:
return scheme.primary;
case LessonStatus.ongoing:
return Color.from(
alpha: scheme.primary.a,
red: 200 / 255,
green: scheme.primary.g,
blue: scheme.primary.b,
);
}
}
}
@@ -0,0 +1,21 @@
import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
enum LessonStatus {
cancelled,
irregular,
teacherChanged,
past,
ongoing,
regular,
}
class LessonStatusClassifier {
static LessonStatus classify(GetTimetableResponseObject lesson, DateTime startTime, DateTime endTime, DateTime now) {
if (lesson.code == 'cancelled') return LessonStatus.cancelled;
if (lesson.code == 'irregular' || (lesson.te.isNotEmpty && lesson.te.first.id == 0)) return LessonStatus.irregular;
if (lesson.te.any((t) => t.orgname != null)) return LessonStatus.teacherChanged;
if (endTime.isBefore(now)) return LessonStatus.past;
if (startTime.isBefore(now) && endTime.isAfter(now)) return LessonStatus.ongoing;
return LessonStatus.regular;
}
}
@@ -0,0 +1,136 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart';
import '../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart';
import '../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart';
import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
import '../../../../storage/timetable/timetableSettings.dart';
import '../../../../storage/timetable/timetable_name_mode.dart';
import '../custom_events/custom_event_colors.dart';
import 'arbitrary_appointment.dart';
import 'lesson_color.dart';
import 'lesson_status.dart';
import 'webuntis_time.dart';
class TimetableAppointmentFactory {
final List<GetTimetableResponseObject> lessons;
final List<CustomTimetableEvent> customEvents;
final GetRoomsResponse rooms;
final GetSubjectsResponse subjects;
final TimetableSettings settings;
final ColorScheme colorScheme;
final DateTime now;
TimetableAppointmentFactory({
required this.lessons,
required this.customEvents,
required this.rooms,
required this.subjects,
required this.settings,
required this.colorScheme,
required this.now,
});
List<Appointment> build() {
final source = settings.connectDoubleLessons ? _mergeAdjacentLessons(lessons) : lessons;
return [
...source.map(_lessonToAppointment),
...customEvents.map(_customEventToAppointment),
];
}
Appointment _lessonToAppointment(GetTimetableResponseObject lesson) {
try {
final startTime = WebuntisTime.parse(lesson.date, lesson.startTime);
final endTime = WebuntisTime.parse(lesson.date, lesson.endTime);
final status = LessonStatusClassifier.classify(lesson, startTime, endTime, now);
return Appointment(
id: WebuntisAppointment(lesson),
startTime: startTime,
endTime: endTime,
subject: _subjectName(lesson),
location: _locationLabel(lesson),
notes: lesson.activityType,
color: LessonColor.forStatus(status, colorScheme),
);
} catch (_) {
return Appointment(
id: WebuntisAppointment(lesson),
startTime: WebuntisTime.parse(lesson.date, lesson.startTime),
endTime: WebuntisTime.parse(lesson.date, lesson.endTime),
subject: 'Änderung',
notes: lesson.info,
location: 'Unbekannt',
color: LessonColor.parseFallback,
startTimeZone: '',
endTimeZone: '',
);
}
}
Appointment _customEventToAppointment(CustomTimetableEvent event) => Appointment(
id: CustomAppointment(event),
startTime: event.startDate,
endTime: event.endDate,
location: event.description,
subject: event.title,
recurrenceRule: event.rrule,
color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name),
startTimeZone: '',
endTimeZone: '',
);
String _subjectName(GetTimetableResponseObject lesson) {
final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id);
if (subject == null) return 'Unbekannt';
return switch (settings.timetableNameMode) {
TimetableNameMode.name => subject.name,
TimetableNameMode.longName => subject.longName,
TimetableNameMode.alternateName => subject.alternateName,
};
}
String _locationLabel(GetTimetableResponseObject lesson) {
final roomName = rooms.result.firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id)?.name ?? 'Unbekannt';
final teacherName = lesson.te.firstOrNull?.longname ?? 'Unbekannt';
return '$roomName\n$teacherName';
}
// Pure: returns a new list, does not mutate input.
static List<GetTimetableResponseObject> _mergeAdjacentLessons(
List<GetTimetableResponseObject> input, {
Duration maxGap = const Duration(minutes: 5),
}) {
if (input.isEmpty) return const [];
final sorted = [...input]..sort((a, b) =>
WebuntisTime.parse(a.date, a.startTime).compareTo(WebuntisTime.parse(b.date, b.startTime)));
final merged = <GetTimetableResponseObject>[sorted.first];
for (var i = 1; i < sorted.length; i++) {
final previous = merged.last;
final current = sorted[i];
if (_canMerge(previous, current, maxGap)) {
previous.endTime = current.endTime;
} else {
merged.add(current);
}
}
return merged;
}
static bool _canMerge(GetTimetableResponseObject a, GetTimetableResponseObject b, Duration maxGap) {
final aSubject = a.su.firstOrNull?.id;
final bSubject = b.su.firstOrNull?.id;
if (aSubject == null || bSubject == null || aSubject != bSubject) return false;
if (a.ro.firstOrNull?.id != b.ro.firstOrNull?.id) return false;
if (a.te.firstOrNull?.id != b.te.firstOrNull?.id) return false;
if (a.code != b.code) return false;
final gap = WebuntisTime.parse(b.date, b.startTime).difference(WebuntisTime.parse(a.date, a.endTime));
return gap <= maxGap;
}
}
@@ -0,0 +1,14 @@
import 'package:intl/intl.dart';
class WebuntisTime {
static final DateFormat _dateFormat = DateFormat('yyyyMMdd');
static DateTime parse(int date, int time) {
final timeString = time.toString().padLeft(4, '0');
return DateTime.parse('$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}');
}
static int formatDate(DateTime date) => int.parse(_dateFormat.format(date));
static String dateKey(DateTime date) => _dateFormat.format(date);
}
@@ -0,0 +1,20 @@
import 'package:bottom_sheet/bottom_sheet.dart';
import 'package:flutter/material.dart';
void showAppointmentBottomSheet(
BuildContext context, {
required Widget Function(BuildContext context) header,
required SliverChildListDelegate Function(BuildContext context) body,
}) {
showStickyFlexibleBottomSheet(
minHeight: 0,
initHeight: 0.4,
maxHeight: 0.7,
anchors: [0, 0.4, 0.7],
isSafeArea: true,
maxHeaderHeight: 100,
context: context,
headerBuilder: (context, _) => header(context),
bodyBuilder: (context, _) => body(context),
);
}
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../data/arbitrary_appointment.dart';
import 'custom_event_sheet.dart';
import 'webuntis_lesson_sheet.dart';
class AppointmentDetailsDispatcher {
static void show(BuildContext context, TimetableBloc bloc, Appointment appointment) {
final id = appointment.id;
if (id is! ArbitraryAppointment) return;
id.when(
webuntis: (lesson) => WebuntisLessonSheet.show(context, bloc, appointment, lesson),
custom: (event) => CustomEventSheet.show(context, event),
);
}
}
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:rrule/rrule.dart';
import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart';
import '../../../../widget/centeredLeading.dart';
import '../../../../widget/debug/debugTile.dart';
import '../custom_events/custom_event_edit_dialog.dart';
import '_bottom_sheet.dart';
import 'delete_custom_event.dart';
class CustomEventSheet {
static void show(BuildContext context, CustomTimetableEvent event) {
showAppointmentBottomSheet(
context,
header: (_) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(event.title, style: const TextStyle(fontSize: 25, overflow: TextOverflow.ellipsis)),
Text(
'${Jiffy.parseFromDateTime(event.startDate).format(pattern: 'HH:mm')} - '
'${Jiffy.parseFromDateTime(event.endDate).format(pattern: 'HH:mm')}',
style: const TextStyle(fontSize: 15),
),
],
),
),
body: (sheetCtx) => SliverChildListDelegate([
const Divider(),
Center(
child: Wrap(
children: [
TextButton.icon(
onPressed: () {
Navigator.of(sheetCtx).pop();
showDialog(
context: context,
builder: (_) => CustomEventEditDialog(existingEvent: event),
);
},
label: const Text('Bearbeiten'),
icon: const Icon(Icons.edit_outlined),
),
TextButton.icon(
onPressed: () {
showDeleteCustomEventDialog(context, event).future.then((_) {
if (!sheetCtx.mounted) return;
Navigator.of(sheetCtx).pop();
});
},
label: const Text('Löschen'),
icon: const Icon(Icons.delete_outline),
),
],
),
),
const Divider(),
ListTile(
leading: const Icon(Icons.info_outline),
title: Text(event.description.isEmpty ? 'Keine Beschreibung' : event.description),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.repeat_outlined)),
title: Text('Serie: ${event.rrule.isNotEmpty ? "Wiederholend" : "Einmalig"}'),
subtitle: FutureBuilder(
future: RruleL10nEn.create(),
builder: (_, snapshot) {
if (event.rrule.isEmpty) return const Text('Keine weiteren Vorkommnisse');
if (snapshot.data == null) return const Text('...');
final rrule = RecurrenceRule.fromString(event.rrule);
if (!rrule.canFullyConvertToText) return const Text('Keine genauere Angabe möglich.');
return Text(rrule.toText(l10n: snapshot.data!));
},
),
),
DebugTile(sheetCtx).child(
ListTile(
leading: const CenteredLeading(Icon(Icons.rule)),
title: const Text('RRule'),
subtitle: Text(event.rrule.isEmpty ? 'Keine' : event.rrule),
),
),
DebugTile(sheetCtx).jsonData(event.toJson()),
]),
);
}
}
@@ -0,0 +1,24 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../widget/confirmDialog.dart';
Completer<void> showDeleteCustomEventDialog(BuildContext context, CustomTimetableEvent event) {
final completer = Completer<void>();
final bloc = context.read<TimetableBloc>();
ConfirmDialog(
title: 'Termin löschen',
content: 'Der ${event.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.',
confirmButton: 'Löschen',
onConfirm: () {
bloc.removeCustomEvent(event.id).then(completer.complete).onError((Object error, StackTrace stack) {
completer.completeError(error, stack);
});
},
).asDialog(context);
return completer;
}
@@ -0,0 +1,110 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart';
import '../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart';
import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../../../../widget/debug/debugTile.dart';
import '../../../../widget/unimplementedDialog.dart';
import '../../more/roomplan/roomplan.dart';
import '_bottom_sheet.dart';
class WebuntisLessonSheet {
static void show(BuildContext context, TimetableBloc bloc, Appointment appointment, GetTimetableResponseObject lesson) {
final state = bloc.state.data;
if (state == null) return;
final subject = _resolveSubject(state, lesson);
final room = _resolveRoom(state, lesson);
showAppointmentBottomSheet(
context,
header: (_) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${_codePrefix(lesson.code)}${subject.alternateName}',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 25),
overflow: TextOverflow.ellipsis,
),
Text(subject.longName),
Text(
'${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - '
'${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}',
style: const TextStyle(fontSize: 15),
),
],
),
),
body: (_) => SliverChildListDelegate([
const Divider(),
ListTile(
leading: const Icon(Icons.notifications_active),
title: Text('Status: ${lesson.code != null ? "Geändert" : "Regulär"}'),
),
ListTile(
leading: const Icon(Icons.room),
title: Text('Raum: ${room.name} (${room.longName})'),
trailing: IconButton(
icon: const Icon(Icons.house_outlined),
onPressed: () => pushScreen(context, withNavBar: false, screen: const Roomplan()),
),
),
ListTile(
leading: const Icon(Icons.person),
title: lesson.te.isNotEmpty
? Text(
'Lehrkraft: ${lesson.te[0].name}'
'${lesson.te[0].longname.isNotEmpty ? " (${lesson.te[0].longname})" : ""}',
)
: const Text('?'),
trailing: Visibility(
visible: !kReleaseMode,
child: IconButton(
icon: const Icon(Icons.textsms_outlined),
onPressed: () => UnimplementedDialog.show(context),
),
),
),
ListTile(
leading: const Icon(Icons.abc),
title: Text('Typ: ${lesson.activityType}'),
),
ListTile(
leading: const Icon(Icons.people),
title: Text('Klasse(n): ${lesson.kl.map((e) => e.name).join(", ")}'),
),
DebugTile(context).jsonData(lesson.toJson()),
]),
);
}
static String _codePrefix(String? code) {
if (code == 'cancelled') return 'Entfällt: ';
if (code == 'irregular') return 'Änderung: ';
return code ?? '';
}
static GetSubjectsResponseObject _resolveSubject(TimetableState state, GetTimetableResponseObject lesson) {
try {
return state.subjects!.result.firstWhere((s) => s.id == lesson.su[0].id);
} catch (_) {
return GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true);
}
}
static GetRoomsResponseObject _resolveRoom(TimetableState state, GetTimetableResponseObject lesson) {
try {
return state.rooms!.result.firstWhere((r) => r.id == lesson.ro[0].id);
} catch (_) {
return GetRoomsResponseObject(0, '?', 'Unbekannt', true, '?');
}
}
}
+146 -357
View File
@@ -1,26 +1,25 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import '../../../extensions/dateTime.dart';
import 'package:provider/provider.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart';
import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
import '../../../model/timetable/timetableProps.dart';
import '../../../storage/base/settingsProvider.dart';
import '../../../widget/loadingSpinner.dart';
import '../../../widget/placeholderView.dart';
import 'appointmenetComponent.dart';
import 'appointmentDetails.dart';
import 'arbitraryAppointment.dart';
import 'customTimetableColors.dart';
import 'customTimetableEventEditDialog.dart';
import 'timeRegionComponent.dart';
import 'timetableEvents.dart';
import 'timetableNameMode.dart';
import 'viewCustomTimetableEvents.dart';
import '../../../extensions/dateTime.dart';
import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart';
import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import 'custom_events/custom_event_edit_dialog.dart';
import 'custom_events/custom_events_view.dart';
import 'data/arbitrary_appointment.dart';
import 'data/timetable_appointment_factory.dart';
import 'details/appointment_details_dispatcher.dart';
import 'widgets/appointment_tile.dart';
import 'widgets/lesson_appointment_source.dart';
import 'widgets/special_regions_builder.dart';
import 'widgets/time_region_tile.dart';
enum _CalendarAction { addEvent, viewEvents }
class Timetable extends StatefulWidget {
const Timetable({super.key});
@@ -29,362 +28,152 @@ class Timetable extends StatefulWidget {
State<Timetable> createState() => _TimetableState();
}
enum CalendarActions { addEvent, viewEvents }
class _TimetableState extends State<Timetable> {
CalendarController controller = CalendarController();
late Timer updateTimings;
late final SettingsProvider settings;
final CalendarController _controller = CalendarController();
late Timer _highlightTicker;
LessonAppointmentSource? _cachedSource;
int? _lastDataVersion;
@override
void initState() {
settings = Provider.of<SettingsProvider>(context, listen: false);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
Provider.of<TimetableProps>(context, listen: false).run();
});
controller.displayDate = DateTime.now().add(const Duration(days: 2));
updateTimings = Timer.periodic(const Duration(seconds: 30), (Timer t) => setState((){}));
super.initState();
_controller.displayDate = _initialDisplayDate();
_highlightTicker = Timer.periodic(const Duration(seconds: 30), (_) {
if (mounted) setState(() => _cachedSource = null);
});
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Stunden & Vertretungsplan'),
actions: [
IconButton(
icon: const Icon(Icons.home_outlined),
onPressed: () {
controller.displayDate = DateTime.now().add(const Duration(days: 2));
}
),
PopupMenuButton<CalendarActions>(
icon: const Icon(Icons.edit_calendar_outlined),
itemBuilder: (context) => CalendarActions.values.map(
(e) {
String title;
Icon icon;
switch(e) {
case CalendarActions.addEvent:
title = 'Kalendereintrag hinzufügen';
icon = const Icon(Icons.add);
case CalendarActions.viewEvents:
title = 'Kalendereinträge anzeigen';
icon = const Icon(Icons.perm_contact_calendar_outlined);
}
return PopupMenuItem<CalendarActions>(
value: e,
child: ListTile(
title: Text(title),
leading: icon,
)
);
}
).toList(),
onSelected: (value) {
switch(value) {
case CalendarActions.addEvent:
showDialog(
context: context,
builder: (context) => const CustomTimetableEventEditDialog(),
barrierDismissible: false,
);
case CalendarActions.viewEvents:
Navigator.of(context).push(MaterialPageRoute(builder: (context) => const ViewCustomTimetableEvents()));
}
},
)
],
),
body: Consumer<TimetableProps>(
builder: (context, value, child) {
if(value.hasError) {
return PlaceholderView(
icon: Icons.calendar_month,
text: 'Webuntis error: ${value.error.toString()}',
button: TextButton(
child: const Text('Neu laden'),
onPressed: () {
controller.displayDate = DateTime.now().add(const Duration(days: 2));
Provider.of<TimetableProps>(context, listen: false).resetWeek();
},
),
);
}
if(value.primaryLoading()) return const LoadingSpinner();
var holidays = value.getHolidaysResponse;
return RefreshIndicator(
child: SfCalendar(
timeZone: 'W. Europe Standard Time',
view: CalendarView.workWeek,
dataSource: _buildTableEvents(value),
maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday),
minDate: DateTime.now().subtract(const Duration (days: 14)).nextWeekday(DateTime.sunday),
controller: controller,
onViewChanged: (ViewChangedDetails details) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
Provider.of<TimetableProps>(context, listen: false).updateWeek(details.visibleDates.first, details.visibleDates.last);
});
},
onTap: (calendarTapDetails) {
if(calendarTapDetails.appointments == null) return;
Appointment tapped = calendarTapDetails.appointments!.first;
AppointmentDetails.show(context, value, tapped);
},
firstDayOfWeek: DateTime.monday,
specialRegions: _buildSpecialTimeRegions(holidays),
timeSlotViewSettings: const TimeSlotViewSettings(
startHour: 07.5,
endHour: 16.5,
timeInterval: Duration(minutes: 30),
timeFormat: 'HH:mm',
dayFormat: 'EE',
timeIntervalHeight: 40,
),
timeRegionBuilder: (BuildContext context, TimeRegionDetails timeRegionDetails) => TimeRegionComponent(details: timeRegionDetails),
appointmentBuilder: (BuildContext context, CalendarAppointmentDetails details) => AppointmentComponent(
details: details,
crossedOut: _isCrossedOut(details)
),
headerHeight: 0,
selectionDecoration: const BoxDecoration(),
allowAppointmentResize: false,
allowDragAndDrop: false,
allowViewNavigation: false,
),
onRefresh: () async {
Provider.of<TimetableProps>(context, listen: false).run(renew: true);
return Future.delayed(const Duration(seconds: 3));
}
);
},
),
);
@override
void dispose() {
updateTimings.cancel();
_highlightTicker.cancel();
super.dispose();
}
List<TimeRegion> _buildSpecialTimeRegions(GetHolidaysResponse holidays) {
var lastMonday = DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.monday);
var firstBreak = lastMonday.copyWith(hour: 10, minute: 15);
var secondBreak = lastMonday.copyWith(hour: 13, minute: 50);
DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2));
var holidayList = holidays.result.map((holiday) {
var startDay = _parseWebuntisTimestamp(holiday.startDate, 0);
var dayCount = _parseWebuntisTimestamp(holiday.endDate, 0)
.difference(startDay)
.inDays;
var days = List<DateTime>.generate(dayCount, (index) => startDay.add(Duration(days: index)));
return days.map((holidayDay) => TimeRegion(
startTime: holidayDay.copyWith(hour: 07, minute: 55),
endTime: holidayDay.copyWith(hour: 16, minute: 30),
text: 'holiday:${holiday.name}',
color: Theme
.of(context)
.disabledColor
.withAlpha(50),
iconData: Icons.holiday_village_outlined
));
}).expand((e) => e);
bool isInHoliday(DateTime time) => holidayList.any((element) => element.startTime.isSameDay(time));
return [
...holidayList,
if(!isInHoliday(firstBreak))
TimeRegion(
startTime: firstBreak,
endTime: firstBreak.add(const Duration(minutes: 20)),
recurrenceRule: 'FREQ=DAILY;INTERVAL=1',
text: 'centerIcon',
color: Theme.of(context).primaryColor.withAlpha(50),
iconData: Icons.restaurant
),
if(!isInHoliday(secondBreak))
TimeRegion(
startTime: secondBreak,
endTime: secondBreak.add(const Duration(minutes: 15)),
recurrenceRule: 'FREQ=DAILY;INTERVAL=1',
text: 'centerIcon',
color: Theme.of(context).primaryColor.withAlpha(50),
iconData: Icons.restaurant
),
];
void _jumpToToday() {
_controller.displayDate = _initialDisplayDate();
}
List<GetTimetableResponseObject> _removeDuplicates(TimetableProps data, Duration maxTimeBetweenDouble) {
var timetableList = data.getTimetableResponse.result.toList();
if(timetableList.isEmpty) return timetableList;
timetableList.sort((a, b) => _parseWebuntisTimestamp(a.date, a.startTime).compareTo(_parseWebuntisTimestamp(b.date, b.startTime)));
var previousElement = timetableList.first;
for(var i = 1; i < timetableList.length; i++) {
var currentElement = timetableList.elementAt(i);
bool isSameLesson() {
var currentSubjectId = currentElement.su.firstOrNull?.id;
var previousSubjectId = previousElement.su.firstOrNull?.id;
if(currentSubjectId == null || previousSubjectId == null || currentSubjectId != previousSubjectId) return false;
var currentRoomId = currentElement.ro.firstOrNull?.id;
var previousRoomId = previousElement.ro.firstOrNull?.id;
if(currentRoomId != previousRoomId) return false;
var currentTeacherId = currentElement.te.firstOrNull?.id;
var previousTeacherId = previousElement.te.firstOrNull?.id;
if(currentTeacherId != previousTeacherId) return false;
var currentStatusCode = currentElement.code;
var previousStatusCode = previousElement.code;
if(currentStatusCode != previousStatusCode) return false;
return true;
}
bool isNotSeparated() => _parseWebuntisTimestamp(previousElement.date, previousElement.endTime).add(maxTimeBetweenDouble)
.isSameOrAfter(_parseWebuntisTimestamp(currentElement.date, currentElement.startTime));
if(isSameLesson() && isNotSeparated()) {
previousElement.endTime = currentElement.endTime;
timetableList.remove(currentElement);
i--;
} else {
previousElement = currentElement;
}
}
return timetableList;
}
TimetableEvents _buildTableEvents(TimetableProps data) {
var timetableList = data.getTimetableResponse.result.toList();
if(settings.val().timetableSettings.connectDoubleLessons) {
timetableList = _removeDuplicates(data, const Duration(minutes: 5));
}
var appointments = timetableList.map((element) {
var rooms = data.getRoomsResponse;
var subjects = data.getSubjectsResponse;
try {
var startTime = _parseWebuntisTimestamp(element.date, element.startTime);
var endTime = _parseWebuntisTimestamp(element.date, element.endTime);
var subject = subjects.result.firstWhereOrNull((subject) => subject.id == element.su.firstOrNull?.id);
var subjectName = 'Unbekannt';
if(subject != null) {
subjectName = {
TimetableNameMode.name: subject.name,
TimetableNameMode.longName: subject.longName,
TimetableNameMode.alternateName: subject.alternateName,
}[settings.val().timetableSettings.timetableNameMode]!;
}
return Appointment(
id: ArbitraryAppointment(webuntis: element),
startTime: startTime,
endTime: endTime,
subject: subjectName,
location: ''
'${rooms.result.firstWhereOrNull((room) => room.id == element.ro.firstOrNull?.id)?.name ?? 'Unbekannt'}'
'\n'
'${element.te.firstOrNull?.longname ?? 'Unbekannt'}',
notes: element.activityType,
color: _getEventColor(element, startTime, endTime),
void _onAction(_CalendarAction action) {
switch (action) {
case _CalendarAction.addEvent:
showDialog(
context: context,
builder: (_) => const CustomEventEditDialog(),
barrierDismissible: false,
);
} catch(e) {
var endTime = _parseWebuntisTimestamp(element.date, element.endTime);
return Appointment(
id: ArbitraryAppointment(webuntis: element),
startTime: _parseWebuntisTimestamp(element.date, element.startTime),
endTime: endTime,
subject: 'Änderung',
notes: element.info,
location: 'Unbekannt',
color: const Color(0xff404040),
startTimeZone: '',
endTimeZone: '',
);
}
}).toList();
appointments.addAll(data.getCustomTimetableEventResponse.events.map((customEvent) => Appointment(
id: ArbitraryAppointment(custom: customEvent),
startTime: customEvent.startDate,
endTime: customEvent.endDate,
location: customEvent.description,
subject: customEvent.title,
recurrenceRule: customEvent.rrule,
color: TimetableColors.getColorFromString(customEvent.color ?? TimetableColors.defaultColor.name),
startTimeZone: '',
endTimeZone: '',
)));
return TimetableEvents(appointments);
}
DateTime _parseWebuntisTimestamp(int date, int time) {
var timeString = time.toString().padLeft(4, '0');
return DateTime.parse('$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}');
}
Color _getEventColor(GetTimetableResponseObject webuntisElement, DateTime startTime, DateTime endTime) {
// Cancelled
if(webuntisElement.code == 'cancelled') return const Color(0xff000000);
// Any changes or no teacher at this element
if(webuntisElement.code == 'irregular' || webuntisElement.te.first.id == 0) return const Color(0xff8F19B3);
// Teacher has changed
if(webuntisElement.te.any((element) => element.orgname != null)) return const Color(0xFF29639B);
// Event was in the past
if(endTime.isBefore(DateTime.now())) return Theme.of(context).primaryColor;
// Event takes currently place
if(endTime.isAfter(DateTime.now()) && startTime.isBefore(DateTime.now())) return Theme.of(context).primaryColor.withRed(200);
// Fallback
return Theme.of(context).primaryColor;
}
bool _isCrossedOut(CalendarAppointmentDetails calendarEntry) {
var appointment = calendarEntry.appointments.first.id as ArbitraryAppointment;
if(appointment.hasWebuntis()) {
return appointment.webuntis!.code == 'cancelled';
case _CalendarAction.viewEvents:
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const CustomEventsView()));
}
}
LessonAppointmentSource _appointmentSource(TimetableState state) {
if (_cachedSource != null && _lastDataVersion == state.dataVersion) {
return _cachedSource!;
}
_lastDataVersion = state.dataVersion;
final settings = context.read<SettingsCubit>();
final appointments = TimetableAppointmentFactory(
lessons: state.getAllKnownLessons().toList(),
customEvents: state.customEvents?.events ?? const [],
rooms: state.rooms!,
subjects: state.subjects!,
settings: settings.val().timetableSettings,
colorScheme: Theme.of(context).colorScheme,
now: DateTime.now(),
).build();
return _cachedSource = LessonAppointmentSource(appointments);
}
@override
Widget build(BuildContext context) {
final bloc = context.read<TimetableBloc>();
return Scaffold(
appBar: AppBar(
title: const Text('Stunden & Vertretungsplan'),
actions: [
IconButton(icon: const Icon(Icons.home_outlined), onPressed: _jumpToToday),
PopupMenuButton<_CalendarAction>(
icon: const Icon(Icons.edit_calendar_outlined),
onSelected: _onAction,
itemBuilder: (_) => const [
PopupMenuItem(
value: _CalendarAction.addEvent,
child: ListTile(title: Text('Kalendereintrag hinzufügen'), leading: Icon(Icons.add)),
),
PopupMenuItem(
value: _CalendarAction.viewEvents,
child: ListTile(
title: Text('Kalendereinträge anzeigen'),
leading: Icon(Icons.perm_contact_calendar_outlined),
),
),
],
),
],
),
body: LoadableStateConsumer<TimetableBloc, TimetableState>(
child: (state, _) => _calendar(state, bloc),
),
);
}
Widget _calendar(TimetableState state, TimetableBloc bloc) {
if (!state.hasReferenceData) return const SizedBox.shrink();
return SfCalendar(
timeZone: 'W. Europe Standard Time',
view: CalendarView.workWeek,
dataSource: _appointmentSource(state),
maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday),
minDate: DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.sunday),
controller: _controller,
onViewChanged: (details) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
bloc.changeWeek(details.visibleDates.first, details.visibleDates.last);
});
},
onTap: (tap) {
if (tap.appointments == null || tap.appointments!.isEmpty) return;
AppointmentDetailsDispatcher.show(context, bloc, tap.appointments!.first);
},
firstDayOfWeek: DateTime.monday,
specialRegions: SpecialRegionsBuilder(
holidays: state.schoolHolidays!,
colorScheme: Theme.of(context).colorScheme,
disabledColor: Theme.of(context).disabledColor,
).build(),
timeSlotViewSettings: const TimeSlotViewSettings(
startHour: 7.5,
endHour: 16.5,
timeInterval: Duration(minutes: 30),
timeFormat: 'HH:mm',
dayFormat: 'EE',
timeIntervalHeight: 40,
),
timeRegionBuilder: (_, details) => TimeRegionTile(details: details),
appointmentBuilder: (_, details) => AppointmentTile(
details: details,
crossedOut: _isCrossedOut(details),
),
headerHeight: 0,
selectionDecoration: const BoxDecoration(),
allowAppointmentResize: false,
allowDragAndDrop: false,
allowViewNavigation: false,
);
}
bool _isCrossedOut(CalendarAppointmentDetails details) {
final appointment = details.appointments.first;
final id = appointment.id;
if (id is WebuntisAppointment) return id.lesson.code == 'cancelled';
return false;
}
}
@@ -1,8 +0,0 @@
import 'package:syncfusion_flutter_calendar/calendar.dart';
class TimetableEvents extends CalendarDataSource {
TimetableEvents(List<Appointment> source) {
appointments = source;
}
}
@@ -1,25 +0,0 @@
import 'package:flutter/material.dart';
import '../../../widget/dropdownDisplay.dart';
enum TimetableNameMode {
name,
longName,
alternateName
}
class TimetableNameModes {
static DropdownDisplay getDisplayOptions(TimetableNameMode theme) {
switch(theme) {
case TimetableNameMode.name:
return DropdownDisplay(icon: Icons.device_unknown_outlined, displayName: 'Name');
case TimetableNameMode.longName:
return DropdownDisplay(icon: Icons.perm_device_info_outlined, displayName: 'Langname');
case TimetableNameMode.alternateName:
return DropdownDisplay(icon: Icons.on_device_training_outlined, displayName: 'Kurzform');
}
}
}
@@ -1,95 +0,0 @@
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:provider/provider.dart';
import '../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart';
import '../../../model/timetable/timetableProps.dart';
import '../../../widget/centeredLeading.dart';
import '../../../widget/loadingSpinner.dart';
import '../../../widget/placeholderView.dart';
import 'appointmentDetails.dart';
import 'customTimetableEventEditDialog.dart';
class ViewCustomTimetableEvents extends StatefulWidget {
const ViewCustomTimetableEvents({super.key});
@override
State<ViewCustomTimetableEvents> createState() => _ViewCustomTimetableEventsState();
}
class _ViewCustomTimetableEventsState extends State<ViewCustomTimetableEvents> {
late Future<GetCustomTimetableEventResponse> events;
@override
void initState() {
super.initState();
}
_openCreateDialog() {
showDialog(
context: context,
builder: (context) => const CustomTimetableEventEditDialog(),
barrierDismissible: false,
);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Eigene Termine'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: _openCreateDialog,
)
],
),
body: Consumer<TimetableProps>(builder: (context, value, child) {
if(value.primaryLoading()) return const LoadingSpinner();
var listView = ListView(
children: value.getCustomTimetableEventResponse.events.map((e) => ListTile(
title: Text(e.title),
subtitle: Text("${e.rrule.isNotEmpty ? "wiederholdend, " : ""}beginnend ${Jiffy.parseFromDateTime(e.startDate).fromNow()}"),
leading: CenteredLeading(Icon(e.rrule.isEmpty ? Icons.event_outlined : Icons.event_repeat_outlined)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () {
showDialog(context: context, builder: (context) => CustomTimetableEventEditDialog(existingEvent: e));
},
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () {
AppointmentDetails.deleteCustomEvent(context, e);
},
)
],
),
)).toList(),
);
var placeholder = PlaceholderView(
icon: Icons.calendar_today_outlined,
text: 'Keine Einträge vorhanden',
button: TextButton(
onPressed: _openCreateDialog,
child: const Text('Termin erstellen'),
),
);
return RefreshIndicator(
onRefresh: () {
Provider.of<TimetableProps>(context, listen: false).run(renew: true);
return Future.delayed(const Duration(seconds: 3));
},
child: value.getCustomTimetableEventResponse.events.isEmpty
? placeholder
: listView
);
}),
);
}
@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import 'cross_painter.dart';
class AppointmentTile extends StatelessWidget {
final CalendarAppointmentDetails details;
final bool crossedOut;
const AppointmentTile({super.key, required this.details, this.crossedOut = false});
@override
Widget build(BuildContext context) {
final Appointment meeting = details.appointments.first;
final isPast = meeting.endTime.isBefore(DateTime.now());
final color = meeting.color.withAlpha(isPast ? 100 : 255);
return Stack(
children: [
Container(
padding: const EdgeInsets.all(3),
height: details.bounds.height,
alignment: Alignment.topLeft,
decoration: BoxDecoration(
shape: BoxShape.rectangle,
borderRadius: const BorderRadius.all(Radius.circular(5)),
color: color,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FittedBox(
fit: BoxFit.fitWidth,
child: Text(
meeting.subject,
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500),
maxLines: 1,
softWrap: false,
),
),
FittedBox(
fit: BoxFit.fitWidth,
child: Text(
meeting.location?.isNotEmpty == true ? meeting.location! : ' ',
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 10),
),
),
],
),
),
),
if (crossedOut)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
border: Border.all(width: 2, color: Colors.red.withAlpha(200)),
borderRadius: const BorderRadius.all(Radius.circular(5)),
),
child: CustomPaint(painter: CrossPainter()),
),
),
],
);
}
}
@@ -0,0 +1,7 @@
import 'package:syncfusion_flutter_calendar/calendar.dart';
class LessonAppointmentSource extends CalendarDataSource {
LessonAppointmentSource(List<Appointment> source) {
appointments = source;
}
}
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart';
import '../../../../extensions/dateTime.dart';
import '../data/webuntis_time.dart';
import 'time_region_tile.dart';
class SpecialRegionsBuilder {
final GetHolidaysResponse holidays;
final ColorScheme colorScheme;
final Color disabledColor;
SpecialRegionsBuilder({
required this.holidays,
required this.colorScheme,
required this.disabledColor,
});
List<TimeRegion> build() {
final lastMonday = DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.monday);
final firstBreak = lastMonday.copyWith(hour: 10, minute: 15);
final secondBreak = lastMonday.copyWith(hour: 13, minute: 50);
final holidayRegions = _buildHolidayRegions().toList();
bool isInHoliday(DateTime time) => holidayRegions.any((region) => region.startTime.isSameDay(time));
return [
...holidayRegions,
if (!isInHoliday(firstBreak)) _breakRegion(firstBreak, const Duration(minutes: 20)),
if (!isInHoliday(secondBreak)) _breakRegion(secondBreak, const Duration(minutes: 15)),
];
}
Iterable<TimeRegion> _buildHolidayRegions() => holidays.result.expand((holiday) {
final startDay = WebuntisTime.parse(holiday.startDate, 0);
final dayCount = WebuntisTime.parse(holiday.endDate, 0).difference(startDay).inDays;
final days = List<DateTime>.generate(dayCount, (i) => startDay.add(Duration(days: i)));
return days.map((day) => TimeRegion(
startTime: day.copyWith(hour: 7, minute: 55),
endTime: day.copyWith(hour: 16, minute: 30),
text: '$kTimeRegionHolidayPrefix${holiday.name}',
color: disabledColor.withAlpha(50),
iconData: Icons.holiday_village_outlined,
));
});
TimeRegion _breakRegion(DateTime start, Duration duration) => TimeRegion(
startTime: start,
endTime: start.add(duration),
recurrenceRule: 'FREQ=DAILY;INTERVAL=1',
text: kTimeRegionCenterIcon,
color: colorScheme.primary.withAlpha(50),
iconData: Icons.restaurant,
);
}
@@ -1,31 +1,28 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
class TimeRegionComponent extends StatefulWidget {
const String kTimeRegionCenterIcon = 'centerIcon';
const String kTimeRegionHolidayPrefix = 'holiday:';
class TimeRegionTile extends StatelessWidget {
final TimeRegionDetails details;
const TimeRegionComponent({super.key, required this.details});
@override
State<TimeRegionComponent> createState() => _TimeRegionComponentState();
}
const TimeRegionTile({super.key, required this.details});
class _TimeRegionComponentState extends State<TimeRegionComponent> {
@override
Widget build(BuildContext context) {
var text = widget.details.region.text!;
var color = widget.details.region.color;
final text = details.region.text ?? '';
final color = details.region.color;
if (text == 'centerIcon') {
if (text == kTimeRegionCenterIcon) {
return Container(
color: color,
alignment: Alignment.center,
child: Icon(
widget.details.region.iconData,
size: 17,
color: Theme.of(context).primaryColor,
),
child: Icon(details.region.iconData, size: 17, color: Theme.of(context).primaryColor),
);
} else if(text.startsWith('holiday')) {
}
if (text.startsWith(kTimeRegionHolidayPrefix)) {
return Container(
color: color,
alignment: Alignment.center,
@@ -38,7 +35,7 @@ class _TimeRegionComponentState extends State<TimeRegionComponent> {
RotatedBox(
quarterTurns: 1,
child: Text(
text.split(':').last,
text.substring(kTimeRegionHolidayPrefix.length),
maxLines: 1,
style: const TextStyle(
fontWeight: FontWeight.bold,