dart format

This commit is contained in:
2026-05-08 20:12:40 +02:00
parent 9e139b5704
commit 3b8da1d3d6
295 changed files with 6404 additions and 4161 deletions
@@ -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(
+25 -8
View File
@@ -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,
),
);
}