implemented dynamic module settings and configurable bottom bar, added all-day event support to timetable, and overhauled marianum dates UI with month grouping and search
This commit is contained in:
@@ -43,7 +43,7 @@ class CustomWorkWeekCalendar extends StatefulWidget {
|
||||
}
|
||||
|
||||
class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||
static const double _rulerWidth = 50;
|
||||
static const double _rulerWidth = 36;
|
||||
|
||||
late PageController _pageController;
|
||||
late int _currentWeekIndex;
|
||||
@@ -128,6 +128,28 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||
),
|
||||
),
|
||||
),
|
||||
ClipRect(
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 320),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.topCenter,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 280),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, animation) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
child: _OutsideHoursStrip(
|
||||
key: ValueKey(visibleWeekStart),
|
||||
weekStart: visibleWeekStart,
|
||||
appointments: widget.appointments,
|
||||
rulerWidth: _rulerWidth,
|
||||
onAppointmentTap: widget.onAppointmentTap,
|
||||
isCrossedOut: widget.isCrossedOut,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(height: 0.5, color: theme.dividerColor.withAlpha(110)),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
@@ -189,6 +211,271 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||
}
|
||||
}
|
||||
|
||||
class _OutsideHoursStrip extends StatelessWidget {
|
||||
static const int _maxVisibleChips = 2;
|
||||
static const double _chipHeight = 22;
|
||||
static const double _chipSpacing = 3;
|
||||
static const double _verticalPadding = 3;
|
||||
|
||||
final DateTime weekStart;
|
||||
final List<Appointment> appointments;
|
||||
final double rulerWidth;
|
||||
final void Function(Appointment) onAppointmentTap;
|
||||
final bool Function(Appointment) isCrossedOut;
|
||||
|
||||
const _OutsideHoursStrip({
|
||||
super.key,
|
||||
required this.weekStart,
|
||||
required this.appointments,
|
||||
required this.rulerWidth,
|
||||
required this.onAppointmentTap,
|
||||
required this.isCrossedOut,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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 > _maxVisibleChips ? _maxVisibleChips : day.length)
|
||||
.fold<int>(0, (m, c) => c > m ? c : m);
|
||||
final stripHeight = _verticalPadding * 2 +
|
||||
maxChipsPerDay * _chipHeight +
|
||||
(maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * _chipSpacing : 0);
|
||||
|
||||
return Container(
|
||||
color: theme.colorScheme.surfaceContainerLowest,
|
||||
padding: const EdgeInsets.symmetric(vertical: _verticalPadding),
|
||||
child: SizedBox(
|
||||
height: stripHeight - _verticalPadding * 2,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: rulerWidth),
|
||||
for (var d = 0; d < 5; d++)
|
||||
Expanded(
|
||||
child: _OutsideDayColumn(
|
||||
appointments: outside[d],
|
||||
maxVisible: _maxVisibleChips,
|
||||
chipHeight: _chipHeight,
|
||||
chipSpacing: _chipSpacing,
|
||||
onAppointmentTap: onAppointmentTap,
|
||||
isCrossedOut: isCrossedOut,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OutsideDayColumn extends StatelessWidget {
|
||||
final List<Appointment> appointments;
|
||||
final int maxVisible;
|
||||
final double chipHeight;
|
||||
final double chipSpacing;
|
||||
final void Function(Appointment) onAppointmentTap;
|
||||
final bool Function(Appointment) isCrossedOut;
|
||||
|
||||
const _OutsideDayColumn({
|
||||
required this.appointments,
|
||||
required this.maxVisible,
|
||||
required this.chipHeight,
|
||||
required this.chipSpacing,
|
||||
required this.onAppointmentTap,
|
||||
required this.isCrossedOut,
|
||||
});
|
||||
|
||||
void _showOverflow(BuildContext context, List<Appointment> hidden) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetCtx) => SafeArea(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
itemCount: hidden.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (_, i) {
|
||||
final apt = hidden[i];
|
||||
return 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);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _subtitleFor(Appointment a) {
|
||||
if (_isAllDayLike(a)) return 'Ganztägig';
|
||||
return '${_hm(a.startTime)}–${_hm(a.endTime)}';
|
||||
}
|
||||
|
||||
static String _hm(DateTime t) =>
|
||||
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (appointments.isEmpty) return const SizedBox.shrink();
|
||||
final sorted = [...appointments]
|
||||
..sort((a, b) {
|
||||
final aLike = _isAllDayLike(a);
|
||||
final bLike = _isAllDayLike(b);
|
||||
if (aLike && !bLike) return -1;
|
||||
if (!aLike && bLike) return 1;
|
||||
return a.startTime.compareTo(b.startTime);
|
||||
});
|
||||
final visible = sorted.length <= maxVisible
|
||||
? sorted
|
||||
: sorted.take(maxVisible - 1).toList();
|
||||
final overflow =
|
||||
sorted.length <= maxVisible ? const <Appointment>[] : sorted.skip(maxVisible - 1).toList();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < visible.length; i++) ...[
|
||||
if (i > 0) SizedBox(height: chipSpacing),
|
||||
SizedBox(
|
||||
height: chipHeight,
|
||||
child: _OutsideChip(
|
||||
appointment: visible[i],
|
||||
onTap: () => onAppointmentTap(visible[i]),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (overflow.isNotEmpty) ...[
|
||||
SizedBox(height: chipSpacing),
|
||||
SizedBox(
|
||||
height: chipHeight,
|
||||
child: _OutsideOverflowChip(
|
||||
count: overflow.length,
|
||||
onTap: () => _showOverflow(context, overflow),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OutsideChip extends StatelessWidget {
|
||||
final Appointment appointment;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _OutsideChip({required this.appointment, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final allDay = _isAllDayLike(appointment);
|
||||
final timeLabel = allDay
|
||||
? null
|
||||
: '${appointment.startTime.hour.toString().padLeft(2, '0')}:${appointment.startTime.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
return Material(
|
||||
color: appointment.color.withAlpha(60),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(7)),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
appointment.subject,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: false,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (timeLabel != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
timeLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.fade,
|
||||
softWrap: false,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OutsideOverflowChip extends StatelessWidget {
|
||||
final int count;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _OutsideOverflowChip({required this.count, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Material(
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'+$count weitere',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DayHeaderStrip extends StatelessWidget {
|
||||
final DateTime weekStart;
|
||||
final DateTime today;
|
||||
@@ -301,7 +588,7 @@ class _WeekGrid extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final perDay = _expandAppointmentsForWeek(appointments, weekStart);
|
||||
final partitioned = _partitionAppointmentsForWeek(appointments, weekStart);
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -316,7 +603,7 @@ class _WeekGrid extends StatelessWidget {
|
||||
child: _DayColumn(
|
||||
date: weekStart.add(Duration(days: d)),
|
||||
schedule: schedule,
|
||||
appointments: perDay[d],
|
||||
appointments: partitioned.inside[d],
|
||||
timeRegions: timeRegions,
|
||||
layout: layout,
|
||||
today: today,
|
||||
@@ -389,9 +676,9 @@ class _PeriodLabel extends StatelessWidget {
|
||||
}
|
||||
|
||||
final timeStyle = theme.textTheme.labelSmall?.copyWith(
|
||||
color: secondaryTextColor,
|
||||
color: secondaryTextColor.withAlpha(140),
|
||||
height: 1.0,
|
||||
fontSize: 10,
|
||||
fontSize: 9,
|
||||
);
|
||||
const tightTextHeight = TextHeightBehavior(
|
||||
applyHeightToFirstAscent: false,
|
||||
@@ -422,7 +709,7 @@ class _PeriodLabel extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${period.name}.',
|
||||
period.name,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -728,25 +1015,34 @@ List<_BoundRegion> _expandRegionsForDay(List<TimeRegion> regions, DateTime day)
|
||||
bool _isSameDay(DateTime a, DateTime b) =>
|
||||
a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
|
||||
/// Expands the given list of appointments across the visible 5-day work week,
|
||||
/// resolving any RRULE-based recurrences into per-day synthetic instances.
|
||||
/// Returns a list of length 5 (Monday..Friday); each entry holds the
|
||||
/// appointments occurring on that day, with `startTime` and `endTime` shifted
|
||||
/// to the actual occurrence date (preserving time-of-day and duration). The
|
||||
/// original `id`, `subject`, `color`, `location` and `notes` are kept so taps
|
||||
/// still resolve to the correct underlying event.
|
||||
List<List<Appointment>> _expandAppointmentsForWeek(
|
||||
List<Appointment> appointments, DateTime weekStart) {
|
||||
final perDay = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
|
||||
/// Expands the given list of appointments across the visible 5-day work week
|
||||
/// (resolving RRULE recurrences) and splits each day's events into two
|
||||
/// buckets: those that fit within the school-hours grid (`inside`) and those
|
||||
/// that don't (`outside` — all-day events and events that start before
|
||||
/// [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) {
|
||||
final inside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
|
||||
final outside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
|
||||
final weekEnd = weekStart.add(const Duration(days: 5));
|
||||
final weekStartUtc = weekStart.toUtc();
|
||||
final weekEndUtc = weekEnd.toUtc();
|
||||
|
||||
void place(int idx, Appointment a) {
|
||||
if (_isOutsideSchoolHours(a)) {
|
||||
outside[idx].add(a);
|
||||
} else {
|
||||
inside[idx].add(a);
|
||||
}
|
||||
}
|
||||
|
||||
for (final a in appointments) {
|
||||
final rule = a.recurrenceRule;
|
||||
if (rule == null || rule.isEmpty) {
|
||||
final idx = a.startTime.difference(weekStart).inDays;
|
||||
if (idx >= 0 && idx < 5) perDay[idx].add(a);
|
||||
final idx = _dayIndex(a.startTime, weekStart);
|
||||
if (idx >= 0 && idx < 5) place(idx, a);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@@ -763,25 +1059,53 @@ List<List<Appointment>> _expandAppointmentsForWeek(
|
||||
if (idx < 0 || idx >= 5) continue;
|
||||
final newStart = DateTime(occLocal.year, occLocal.month, occLocal.day,
|
||||
a.startTime.hour, a.startTime.minute);
|
||||
perDay[idx].add(Appointment(
|
||||
id: a.id,
|
||||
startTime: newStart,
|
||||
endTime: newStart.add(duration),
|
||||
subject: a.subject,
|
||||
color: a.color,
|
||||
location: a.location,
|
||||
notes: a.notes,
|
||||
));
|
||||
place(
|
||||
idx,
|
||||
Appointment(
|
||||
id: a.id,
|
||||
startTime: newStart,
|
||||
endTime: newStart.add(duration),
|
||||
subject: a.subject,
|
||||
color: a.color,
|
||||
location: a.location,
|
||||
notes: a.notes,
|
||||
isAllDay: a.isAllDay,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// Malformed RRULE → behave as non-recurring (anchor day only).
|
||||
final idx = a.startTime.difference(weekStart).inDays;
|
||||
if (idx >= 0 && idx < 5) perDay[idx].add(a);
|
||||
final idx = _dayIndex(a.startTime, weekStart);
|
||||
if (idx >= 0 && idx < 5) place(idx, a);
|
||||
}
|
||||
}
|
||||
return perDay;
|
||||
return (inside: inside, outside: outside);
|
||||
}
|
||||
|
||||
int _dayIndex(DateTime t, DateTime weekStart) =>
|
||||
DateTime(t.year, t.month, t.day).difference(weekStart).inDays;
|
||||
|
||||
/// True when the appointment doesn't fit into the school-hours grid:
|
||||
/// all-day, fully before the grid start, fully after the grid end, engulfing
|
||||
/// the grid entirely, or lasting 10+ hours (treated as a de-facto all-day
|
||||
/// event the source system happens to represent with explicit times).
|
||||
bool _isOutsideSchoolHours(Appointment a) {
|
||||
if (_isAllDayLike(a)) return true;
|
||||
final schoolStart = (kCalendarStartHour * 60).round();
|
||||
final schoolEnd = (kCalendarEndHour * 60).round();
|
||||
final startMin = a.startTime.hour * 60 + a.startTime.minute;
|
||||
final endMin = a.endTime.hour * 60 + a.endTime.minute;
|
||||
if (endMin <= schoolStart) return true;
|
||||
if (startMin >= schoolEnd) return true;
|
||||
if (startMin <= schoolStart && endMin >= schoolEnd) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Either explicitly marked as all-day, or so long it's effectively a full
|
||||
/// day from the user's perspective. We compare in minutes (not hours) because
|
||||
/// `Duration.inHours` truncates: a 9h 30min event would otherwise count as 9.
|
||||
bool _isAllDayLike(Appointment a) =>
|
||||
a.isAllDay || a.endTime.difference(a.startTime).inMinutes >= 8 * 60;
|
||||
|
||||
/// Maps lesson periods to vertical screen positions. Every non-break period
|
||||
/// gets the same fixed height (`lessonHeight`), every break gets `breakHeight`.
|
||||
/// Short transition gaps (Wechselzeiten) between periods are not represented
|
||||
|
||||
Reference in New Issue
Block a user