dart format
This commit is contained in:
@@ -5,7 +5,8 @@ import '../../../../theming/dark_app_theme.dart';
|
||||
enum CustomTimetableColors { orange, red, green, blue }
|
||||
|
||||
class TimetableColors {
|
||||
static const CustomTimetableColors defaultColor = CustomTimetableColors.orange;
|
||||
static const CustomTimetableColors defaultColor =
|
||||
CustomTimetableColors.orange;
|
||||
|
||||
static ColorModeDisplay getDisplayOptions(CustomTimetableColors color) {
|
||||
switch (color) {
|
||||
@@ -14,17 +15,24 @@ class TimetableColors {
|
||||
case CustomTimetableColors.blue:
|
||||
return ColorModeDisplay(color: Colors.blue, displayName: 'Blau');
|
||||
case CustomTimetableColors.orange:
|
||||
return ColorModeDisplay(color: Colors.orange.shade800, displayName: 'Orange');
|
||||
return ColorModeDisplay(
|
||||
color: Colors.orange.shade800,
|
||||
displayName: 'Orange',
|
||||
);
|
||||
case CustomTimetableColors.red:
|
||||
return ColorModeDisplay(color: DarkAppTheme.marianumRed, displayName: 'Rot');
|
||||
return ColorModeDisplay(
|
||||
color: DarkAppTheme.marianumRed,
|
||||
displayName: 'Rot',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static Color getColorFromString(String color) =>
|
||||
getDisplayOptions(CustomTimetableColors.values.firstWhere(
|
||||
(e) => e.name == color,
|
||||
orElse: () => defaultColor,
|
||||
)).color;
|
||||
static Color getColorFromString(String color) => getDisplayOptions(
|
||||
CustomTimetableColors.values.firstWhere(
|
||||
(e) => e.name == color,
|
||||
orElse: () => defaultColor,
|
||||
),
|
||||
).color;
|
||||
}
|
||||
|
||||
class ColorModeDisplay {
|
||||
|
||||
@@ -42,7 +42,8 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
||||
static const TimeOfDay _defaultEnd = TimeOfDay(hour: 9, minute: 30);
|
||||
static const int _minDurationMinutes = 15;
|
||||
|
||||
late DateTime _date = widget.existingEvent?.startDate ?? widget.initialStart ?? DateTime.now();
|
||||
late DateTime _date =
|
||||
widget.existingEvent?.startDate ?? widget.initialStart ?? DateTime.now();
|
||||
late TimeOfDay _startTime;
|
||||
late TimeOfDay _endTime;
|
||||
late bool _isAllDay;
|
||||
@@ -85,13 +86,18 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
||||
_endTime = clamped.$2;
|
||||
}
|
||||
|
||||
static (TimeOfDay, TimeOfDay) _clampToVisibleWindow(TimeOfDay rawStart, TimeOfDay rawEnd) {
|
||||
static (TimeOfDay, TimeOfDay) _clampToVisibleWindow(
|
||||
TimeOfDay rawStart,
|
||||
TimeOfDay rawEnd,
|
||||
) {
|
||||
int toMin(TimeOfDay t) => t.hour * 60 + t.minute;
|
||||
TimeOfDay fromMin(int m) => TimeOfDay(hour: m ~/ 60, minute: m % 60);
|
||||
|
||||
final windowStart = toMin(_windowStart);
|
||||
final windowEnd = toMin(_windowEnd);
|
||||
var start = toMin(rawStart).clamp(windowStart, windowEnd - _minDurationMinutes);
|
||||
var start = toMin(
|
||||
rawStart,
|
||||
).clamp(windowStart, windowEnd - _minDurationMinutes);
|
||||
var end = toMin(rawEnd);
|
||||
if (end < start + _minDurationMinutes) end = start + _minDurationMinutes;
|
||||
if (end > windowEnd) {
|
||||
@@ -165,10 +171,7 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
||||
context: context,
|
||||
start: _startTime,
|
||||
end: _endTime,
|
||||
disabledTime: TimeRange(
|
||||
startTime: _windowEnd,
|
||||
endTime: _windowStart,
|
||||
),
|
||||
disabledTime: TimeRange(startTime: _windowEnd, endTime: _windowStart),
|
||||
disabledColor: Colors.grey,
|
||||
paintingStyle: PaintingStyle.fill,
|
||||
interval: const Duration(minutes: 5),
|
||||
@@ -188,103 +191,118 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
||||
|
||||
@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),
|
||||
),
|
||||
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(),
|
||||
),
|
||||
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,
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.today_outlined),
|
||||
title: const Text('Ganztägig'),
|
||||
value: _isAllDay,
|
||||
onChanged: (v) => setState(() => _isAllDay = v),
|
||||
),
|
||||
if (!_isAllDay)
|
||||
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(
|
||||
selectDayStyle: RRuleSelectDayStyle(
|
||||
dayStyle: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
dayTextStyle: const TextStyle(color: Colors.black),
|
||||
selectedDayStyle: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
initialRRule: _rrule,
|
||||
locale: RRuleLocale.de_DE,
|
||||
onChange: (newValue) {
|
||||
log('Rule: $newValue');
|
||||
setState(() => _rrule = newValue);
|
||||
},
|
||||
),
|
||||
],
|
||||
onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
AsyncDialogAction(
|
||||
confirmLabel: _isEditing ? 'Speichern' : 'Erstellen',
|
||||
onConfirm: _save,
|
||||
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,
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.today_outlined),
|
||||
title: const Text('Ganztägig'),
|
||||
value: _isAllDay,
|
||||
onChanged: (v) => setState(() => _isAllDay = v),
|
||||
),
|
||||
if (!_isAllDay)
|
||||
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(
|
||||
selectDayStyle: RRuleSelectDayStyle(
|
||||
dayStyle: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
dayTextStyle: const TextStyle(color: Colors.black),
|
||||
selectedDayStyle: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
initialRRule: _rrule,
|
||||
locale: RRuleLocale.de_DE,
|
||||
onChange: (newValue) {
|
||||
log('Rule: $newValue');
|
||||
setState(() => _rrule = newValue);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
AsyncDialogAction(
|
||||
confirmLabel: _isEditing ? 'Speichern' : 'Erstellen',
|
||||
onConfirm: _save,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,57 +22,69 @@ class CustomEventsView extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Eigene Termine'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
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 ${e.startDate.formatRelative()}',
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
children: events.map((e) => ListTile(
|
||||
title: Text(e.title),
|
||||
subtitle: Text(
|
||||
'${e.rrule.isNotEmpty ? "wiederholend, " : ""}'
|
||||
'beginnend ${e.startDate.formatRelative()}',
|
||||
),
|
||||
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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ sealed class ArbitraryAppointment {
|
||||
required T Function(GetTimetableResponseObject lesson) webuntis,
|
||||
required T Function(CustomTimetableEvent event) custom,
|
||||
}) => switch (this) {
|
||||
WebuntisAppointment(:final lesson) => webuntis(lesson),
|
||||
CustomAppointment(:final event) => custom(event),
|
||||
};
|
||||
WebuntisAppointment(:final lesson) => webuntis(lesson),
|
||||
CustomAppointment(:final event) => custom(event),
|
||||
};
|
||||
}
|
||||
|
||||
class WebuntisAppointment extends ArbitraryAppointment {
|
||||
|
||||
@@ -43,24 +43,28 @@ List<BoundRegion> expandRegionsForDay(List<TimeRegion> regions, DateTime day) {
|
||||
final result = <BoundRegion>[];
|
||||
final dayStart = DateTime(day.year, day.month, day.day);
|
||||
for (final region in regions) {
|
||||
final isRecurringDaily = region.recurrenceRule != null &&
|
||||
final isRecurringDaily =
|
||||
region.recurrenceRule != null &&
|
||||
region.recurrenceRule!.toUpperCase().contains('FREQ=DAILY');
|
||||
if (isRecurringDaily) {
|
||||
final start = dayStart.add(Duration(
|
||||
hours: region.startTime.hour,
|
||||
minutes: region.startTime.minute,
|
||||
));
|
||||
final end = dayStart.add(Duration(
|
||||
hours: region.endTime.hour,
|
||||
minutes: region.endTime.minute,
|
||||
));
|
||||
final start = dayStart.add(
|
||||
Duration(
|
||||
hours: region.startTime.hour,
|
||||
minutes: region.startTime.minute,
|
||||
),
|
||||
);
|
||||
final end = dayStart.add(
|
||||
Duration(hours: region.endTime.hour, minutes: region.endTime.minute),
|
||||
);
|
||||
result.add(BoundRegion(region: region, start: start, end: end));
|
||||
} else if (region.startTime.isSameDay(day)) {
|
||||
result.add(BoundRegion(
|
||||
region: region,
|
||||
start: region.startTime,
|
||||
end: region.endTime,
|
||||
));
|
||||
result.add(
|
||||
BoundRegion(
|
||||
region: region,
|
||||
start: region.startTime,
|
||||
end: region.endTime,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -73,8 +77,10 @@ List<BoundRegion> expandRegionsForDay(List<TimeRegion> regions, DateTime day) {
|
||||
/// [kCalendarStartHour] or end after [kCalendarEndHour]). The outside bucket
|
||||
/// is rendered as chips above the grid.
|
||||
({List<List<Appointment>> inside, List<List<Appointment>> outside})
|
||||
partitionAppointmentsForWeek(
|
||||
List<Appointment> appointments, DateTime weekStart) {
|
||||
partitionAppointmentsForWeek(
|
||||
List<Appointment> appointments,
|
||||
DateTime weekStart,
|
||||
) {
|
||||
final inside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
|
||||
final outside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
|
||||
final weekEnd = weekStart.add(const Duration(days: 5));
|
||||
@@ -104,12 +110,19 @@ List<BoundRegion> expandRegionsForDay(List<TimeRegion> regions, DateTime day) {
|
||||
if (!occUtc.isBefore(weekEndUtc)) break;
|
||||
if (occUtc.isBefore(weekStartUtc)) continue;
|
||||
final occLocal = occUtc.toLocal();
|
||||
final idx = DateTime(occLocal.year, occLocal.month, occLocal.day)
|
||||
.difference(weekStart)
|
||||
.inDays;
|
||||
final idx = DateTime(
|
||||
occLocal.year,
|
||||
occLocal.month,
|
||||
occLocal.day,
|
||||
).difference(weekStart).inDays;
|
||||
if (idx < 0 || idx >= 5) continue;
|
||||
final newStart = DateTime(occLocal.year, occLocal.month, occLocal.day,
|
||||
a.startTime.hour, a.startTime.minute);
|
||||
final newStart = DateTime(
|
||||
occLocal.year,
|
||||
occLocal.month,
|
||||
occLocal.day,
|
||||
a.startTime.hour,
|
||||
a.startTime.minute,
|
||||
);
|
||||
place(
|
||||
idx,
|
||||
Appointment(
|
||||
@@ -150,8 +163,7 @@ class PeriodLayout {
|
||||
|
||||
double _h(LessonPeriod p) => p.isBreak ? breakHeight : lessonHeight;
|
||||
|
||||
double get totalHeight =>
|
||||
periods.fold<double>(0, (sum, p) => sum + _h(p));
|
||||
double get totalHeight => periods.fold<double>(0, (sum, p) => sum + _h(p));
|
||||
|
||||
double topOf(LessonPeriod period) {
|
||||
var y = 0.0;
|
||||
@@ -241,7 +253,13 @@ class LaidOutOverflow extends LaidOutCell {
|
||||
final DateTime startTime;
|
||||
@override
|
||||
final DateTime endTime;
|
||||
LaidOutOverflow(this.appointments, this.lane, this.laneCount, this.startTime, this.endTime);
|
||||
LaidOutOverflow(
|
||||
this.appointments,
|
||||
this.lane,
|
||||
this.laneCount,
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
);
|
||||
}
|
||||
|
||||
/// Horizontal ordering rank for parallel appointments. Lower = further left.
|
||||
@@ -269,17 +287,21 @@ int _appointmentPriority(Appointment a) {
|
||||
/// is free at its `startTime`. When no lane is free, open a new one.
|
||||
/// 3. A cluster ends as soon as every active lane's end is at or before the
|
||||
/// next appointment's start.
|
||||
List<LaidOutCell> assignLanes(List<Appointment> appts, {required int maxLanes}) {
|
||||
List<LaidOutCell> assignLanes(
|
||||
List<Appointment> appts, {
|
||||
required int maxLanes,
|
||||
}) {
|
||||
assert(maxLanes >= 2, 'maxLanes must reserve at least one slot for overflow');
|
||||
if (appts.isEmpty) return const <LaidOutCell>[];
|
||||
|
||||
final sorted = [...appts]..sort((a, b) {
|
||||
final c = a.startTime.compareTo(b.startTime);
|
||||
if (c != 0) return c;
|
||||
final p = _appointmentPriority(a).compareTo(_appointmentPriority(b));
|
||||
if (p != 0) return p;
|
||||
return b.endTime.compareTo(a.endTime);
|
||||
});
|
||||
final sorted = [...appts]
|
||||
..sort((a, b) {
|
||||
final c = a.startTime.compareTo(b.startTime);
|
||||
if (c != 0) return c;
|
||||
final p = _appointmentPriority(a).compareTo(_appointmentPriority(b));
|
||||
if (p != 0) return p;
|
||||
return b.endTime.compareTo(a.endTime);
|
||||
});
|
||||
|
||||
// Phase 1: greedy lane assignment, grouped by cluster.
|
||||
final clusters = <List<({Appointment apt, int lane})>>[];
|
||||
@@ -288,7 +310,8 @@ List<LaidOutCell> assignLanes(List<Appointment> appts, {required int maxLanes})
|
||||
|
||||
for (final apt in sorted) {
|
||||
final allFree =
|
||||
laneEnds.isNotEmpty && laneEnds.every((end) => !end.isAfter(apt.startTime));
|
||||
laneEnds.isNotEmpty &&
|
||||
laneEnds.every((end) => !end.isAfter(apt.startTime));
|
||||
if (allFree) {
|
||||
clusters.add(current);
|
||||
current = <({Appointment apt, int lane})>[];
|
||||
@@ -316,8 +339,10 @@ List<LaidOutCell> assignLanes(List<Appointment> appts, {required int maxLanes})
|
||||
// Phase 2: emit cells per cluster, collapsing if too wide.
|
||||
final result = <LaidOutCell>[];
|
||||
for (final cluster in clusters) {
|
||||
final laneCount =
|
||||
cluster.fold<int>(0, (m, e) => e.lane + 1 > m ? e.lane + 1 : m);
|
||||
final laneCount = cluster.fold<int>(
|
||||
0,
|
||||
(m, e) => e.lane + 1 > m ? e.lane + 1 : m,
|
||||
);
|
||||
|
||||
if (laneCount <= maxLanes) {
|
||||
for (final entry in cluster) {
|
||||
@@ -348,8 +373,9 @@ List<LaidOutCell> assignLanes(List<Appointment> appts, {required int maxLanes})
|
||||
if (a.startTime.isBefore(earliest)) earliest = a.startTime;
|
||||
if (a.endTime.isAfter(latest)) latest = a.endTime;
|
||||
}
|
||||
result.add(LaidOutOverflow(
|
||||
overflow, maxLanes - 1, maxLanes, earliest, latest));
|
||||
result.add(
|
||||
LaidOutOverflow(overflow, maxLanes - 1, maxLanes, earliest, latest),
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -17,8 +17,8 @@ class LessonPeriod {
|
||||
});
|
||||
|
||||
Duration get duration => Duration(
|
||||
minutes: (end.hour * 60 + end.minute) - (start.hour * 60 + start.minute),
|
||||
);
|
||||
minutes: (end.hour * 60 + end.minute) - (start.hour * 60 + start.minute),
|
||||
);
|
||||
|
||||
int get _startMinutes => start.hour * 60 + start.minute;
|
||||
}
|
||||
@@ -31,39 +31,94 @@ class LessonPeriodSchedule {
|
||||
static LessonPeriodSchedule? fromApi(GetTimegridUnitsResponse response) {
|
||||
final canonical = response.result.firstWhere(
|
||||
(d) => d.day == 1,
|
||||
orElse: () => response.result.isNotEmpty ? response.result.first : GetTimegridUnitsResponseDay(0, []),
|
||||
orElse: () => response.result.isNotEmpty
|
||||
? response.result.first
|
||||
: GetTimegridUnitsResponseDay(0, []),
|
||||
);
|
||||
if (canonical.timeUnits.isEmpty) return null;
|
||||
|
||||
final periods = canonical.timeUnits
|
||||
.map((u) => LessonPeriod(
|
||||
name: u.name,
|
||||
start: _fromHHMM(u.startTime),
|
||||
end: _fromHHMM(u.endTime),
|
||||
))
|
||||
.toList()
|
||||
..sort((a, b) => a._startMinutes.compareTo(b._startMinutes));
|
||||
final periods =
|
||||
canonical.timeUnits
|
||||
.map(
|
||||
(u) => LessonPeriod(
|
||||
name: u.name,
|
||||
start: _fromHHMM(u.startTime),
|
||||
end: _fromHHMM(u.endTime),
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) => a._startMinutes.compareTo(b._startMinutes));
|
||||
|
||||
return LessonPeriodSchedule(periods);
|
||||
}
|
||||
|
||||
static LessonPeriodSchedule fallback() => const LessonPeriodSchedule([
|
||||
LessonPeriod(name: '0', start: TimeOfDay(hour: 7, minute: 10), end: TimeOfDay(hour: 7, minute: 53)),
|
||||
LessonPeriod(name: '1', start: TimeOfDay(hour: 7, minute: 55), end: TimeOfDay(hour: 8, minute: 40)),
|
||||
LessonPeriod(name: '2', start: TimeOfDay(hour: 8, minute: 40), end: TimeOfDay(hour: 9, minute: 25)),
|
||||
LessonPeriod(name: '3', start: TimeOfDay(hour: 9, minute: 30), end: TimeOfDay(hour: 10, minute: 15)),
|
||||
LessonPeriod(name: '4', start: TimeOfDay(hour: 10, minute: 35), end: TimeOfDay(hour: 11, minute: 20)),
|
||||
LessonPeriod(name: '5', start: TimeOfDay(hour: 11, minute: 25), end: TimeOfDay(hour: 12, minute: 10)),
|
||||
LessonPeriod(name: '6', start: TimeOfDay(hour: 12, minute: 15), end: TimeOfDay(hour: 13, minute: 0)),
|
||||
LessonPeriod(name: '7', start: TimeOfDay(hour: 13, minute: 5), end: TimeOfDay(hour: 13, minute: 50)),
|
||||
LessonPeriod(name: '8', start: TimeOfDay(hour: 14, minute: 5), end: TimeOfDay(hour: 14, minute: 50)),
|
||||
LessonPeriod(name: '9', start: TimeOfDay(hour: 14, minute: 50), end: TimeOfDay(hour: 15, minute: 35)),
|
||||
LessonPeriod(name: '10', start: TimeOfDay(hour: 15, minute: 40), end: TimeOfDay(hour: 16, minute: 25)),
|
||||
LessonPeriod(name: '11', start: TimeOfDay(hour: 16, minute: 25), end: TimeOfDay(hour: 17, minute: 10)),
|
||||
]);
|
||||
LessonPeriod(
|
||||
name: '0',
|
||||
start: TimeOfDay(hour: 7, minute: 10),
|
||||
end: TimeOfDay(hour: 7, minute: 53),
|
||||
),
|
||||
LessonPeriod(
|
||||
name: '1',
|
||||
start: TimeOfDay(hour: 7, minute: 55),
|
||||
end: TimeOfDay(hour: 8, minute: 40),
|
||||
),
|
||||
LessonPeriod(
|
||||
name: '2',
|
||||
start: TimeOfDay(hour: 8, minute: 40),
|
||||
end: TimeOfDay(hour: 9, minute: 25),
|
||||
),
|
||||
LessonPeriod(
|
||||
name: '3',
|
||||
start: TimeOfDay(hour: 9, minute: 30),
|
||||
end: TimeOfDay(hour: 10, minute: 15),
|
||||
),
|
||||
LessonPeriod(
|
||||
name: '4',
|
||||
start: TimeOfDay(hour: 10, minute: 35),
|
||||
end: TimeOfDay(hour: 11, minute: 20),
|
||||
),
|
||||
LessonPeriod(
|
||||
name: '5',
|
||||
start: TimeOfDay(hour: 11, minute: 25),
|
||||
end: TimeOfDay(hour: 12, minute: 10),
|
||||
),
|
||||
LessonPeriod(
|
||||
name: '6',
|
||||
start: TimeOfDay(hour: 12, minute: 15),
|
||||
end: TimeOfDay(hour: 13, minute: 0),
|
||||
),
|
||||
LessonPeriod(
|
||||
name: '7',
|
||||
start: TimeOfDay(hour: 13, minute: 5),
|
||||
end: TimeOfDay(hour: 13, minute: 50),
|
||||
),
|
||||
LessonPeriod(
|
||||
name: '8',
|
||||
start: TimeOfDay(hour: 14, minute: 5),
|
||||
end: TimeOfDay(hour: 14, minute: 50),
|
||||
),
|
||||
LessonPeriod(
|
||||
name: '9',
|
||||
start: TimeOfDay(hour: 14, minute: 50),
|
||||
end: TimeOfDay(hour: 15, minute: 35),
|
||||
),
|
||||
LessonPeriod(
|
||||
name: '10',
|
||||
start: TimeOfDay(hour: 15, minute: 40),
|
||||
end: TimeOfDay(hour: 16, minute: 25),
|
||||
),
|
||||
LessonPeriod(
|
||||
name: '11',
|
||||
start: TimeOfDay(hour: 16, minute: 25),
|
||||
end: TimeOfDay(hour: 17, minute: 10),
|
||||
),
|
||||
]);
|
||||
|
||||
static LessonPeriodSchedule fromState(TimetableState state) {
|
||||
final fromApi = state.timegrid != null ? LessonPeriodSchedule.fromApi(state.timegrid!) : null;
|
||||
final fromApi = state.timegrid != null
|
||||
? LessonPeriodSchedule.fromApi(state.timegrid!)
|
||||
: null;
|
||||
return (fromApi ?? fallback()).withSyntheticBreaks();
|
||||
}
|
||||
|
||||
@@ -74,21 +129,22 @@ class LessonPeriodSchedule {
|
||||
result.add(current);
|
||||
if (i + 1 >= periods.length) continue;
|
||||
final next = periods[i + 1];
|
||||
final gapMinutes = next._startMinutes - (current.end.hour * 60 + current.end.minute);
|
||||
final gapMinutes =
|
||||
next._startMinutes - (current.end.hour * 60 + current.end.minute);
|
||||
if (gapMinutes >= 10) {
|
||||
result.add(LessonPeriod(
|
||||
name: 'Pause',
|
||||
start: current.end,
|
||||
end: next.start,
|
||||
isBreak: true,
|
||||
));
|
||||
result.add(
|
||||
LessonPeriod(
|
||||
name: 'Pause',
|
||||
start: current.end,
|
||||
end: next.start,
|
||||
isBreak: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return LessonPeriodSchedule(result);
|
||||
}
|
||||
|
||||
static TimeOfDay _fromHHMM(int hhmm) => TimeOfDay(
|
||||
hour: hhmm ~/ 100,
|
||||
minute: hhmm % 100,
|
||||
);
|
||||
static TimeOfDay _fromHHMM(int hhmm) =>
|
||||
TimeOfDay(hour: hhmm ~/ 100, minute: hhmm % 100);
|
||||
}
|
||||
|
||||
@@ -20,10 +20,17 @@ class LessonStatusClassifier {
|
||||
}) {
|
||||
if (lesson.code == 'cancelled') return LessonStatus.cancelled;
|
||||
if (isEvent) return LessonStatus.event;
|
||||
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 (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;
|
||||
if (startTime.isBefore(now) && endTime.isAfter(now)) {
|
||||
return LessonStatus.ongoing;
|
||||
}
|
||||
return LessonStatus.regular;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ class TimetableAppointmentFactory {
|
||||
});
|
||||
|
||||
List<Appointment> build() {
|
||||
final source = settings.connectDoubleLessons ? _mergeAdjacentLessons(lessons) : lessons;
|
||||
final source = settings.connectDoubleLessons
|
||||
? _mergeAdjacentLessons(lessons)
|
||||
: lessons;
|
||||
return [
|
||||
...source.map(_lessonToAppointment),
|
||||
...customEvents.map(_customEventToAppointment),
|
||||
@@ -42,7 +44,9 @@ class TimetableAppointmentFactory {
|
||||
try {
|
||||
final startTime = WebuntisTime.parse(lesson.date, lesson.startTime);
|
||||
final endTime = WebuntisTime.parse(lesson.date, lesson.endTime);
|
||||
final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id);
|
||||
final subject = subjects.result.firstWhereOrNull(
|
||||
(s) => s.id == lesson.su.firstOrNull?.id,
|
||||
);
|
||||
final status = LessonStatusClassifier.classify(
|
||||
lesson,
|
||||
startTime,
|
||||
@@ -81,16 +85,26 @@ class TimetableAppointmentFactory {
|
||||
id: CustomAppointment(event),
|
||||
startTime: event.startDate,
|
||||
endTime: allDay
|
||||
? DateTime(event.startDate.year, event.startDate.month, event.startDate.day, 23, 59)
|
||||
? DateTime(
|
||||
event.startDate.year,
|
||||
event.startDate.month,
|
||||
event.startDate.day,
|
||||
23,
|
||||
59,
|
||||
)
|
||||
: event.endDate,
|
||||
isAllDay: allDay,
|
||||
// Preserve user-entered newlines in descriptions; the tile soft-wraps to
|
||||
// fill the available height. For lessons we still collapse whitespace
|
||||
// so room/teacher stay on one line each.
|
||||
location: event.description.trim().isEmpty ? null : event.description.trim(),
|
||||
location: event.description.trim().isEmpty
|
||||
? null
|
||||
: event.description.trim(),
|
||||
subject: _collapseWhitespace(event.title) ?? event.title,
|
||||
recurrenceRule: event.rrule,
|
||||
color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name),
|
||||
color: TimetableColors.getColorFromString(
|
||||
event.color ?? TimetableColors.defaultColor.name,
|
||||
),
|
||||
startTimeZone: '',
|
||||
endTimeZone: '',
|
||||
);
|
||||
@@ -114,7 +128,10 @@ class TimetableAppointmentFactory {
|
||||
e.second == 0;
|
||||
}
|
||||
|
||||
String _subjectName(GetTimetableResponseObject lesson, GetSubjectsResponseObject? subject) {
|
||||
String _subjectName(
|
||||
GetTimetableResponseObject lesson,
|
||||
GetSubjectsResponseObject? subject,
|
||||
) {
|
||||
if (subject == null) return 'Event';
|
||||
final name = switch (settings.timetableNameMode) {
|
||||
TimetableNameMode.name => subject.name,
|
||||
@@ -125,10 +142,15 @@ class TimetableAppointmentFactory {
|
||||
}
|
||||
|
||||
String _locationLabel(GetTimetableResponseObject lesson) {
|
||||
final roomName = _collapseWhitespace(
|
||||
rooms.result.firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id)?.name) ??
|
||||
final roomName =
|
||||
_collapseWhitespace(
|
||||
rooms.result
|
||||
.firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id)
|
||||
?.name,
|
||||
) ??
|
||||
'Unbekannt';
|
||||
final teacherName = _collapseWhitespace(lesson.te.firstOrNull?.longname) ?? 'Unbekannt';
|
||||
final teacherName =
|
||||
_collapseWhitespace(lesson.te.firstOrNull?.longname) ?? 'Unbekannt';
|
||||
return '$roomName\n$teacherName';
|
||||
}
|
||||
|
||||
@@ -161,8 +183,13 @@ class TimetableAppointmentFactory {
|
||||
}) {
|
||||
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 sorted = [...input]
|
||||
..sort(
|
||||
(a, b) => WebuntisTime.parse(
|
||||
a.date,
|
||||
a.startTime,
|
||||
).compareTo(WebuntisTime.parse(b.date, b.startTime)),
|
||||
);
|
||||
|
||||
final merged = <GetTimetableResponseObject>[];
|
||||
for (final current in sorted) {
|
||||
@@ -180,10 +207,16 @@ class TimetableAppointmentFactory {
|
||||
static GetTimetableResponseObject _copyLesson(GetTimetableResponseObject l) =>
|
||||
GetTimetableResponseObject.fromJson(l.toJson());
|
||||
|
||||
static bool _canMerge(GetTimetableResponseObject a, GetTimetableResponseObject b, Duration maxGap) {
|
||||
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 (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;
|
||||
@@ -193,7 +226,10 @@ class TimetableAppointmentFactory {
|
||||
// overlap in time would silently collapse into one — and because the
|
||||
// merge sets `previous.endTime = current.endTime`, an overlapping merge
|
||||
// can even truncate the earlier lesson.
|
||||
final gap = WebuntisTime.parse(b.date, b.startTime).difference(WebuntisTime.parse(a.date, a.endTime));
|
||||
final gap = WebuntisTime.parse(
|
||||
b.date,
|
||||
b.startTime,
|
||||
).difference(WebuntisTime.parse(a.date, a.endTime));
|
||||
return !gap.isNegative && gap <= maxGap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,20 @@ class TimetableNameModes {
|
||||
static DropdownDisplay getDisplayOptions(TimetableNameMode mode) {
|
||||
switch (mode) {
|
||||
case TimetableNameMode.name:
|
||||
return DropdownDisplay(icon: Icons.device_unknown_outlined, displayName: 'Name');
|
||||
return DropdownDisplay(
|
||||
icon: Icons.device_unknown_outlined,
|
||||
displayName: 'Name',
|
||||
);
|
||||
case TimetableNameMode.longName:
|
||||
return DropdownDisplay(icon: Icons.perm_device_info_outlined, displayName: 'Langname');
|
||||
return DropdownDisplay(
|
||||
icon: Icons.perm_device_info_outlined,
|
||||
displayName: 'Langname',
|
||||
);
|
||||
case TimetableNameMode.alternateName:
|
||||
return DropdownDisplay(icon: Icons.on_device_training_outlined, displayName: 'Kurzform');
|
||||
return DropdownDisplay(
|
||||
icon: Icons.on_device_training_outlined,
|
||||
displayName: 'Kurzform',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ class WebuntisTime {
|
||||
|
||||
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)}');
|
||||
return DateTime.parse(
|
||||
'$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}',
|
||||
);
|
||||
}
|
||||
|
||||
static int formatDate(DateTime date) => int.parse(_dateFormat.format(date));
|
||||
|
||||
@@ -7,12 +7,17 @@ import 'custom_event_sheet.dart';
|
||||
import 'webuntis_lesson_sheet.dart';
|
||||
|
||||
class AppointmentDetailsDispatcher {
|
||||
static void show(BuildContext context, TimetableBloc bloc, Appointment appointment) {
|
||||
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),
|
||||
webuntis: (lesson) =>
|
||||
WebuntisLessonSheet.show(context, bloc, appointment, lesson),
|
||||
custom: (event) => CustomEventSheet.show(context, event),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ class CustomEventSheet {
|
||||
context,
|
||||
header: ListTile(
|
||||
leading: const Icon(Icons.event_outlined, size: 32),
|
||||
title: Text(event.title, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
title: Text(
|
||||
event.title,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(timeRange),
|
||||
),
|
||||
children: (sheetCtx) => [
|
||||
@@ -31,7 +34,8 @@ class CustomEventSheet {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => CustomEventEditDialog(existingEvent: event),
|
||||
builder: (_) =>
|
||||
CustomEventEditDialog(existingEvent: event),
|
||||
);
|
||||
},
|
||||
label: const Text('Bearbeiten'),
|
||||
@@ -39,7 +43,9 @@ class CustomEventSheet {
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
showDeleteCustomEventDialog(context, event).future.then((_) {
|
||||
showDeleteCustomEventDialog(context, event).future.then((
|
||||
_,
|
||||
) {
|
||||
if (!sheetCtx.mounted) return;
|
||||
Navigator.of(sheetCtx).pop();
|
||||
});
|
||||
@@ -54,18 +60,28 @@ class CustomEventSheet {
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: Text(event.description.isEmpty ? 'Keine Beschreibung' : event.description),
|
||||
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"}'),
|
||||
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 (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.');
|
||||
if (!rrule.canFullyConvertToText) {
|
||||
return const Text('Keine genauere Angabe möglich.');
|
||||
}
|
||||
return Text(rrule.toText(l10n: snapshot.data!));
|
||||
},
|
||||
),
|
||||
|
||||
@@ -7,12 +7,16 @@ import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
|
||||
Completer<void> showDeleteCustomEventDialog(BuildContext context, CustomTimetableEvent event) {
|
||||
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.',
|
||||
content:
|
||||
'Der ${event.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.',
|
||||
confirmButton: 'Löschen',
|
||||
onConfirmAsync: () async {
|
||||
await bloc.removeCustomEvent(event.id);
|
||||
|
||||
@@ -14,13 +14,30 @@ import '../../../../widget/details_bottom_sheet.dart';
|
||||
import '../../../../widget/unimplemented_dialog.dart';
|
||||
|
||||
class WebuntisLessonSheet {
|
||||
static void show(BuildContext context, TimetableBloc bloc, Appointment appointment, GetTimetableResponseObject lesson) {
|
||||
static void show(
|
||||
BuildContext context,
|
||||
TimetableBloc bloc,
|
||||
Appointment appointment,
|
||||
GetTimetableResponseObject lesson,
|
||||
) {
|
||||
final state = bloc.state.data;
|
||||
if (state == null) return;
|
||||
|
||||
final headerSubject = LessonResolver.resolveSubject(state, lesson.su.firstOrNull?.id);
|
||||
final headerTitle = firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']);
|
||||
final headerLongName = headerSubject.longName.isNotEmpty && headerSubject.longName != headerTitle ? headerSubject.longName : '';
|
||||
final headerSubject = LessonResolver.resolveSubject(
|
||||
state,
|
||||
lesson.su.firstOrNull?.id,
|
||||
);
|
||||
final headerTitle = firstNonEmpty([
|
||||
headerSubject.alternateName,
|
||||
headerSubject.name,
|
||||
headerSubject.longName,
|
||||
'?',
|
||||
]);
|
||||
final headerLongName =
|
||||
headerSubject.longName.isNotEmpty &&
|
||||
headerSubject.longName != headerTitle
|
||||
? headerSubject.longName
|
||||
: '';
|
||||
|
||||
final timeRange = appointment.startTime.timeRangeTo(appointment.endTime);
|
||||
|
||||
@@ -32,9 +49,9 @@ class WebuntisLessonSheet {
|
||||
'${LessonFormatter.codePrefix(lesson.code)}$headerTitle',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(headerLongName.isNotEmpty
|
||||
? '$timeRange\n$headerLongName'
|
||||
: timeRange),
|
||||
subtitle: Text(
|
||||
headerLongName.isNotEmpty ? '$timeRange\n$headerLongName' : timeRange,
|
||||
),
|
||||
isThreeLine: headerLongName.isNotEmpty,
|
||||
),
|
||||
children: (_) => <Widget>[
|
||||
@@ -66,10 +83,12 @@ class WebuntisLessonSheet {
|
||||
icon: Icons.people,
|
||||
label: lesson.kl.length == 1 ? 'Klasse' : 'Klassen',
|
||||
entries: lesson.kl
|
||||
.map((k) => LessonFormatter.formatLine(
|
||||
k.name.isNotEmpty ? k.name : '?',
|
||||
longname: k.longname,
|
||||
))
|
||||
.map(
|
||||
(k) => LessonFormatter.formatLine(
|
||||
k.name.isNotEmpty ? k.name : '?',
|
||||
longname: k.longname,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
..._optionalTextTiles(lesson),
|
||||
@@ -78,7 +97,11 @@ class WebuntisLessonSheet {
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _roomTile(BuildContext context, TimetableState state, GetTimetableResponseObject lesson) {
|
||||
static Widget _roomTile(
|
||||
BuildContext context,
|
||||
TimetableState state,
|
||||
GetTimetableResponseObject lesson,
|
||||
) {
|
||||
final trailing = IconButton(
|
||||
icon: const Icon(Icons.house_outlined),
|
||||
onPressed: () => AppRoutes.openRoomplan(context),
|
||||
@@ -112,7 +135,10 @@ class WebuntisLessonSheet {
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _teacherTile(BuildContext context, GetTimetableResponseObject lesson) {
|
||||
static Widget _teacherTile(
|
||||
BuildContext context,
|
||||
GetTimetableResponseObject lesson,
|
||||
) {
|
||||
final trailing = Visibility(
|
||||
visible: !kReleaseMode,
|
||||
child: IconButton(
|
||||
|
||||
@@ -27,7 +27,8 @@ class Timetable extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TimetableState extends State<Timetable> {
|
||||
final GlobalKey<CustomWorkWeekCalendarState> _calendarKey = GlobalKey<CustomWorkWeekCalendarState>();
|
||||
final GlobalKey<CustomWorkWeekCalendarState> _calendarKey =
|
||||
GlobalKey<CustomWorkWeekCalendarState>();
|
||||
|
||||
List<Appointment>? _cachedAppointments;
|
||||
int? _lastDataVersion;
|
||||
@@ -53,7 +54,10 @@ class _TimetableState extends State<Timetable> {
|
||||
}
|
||||
|
||||
List<Appointment> _appointments(TimetableState state) {
|
||||
final timetableSettings = context.watch<SettingsCubit>().val().timetableSettings;
|
||||
final timetableSettings = context
|
||||
.watch<SettingsCubit>()
|
||||
.val()
|
||||
.timetableSettings;
|
||||
if (_cachedAppointments != null &&
|
||||
_lastDataVersion == state.dataVersion &&
|
||||
identical(_lastTimetableSettings, timetableSettings)) {
|
||||
@@ -81,7 +85,11 @@ class _TimetableState extends State<Timetable> {
|
||||
bool _isOnInitialWeek(TimetableState state) {
|
||||
final target = _initialDisplayDate();
|
||||
final targetMonday = target.subtract(Duration(days: target.weekday - 1));
|
||||
final mondayOnly = DateTime(targetMonday.year, targetMonday.month, targetMonday.day);
|
||||
final mondayOnly = DateTime(
|
||||
targetMonday.year,
|
||||
targetMonday.month,
|
||||
targetMonday.day,
|
||||
);
|
||||
return state.startDate == mondayOnly;
|
||||
}
|
||||
|
||||
@@ -105,7 +113,10 @@ class _TimetableState extends State<Timetable> {
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(
|
||||
value: _CalendarAction.addEvent,
|
||||
child: ListTile(title: Text('Kalendereintrag hinzufügen'), leading: Icon(Icons.add)),
|
||||
child: ListTile(
|
||||
title: Text('Kalendereintrag hinzufügen'),
|
||||
leading: Icon(Icons.add),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _CalendarAction.viewEvents,
|
||||
@@ -142,9 +153,14 @@ class _TimetableState extends State<Timetable> {
|
||||
appointments: appointments,
|
||||
timeRegions: regions,
|
||||
initialDate: _initialDisplayDate(),
|
||||
minDate: DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.sunday),
|
||||
maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday),
|
||||
onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show(context, bloc, apt),
|
||||
minDate: DateTime.now()
|
||||
.subtract(const Duration(days: 14))
|
||||
.nextWeekday(DateTime.sunday),
|
||||
maxDate: DateTime.now()
|
||||
.add(const Duration(days: 7))
|
||||
.nextWeekday(DateTime.saturday),
|
||||
onAppointmentTap: (apt) =>
|
||||
AppointmentDetailsDispatcher.show(context, bloc, apt),
|
||||
onWeekChanged: (start, end) => bloc.changeWeek(start, end),
|
||||
isCrossedOut: _isCrossedOut,
|
||||
onCreateEvent: _onCreateEventAt,
|
||||
@@ -154,7 +170,8 @@ class _TimetableState extends State<Timetable> {
|
||||
void _onCreateEventAt(DateTime start, DateTime end) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => CustomEventEditDialog(initialStart: start, initialEnd: end),
|
||||
builder: (_) =>
|
||||
CustomEventEditDialog(initialStart: start, initialEnd: end),
|
||||
barrierDismissible: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@ class AppointmentTile extends StatelessWidget {
|
||||
final Appointment appointment;
|
||||
final bool crossedOut;
|
||||
|
||||
const AppointmentTile({super.key, required this.appointment, this.crossedOut = false});
|
||||
const AppointmentTile({
|
||||
super.key,
|
||||
required this.appointment,
|
||||
this.crossedOut = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -56,11 +60,15 @@ class AppointmentTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
for (final line in description
|
||||
.split('\n')
|
||||
.where((p) => p.isNotEmpty)
|
||||
.take(2))
|
||||
_ScaledLine(text: line, fontSize: kAppointmentBodyFontSize),
|
||||
for (final line
|
||||
in description
|
||||
.split('\n')
|
||||
.where((p) => p.isNotEmpty)
|
||||
.take(2))
|
||||
_ScaledLine(
|
||||
text: line,
|
||||
fontSize: kAppointmentBodyFontSize,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -72,7 +80,10 @@ class AppointmentTile extends StatelessWidget {
|
||||
borderRadius: _radius,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(width: 2, color: Colors.red.withAlpha(200)),
|
||||
border: Border.all(
|
||||
width: 2,
|
||||
color: Colors.red.withAlpha(200),
|
||||
),
|
||||
borderRadius: _radius,
|
||||
),
|
||||
child: CustomPaint(painter: CrossPainter()),
|
||||
@@ -114,7 +125,10 @@ class _AdaptiveTitle extends StatelessWidget {
|
||||
builder: (context, constraints) {
|
||||
// Probe at the minimum size: if even that overflows, we have to ellipsize.
|
||||
final probe = TextPainter(
|
||||
text: TextSpan(text: text, style: baseStyle.copyWith(fontSize: minFontSize)),
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: baseStyle.copyWith(fontSize: minFontSize),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: 1,
|
||||
textScaler: textScaler,
|
||||
@@ -131,12 +145,7 @@ class _AdaptiveTitle extends StatelessWidget {
|
||||
return FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
text,
|
||||
style: baseStyle,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
child: Text(text, style: baseStyle, maxLines: 1, softWrap: false),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -187,24 +196,17 @@ class _ScaledLine extends StatelessWidget {
|
||||
final String text;
|
||||
final double fontSize;
|
||||
|
||||
const _ScaledLine({
|
||||
required this.text,
|
||||
required this.fontSize,
|
||||
});
|
||||
const _ScaledLine({required this.text, required this.fontSize});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: fontSize,
|
||||
height: 1.1,
|
||||
),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
);
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(color: Colors.white, fontSize: fontSize, height: 1.1),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,17 +14,17 @@ class _DayHeaderStrip extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
children: [
|
||||
SizedBox(width: rulerWidth),
|
||||
for (var d = 0; d < 5; d++)
|
||||
Expanded(
|
||||
child: _DayHeaderCell(
|
||||
date: weekStart.add(Duration(days: d)),
|
||||
today: today,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
children: [
|
||||
SizedBox(width: rulerWidth),
|
||||
for (var d = 0; d < 5; d++)
|
||||
Expanded(
|
||||
child: _DayHeaderCell(
|
||||
date: weekStart.add(Duration(days: d)),
|
||||
today: today,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _DayHeaderCell extends StatelessWidget {
|
||||
@@ -37,7 +37,10 @@ class _DayHeaderCell extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isToday = date.isSameDay(today);
|
||||
final dayName = DateFormat('EE', Localizations.localeOf(context).toString()).format(date).toUpperCase();
|
||||
final dayName = DateFormat(
|
||||
'EE',
|
||||
Localizations.localeOf(context).toString(),
|
||||
).format(date).toUpperCase();
|
||||
|
||||
final accent = theme.colorScheme.primary;
|
||||
final onAccent = theme.colorScheme.onPrimary;
|
||||
|
||||
@@ -18,20 +18,30 @@ class _OutsideHoursStrip extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final outside = partitionAppointmentsForWeek(appointments, weekStart).outside;
|
||||
final outside = partitionAppointmentsForWeek(
|
||||
appointments,
|
||||
weekStart,
|
||||
).outside;
|
||||
if (outside.every((day) => day.isEmpty)) return const SizedBox.shrink();
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final maxChipsPerDay = outside
|
||||
.map((day) => day.length > kOutsideChipsMaxVisible ? kOutsideChipsMaxVisible : day.length)
|
||||
.map(
|
||||
(day) => day.length > kOutsideChipsMaxVisible
|
||||
? kOutsideChipsMaxVisible
|
||||
: day.length,
|
||||
)
|
||||
.fold<int>(0, (m, c) => c > m ? c : m);
|
||||
final stripHeight = kOutsideStripVerticalPadding * 2 +
|
||||
final stripHeight =
|
||||
kOutsideStripVerticalPadding * 2 +
|
||||
maxChipsPerDay * kOutsideChipHeight +
|
||||
(maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * kOutsideChipSpacing : 0);
|
||||
|
||||
return Container(
|
||||
color: theme.colorScheme.surfaceContainerLowest,
|
||||
padding: const EdgeInsets.symmetric(vertical: kOutsideStripVerticalPadding),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: kOutsideStripVerticalPadding,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: stripHeight - kOutsideStripVerticalPadding * 2,
|
||||
child: Row(
|
||||
@@ -72,27 +82,29 @@ class _OutsideDayColumn extends StatelessWidget {
|
||||
for (var i = 0; i < hidden.length; i++) {
|
||||
if (i > 0) tiles.add(const Divider(height: 1));
|
||||
final apt = hidden[i];
|
||||
tiles.add(ListTile(
|
||||
leading: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: apt.color,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
tiles.add(
|
||||
ListTile(
|
||||
leading: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: apt.color,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
apt.subject,
|
||||
style: isCrossedOut(apt)
|
||||
? const TextStyle(decoration: TextDecoration.lineThrough)
|
||||
: null,
|
||||
),
|
||||
subtitle: Text(_subtitleFor(apt)),
|
||||
onTap: () {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
onAppointmentTap(apt);
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
apt.subject,
|
||||
style: isCrossedOut(apt)
|
||||
? const TextStyle(decoration: TextDecoration.lineThrough)
|
||||
: null,
|
||||
),
|
||||
subtitle: Text(_subtitleFor(apt)),
|
||||
onTap: () {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
onAppointmentTap(apt);
|
||||
},
|
||||
));
|
||||
);
|
||||
}
|
||||
return tiles;
|
||||
},
|
||||
|
||||
@@ -34,11 +34,7 @@ class _WeekGrid extends StatelessWidget {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_PeriodRuler(
|
||||
schedule: schedule,
|
||||
layout: layout,
|
||||
width: rulerWidth,
|
||||
),
|
||||
_PeriodRuler(schedule: schedule, layout: layout, width: rulerWidth),
|
||||
for (var d = 0; d < 5; d++)
|
||||
Expanded(
|
||||
child: _DayColumn(
|
||||
@@ -112,7 +108,11 @@ class _PeriodLabel extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(Icons.coffee_outlined, size: 12, color: secondaryTextColor.withAlpha(180)),
|
||||
child: Icon(
|
||||
Icons.coffee_outlined,
|
||||
size: 12,
|
||||
color: secondaryTextColor.withAlpha(180),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -207,27 +207,49 @@ class _DayColumn extends StatelessWidget {
|
||||
required this.onCreateEvent,
|
||||
});
|
||||
|
||||
bool _overlapsExistingAppointment(DateTime start, DateTime end, List<Appointment> dayAppts) {
|
||||
bool _overlapsExistingAppointment(
|
||||
DateTime start,
|
||||
DateTime end,
|
||||
List<Appointment> dayAppts,
|
||||
) {
|
||||
for (final a in dayAppts) {
|
||||
if (a.endTime.isAfter(start) && a.startTime.isBefore(end)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _handleLongPress(LongPressStartDetails details, List<Appointment> dayAppts) {
|
||||
void _handleLongPress(
|
||||
LongPressStartDetails details,
|
||||
List<Appointment> dayAppts,
|
||||
) {
|
||||
if (onCreateEvent == null) return;
|
||||
final period = layout.periodAtY(details.localPosition.dy);
|
||||
if (period == null) return;
|
||||
|
||||
final start = DateTime(date.year, date.month, date.day, period.start.hour, period.start.minute);
|
||||
final end = DateTime(date.year, date.month, date.day, period.end.hour, period.end.minute);
|
||||
final start = DateTime(
|
||||
date.year,
|
||||
date.month,
|
||||
date.day,
|
||||
period.start.hour,
|
||||
period.start.minute,
|
||||
);
|
||||
final end = DateTime(
|
||||
date.year,
|
||||
date.month,
|
||||
date.day,
|
||||
period.end.hour,
|
||||
period.end.minute,
|
||||
);
|
||||
if (_overlapsExistingAppointment(start, end, dayAppts)) return;
|
||||
|
||||
HapticFeedback.mediumImpact();
|
||||
onCreateEvent!(start, end);
|
||||
}
|
||||
|
||||
void _showOverflowSheet(BuildContext context, List<Appointment> appointments) {
|
||||
void _showOverflowSheet(
|
||||
BuildContext context,
|
||||
List<Appointment> appointments,
|
||||
) {
|
||||
final sorted = [...appointments]
|
||||
..sort((a, b) => a.startTime.compareTo(b.startTime));
|
||||
showDetailsBottomSheet(
|
||||
@@ -237,27 +259,29 @@ class _DayColumn extends StatelessWidget {
|
||||
for (var i = 0; i < sorted.length; i++) {
|
||||
if (i > 0) tiles.add(const Divider(height: 1));
|
||||
final apt = sorted[i];
|
||||
tiles.add(ListTile(
|
||||
leading: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: apt.color,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
tiles.add(
|
||||
ListTile(
|
||||
leading: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: apt.color,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
apt.subject,
|
||||
style: isCrossedOut(apt)
|
||||
? const TextStyle(decoration: TextDecoration.lineThrough)
|
||||
: null,
|
||||
),
|
||||
subtitle: Text(_overflowSubtitle(apt)),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
onAppointmentTap(apt);
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
apt.subject,
|
||||
style: isCrossedOut(apt)
|
||||
? const TextStyle(decoration: TextDecoration.lineThrough)
|
||||
: null,
|
||||
),
|
||||
subtitle: Text(_overflowSubtitle(apt)),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
onAppointmentTap(apt);
|
||||
},
|
||||
));
|
||||
);
|
||||
}
|
||||
return tiles;
|
||||
},
|
||||
@@ -288,46 +312,53 @@ class _DayColumn extends StatelessWidget {
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onLongPressStart: (details) => _handleLongPress(details, dayAppointments),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: isToday ? theme.colorScheme.primary.withAlpha(14) : null,
|
||||
border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
for (final period in schedule.periods)
|
||||
Positioned(
|
||||
top: layout.topOf(period),
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
height: 0.5,
|
||||
color: theme.dividerColor.withAlpha(60),
|
||||
decoration: BoxDecoration(
|
||||
color: isToday ? theme.colorScheme.primary.withAlpha(14) : null,
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: theme.dividerColor.withAlpha(90),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
for (final period in schedule.periods)
|
||||
Positioned(
|
||||
top: layout.topOf(period),
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
height: 0.5,
|
||||
color: theme.dividerColor.withAlpha(60),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final region in dayRegions)
|
||||
Positioned(
|
||||
top: layout.yOfDateTime(region.start),
|
||||
height: (layout.yOfDateTime(region.end) -
|
||||
layout.yOfDateTime(region.start))
|
||||
.clamp(0, double.infinity),
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: TimeRegionTile(region: region.region),
|
||||
),
|
||||
for (final cell in laidOut)
|
||||
Positioned(
|
||||
top: layout.yOfDateTime(cell.startTime),
|
||||
height: (layout.yOfDateTime(cell.endTime) -
|
||||
layout.yOfDateTime(cell.startTime))
|
||||
.clamp(0, double.infinity),
|
||||
left: cell.lane * width / cell.laneCount,
|
||||
width: width / cell.laneCount,
|
||||
child: switch (cell) {
|
||||
LaidOutAppointment(:final appointment) => GestureDetector(
|
||||
for (final region in dayRegions)
|
||||
Positioned(
|
||||
top: layout.yOfDateTime(region.start),
|
||||
height:
|
||||
(layout.yOfDateTime(region.end) -
|
||||
layout.yOfDateTime(region.start))
|
||||
.clamp(0, double.infinity),
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: TimeRegionTile(region: region.region),
|
||||
),
|
||||
for (final cell in laidOut)
|
||||
Positioned(
|
||||
top: layout.yOfDateTime(cell.startTime),
|
||||
height:
|
||||
(layout.yOfDateTime(cell.endTime) -
|
||||
layout.yOfDateTime(cell.startTime))
|
||||
.clamp(0, double.infinity),
|
||||
left: cell.lane * width / cell.laneCount,
|
||||
width: width / cell.laneCount,
|
||||
child: switch (cell) {
|
||||
LaidOutAppointment(:final appointment) => GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => onAppointmentTap(appointment),
|
||||
child: AppointmentTile(
|
||||
@@ -335,25 +366,27 @@ class _DayColumn extends StatelessWidget {
|
||||
crossedOut: isCrossedOut(appointment),
|
||||
),
|
||||
),
|
||||
LaidOutOverflow(:final appointments) => GestureDetector(
|
||||
LaidOutOverflow(:final appointments) => GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () =>
|
||||
_showOverflowSheet(context, appointments),
|
||||
onTap: () => _showOverflowSheet(context, appointments),
|
||||
child: _OverflowTile(count: appointments.length),
|
||||
),
|
||||
},
|
||||
),
|
||||
if (isToday)
|
||||
ValueListenableBuilder<DateTime>(
|
||||
valueListenable: nowNotifier,
|
||||
builder: (_, now, child) =>
|
||||
_CurrentTimeMarker(now: now, layout: layout, theme: theme),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
if (isToday)
|
||||
ValueListenableBuilder<DateTime>(
|
||||
valueListenable: nowNotifier,
|
||||
builder: (_, now, child) => _CurrentTimeMarker(
|
||||
now: now,
|
||||
layout: layout,
|
||||
theme: theme,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -376,8 +409,7 @@ class _CurrentTimeMarker extends StatelessWidget {
|
||||
final tMin = now.hour * 60 + now.minute;
|
||||
final firstStart =
|
||||
periods.first.start.hour * 60 + periods.first.start.minute;
|
||||
final lastEnd =
|
||||
periods.last.end.hour * 60 + periods.last.end.minute;
|
||||
final lastEnd = periods.last.end.hour * 60 + periods.last.end.minute;
|
||||
if (tMin < firstStart || tMin > lastEnd) return const SizedBox.shrink();
|
||||
|
||||
final y = layout.yOfDateTime(now);
|
||||
@@ -392,10 +424,7 @@ class _CurrentTimeMarker extends StatelessWidget {
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
height: 2,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
Container(height: 2, color: theme.colorScheme.primary),
|
||||
Positioned(
|
||||
top: -3,
|
||||
left: -4,
|
||||
@@ -456,7 +485,10 @@ class _OverflowTile extends StatelessWidget {
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
|
||||
@@ -72,7 +72,8 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||
_firstMonday = _mondayOf(widget.minDate);
|
||||
final lastMonday = _mondayOf(widget.maxDate);
|
||||
_totalWeeks = lastMonday.difference(_firstMonday).inDays ~/ 7 + 1;
|
||||
_currentWeekIndex = _mondayOf(widget.initialDate).difference(_firstMonday).inDays ~/ 7;
|
||||
_currentWeekIndex =
|
||||
_mondayOf(widget.initialDate).difference(_firstMonday).inDays ~/ 7;
|
||||
_pageController = PageController(initialPage: _currentWeekIndex);
|
||||
_nowNotifier = ValueNotifier<DateTime>(DateTime.now());
|
||||
|
||||
@@ -113,7 +114,9 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final visibleWeekStart = _firstMonday.add(Duration(days: _currentWeekIndex * 7));
|
||||
final visibleWeekStart = _firstMonday.add(
|
||||
Duration(days: _currentWeekIndex * 7),
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -168,13 +171,13 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final periods = widget.schedule.periods;
|
||||
final lessonCount =
|
||||
periods.where((p) => !p.isBreak).length;
|
||||
final lessonCount = periods.where((p) => !p.isBreak).length;
|
||||
final breakCount = periods.length - lessonCount;
|
||||
final available =
|
||||
constraints.maxHeight - breakCount * kBreakBlockHeight;
|
||||
final fitLessonH =
|
||||
lessonCount > 0 ? available / lessonCount : kLessonBlockMinHeight;
|
||||
final fitLessonH = lessonCount > 0
|
||||
? available / lessonCount
|
||||
: kLessonBlockMinHeight;
|
||||
final lessonH = fitLessonH < kLessonBlockMinHeight
|
||||
? kLessonBlockMinHeight
|
||||
: fitLessonH;
|
||||
@@ -194,11 +197,18 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||
itemCount: _totalWeeks,
|
||||
onPageChanged: (index) {
|
||||
setState(() => _currentWeekIndex = index);
|
||||
final weekStart = _firstMonday.add(Duration(days: index * 7));
|
||||
widget.onWeekChanged(weekStart, weekStart.add(const Duration(days: 4)));
|
||||
final weekStart = _firstMonday.add(
|
||||
Duration(days: index * 7),
|
||||
);
|
||||
widget.onWeekChanged(
|
||||
weekStart,
|
||||
weekStart.add(const Duration(days: 4)),
|
||||
);
|
||||
},
|
||||
itemBuilder: (_, weekIndex) {
|
||||
final weekStart = _firstMonday.add(Duration(days: weekIndex * 7));
|
||||
final weekStart = _firstMonday.add(
|
||||
Duration(days: weekIndex * 7),
|
||||
);
|
||||
return _WeekGrid(
|
||||
weekStart: weekStart,
|
||||
schedule: widget.schedule,
|
||||
|
||||
@@ -22,45 +22,61 @@ class SpecialRegionsBuilder {
|
||||
});
|
||||
|
||||
List<TimeRegion> build() {
|
||||
final lastMonday = DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.monday);
|
||||
final lastMonday = DateTime.now()
|
||||
.subtract(const Duration(days: 14))
|
||||
.nextWeekday(DateTime.monday);
|
||||
|
||||
final holidayRegions = _buildHolidayRegions().toList();
|
||||
bool isInHoliday(DateTime time) => holidayRegions.any((region) => region.startTime.isSameDay(time));
|
||||
bool isInHoliday(DateTime time) =>
|
||||
holidayRegions.any((region) => region.startTime.isSameDay(time));
|
||||
|
||||
final breakRegions = schedule.periods.where((p) => p.isBreak).map((p) {
|
||||
final start = lastMonday.copyWith(hour: p.start.hour, minute: p.start.minute);
|
||||
return _breakRegion(start, p.duration);
|
||||
}).where((region) => !isInHoliday(region.startTime));
|
||||
final breakRegions = schedule.periods
|
||||
.where((p) => p.isBreak)
|
||||
.map((p) {
|
||||
final start = lastMonday.copyWith(
|
||||
hour: p.start.hour,
|
||||
minute: p.start.minute,
|
||||
);
|
||||
return _breakRegion(start, p.duration);
|
||||
})
|
||||
.where((region) => !isInHoliday(region.startTime));
|
||||
|
||||
return [
|
||||
...holidayRegions,
|
||||
...breakRegions,
|
||||
];
|
||||
return [...holidayRegions, ...breakRegions];
|
||||
}
|
||||
|
||||
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)));
|
||||
final gridStartHour = kCalendarStartHour.floor();
|
||||
final gridStartMinute = ((kCalendarStartHour - gridStartHour) * 60).round();
|
||||
final gridEndHour = kCalendarEndHour.floor();
|
||||
final gridEndMinute = ((kCalendarEndHour - gridEndHour) * 60).round();
|
||||
return days.map((day) => TimeRegion(
|
||||
startTime: day.copyWith(hour: gridStartHour, minute: gridStartMinute),
|
||||
endTime: day.copyWith(hour: gridEndHour, minute: gridEndMinute),
|
||||
text: '$kTimeRegionHolidayPrefix${holiday.name}',
|
||||
color: disabledColor.withAlpha(50),
|
||||
iconData: Icons.holiday_village_outlined,
|
||||
));
|
||||
});
|
||||
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)),
|
||||
);
|
||||
final gridStartHour = kCalendarStartHour.floor();
|
||||
final gridStartMinute = ((kCalendarStartHour - gridStartHour) * 60).round();
|
||||
final gridEndHour = kCalendarEndHour.floor();
|
||||
final gridEndMinute = ((kCalendarEndHour - gridEndHour) * 60).round();
|
||||
return days.map(
|
||||
(day) => TimeRegion(
|
||||
startTime: day.copyWith(hour: gridStartHour, minute: gridStartMinute),
|
||||
endTime: day.copyWith(hour: gridEndHour, minute: gridEndMinute),
|
||||
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,
|
||||
);
|
||||
startTime: start,
|
||||
endTime: start.add(duration),
|
||||
recurrenceRule: 'FREQ=DAILY;INTERVAL=1',
|
||||
text: kTimeRegionCenterIcon,
|
||||
color: colorScheme.primary.withAlpha(50),
|
||||
iconData: Icons.restaurant,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,11 @@ class TimeRegionTile extends StatelessWidget {
|
||||
return Container(
|
||||
color: color,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(region.iconData, size: 17, color: Theme.of(context).colorScheme.primary),
|
||||
child: Icon(
|
||||
region.iconData,
|
||||
size: 17,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user