refactored broad range of the application, split files, modularized calendar and file views, centralized bottom sheets and clipboard handling, and implemented unit test coverage
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
import 'package:rrule/rrule.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import 'arbitrary_appointment.dart';
|
||||
import 'calendar_layout.dart';
|
||||
import 'lesson_period_schedule.dart';
|
||||
|
||||
/// 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;
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
int dayIndex(DateTime t, DateTime weekStart) =>
|
||||
DateTime(t.year, t.month, t.day).difference(weekStart).inDays;
|
||||
|
||||
class BoundRegion {
|
||||
final TimeRegion region;
|
||||
final DateTime start;
|
||||
final DateTime end;
|
||||
|
||||
BoundRegion({required this.region, required this.start, required this.end});
|
||||
}
|
||||
|
||||
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 &&
|
||||
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,
|
||||
));
|
||||
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,
|
||||
));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 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 = dayIndex(a.startTime, weekStart);
|
||||
if (idx >= 0 && idx < 5) place(idx, a);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
final parsed = RecurrenceRule.fromString(rule);
|
||||
final anchorUtc = a.startTime.toUtc();
|
||||
final duration = a.endTime.difference(a.startTime);
|
||||
for (final occUtc in parsed.getInstances(start: anchorUtc)) {
|
||||
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;
|
||||
if (idx < 0 || idx >= 5) continue;
|
||||
final newStart = DateTime(occLocal.year, occLocal.month, occLocal.day,
|
||||
a.startTime.hour, a.startTime.minute);
|
||||
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 (_) {
|
||||
final idx = dayIndex(a.startTime, weekStart);
|
||||
if (idx >= 0 && idx < 5) place(idx, a);
|
||||
}
|
||||
}
|
||||
return (inside: inside, outside: outside);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// at all — periods are rendered back-to-back, so a 5-minute gap simply
|
||||
/// disappears visually.
|
||||
class PeriodLayout {
|
||||
final List<LessonPeriod> periods;
|
||||
final double lessonHeight;
|
||||
final double breakHeight;
|
||||
|
||||
const PeriodLayout({
|
||||
required this.periods,
|
||||
required this.lessonHeight,
|
||||
required this.breakHeight,
|
||||
});
|
||||
|
||||
double _h(LessonPeriod p) => p.isBreak ? breakHeight : lessonHeight;
|
||||
|
||||
double get totalHeight =>
|
||||
periods.fold<double>(0, (sum, p) => sum + _h(p));
|
||||
|
||||
double topOf(LessonPeriod period) {
|
||||
var y = 0.0;
|
||||
for (final p in periods) {
|
||||
if (identical(p, period)) return y;
|
||||
y += _h(p);
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
double heightOf(LessonPeriod period) => _h(period);
|
||||
|
||||
/// Vertical offset for a given time of day. Times inside a period are mapped
|
||||
/// proportionally; times that fall into a transition gap are clipped to the
|
||||
/// end of the preceding period. Times before the first / after the last
|
||||
/// period clip to 0 / [totalHeight].
|
||||
double yOfDateTime(DateTime t) {
|
||||
final tMin = t.hour * 60 + t.minute + t.second / 60.0;
|
||||
var y = 0.0;
|
||||
for (final p in periods) {
|
||||
final pStart = p.start.hour * 60 + p.start.minute;
|
||||
final pEnd = p.end.hour * 60 + p.end.minute;
|
||||
final h = _h(p);
|
||||
if (tMin < pStart) return y;
|
||||
if (tMin <= pEnd) {
|
||||
final span = pEnd - pStart;
|
||||
final ratio = span > 0 ? (tMin - pStart) / span : 0.0;
|
||||
return y + ratio * h;
|
||||
}
|
||||
y += h;
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
/// Period at a given y-offset. If y falls into a break, returns the next
|
||||
/// non-break period. Returns null when y is past the last period.
|
||||
LessonPeriod? periodAtY(double y) {
|
||||
var cursor = 0.0;
|
||||
for (var i = 0; i < periods.length; i++) {
|
||||
final p = periods[i];
|
||||
final h = _h(p);
|
||||
if (y >= cursor && y < cursor + h) {
|
||||
if (p.isBreak) {
|
||||
for (var j = i + 1; j < periods.length; j++) {
|
||||
if (!periods[j].isBreak) return periods[j];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
cursor += h;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// One cell rendered in the day column — either a regular appointment or an
|
||||
/// overflow placeholder representing several hidden appointments.
|
||||
sealed class LaidOutCell {
|
||||
int get lane;
|
||||
int get laneCount;
|
||||
DateTime get startTime;
|
||||
DateTime get endTime;
|
||||
}
|
||||
|
||||
class LaidOutAppointment extends LaidOutCell {
|
||||
final Appointment appointment;
|
||||
@override
|
||||
final int lane;
|
||||
@override
|
||||
final int laneCount;
|
||||
LaidOutAppointment(this.appointment, this.lane, this.laneCount);
|
||||
|
||||
@override
|
||||
DateTime get startTime => appointment.startTime;
|
||||
@override
|
||||
DateTime get endTime => appointment.endTime;
|
||||
}
|
||||
|
||||
class LaidOutOverflow extends LaidOutCell {
|
||||
final List<Appointment> appointments;
|
||||
@override
|
||||
final int lane;
|
||||
@override
|
||||
final int laneCount;
|
||||
@override
|
||||
final DateTime startTime;
|
||||
@override
|
||||
final DateTime endTime;
|
||||
LaidOutOverflow(this.appointments, this.lane, this.laneCount, this.startTime, this.endTime);
|
||||
}
|
||||
|
||||
/// Horizontal ordering rank for parallel appointments. Lower = further left.
|
||||
/// User-owned custom events sit on the leftmost lane, cancelled lessons after
|
||||
/// them, every other lesson last. Only used as a tiebreaker — the greedy lane
|
||||
/// assignment still has to honor actual time-overlap constraints, so events
|
||||
/// that start later can't jump left of events that started earlier and are
|
||||
/// still occupying that lane.
|
||||
int _appointmentPriority(Appointment a) {
|
||||
final id = a.id;
|
||||
if (id is CustomAppointment) return 0;
|
||||
if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
/// Assigns each appointment a lane index using a greedy sweep, then collapses
|
||||
/// clusters that exceed [maxLanes] into `(maxLanes - 1)` visible appointments
|
||||
/// + one trailing overflow cell.
|
||||
///
|
||||
/// Greedy sweep:
|
||||
/// 1. Sort by `startTime` ascending, then [_appointmentPriority] (custom →
|
||||
/// cancelled → other) so parallel events land in the requested left-to-
|
||||
/// right order, then `endTime` descending as a final tiebreaker.
|
||||
/// 2. Walk the list, placing each appointment in the lowest-index lane that
|
||||
/// 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}) {
|
||||
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);
|
||||
});
|
||||
|
||||
// Phase 1: greedy lane assignment, grouped by cluster.
|
||||
final clusters = <List<({Appointment apt, int lane})>>[];
|
||||
var current = <({Appointment apt, int lane})>[];
|
||||
var laneEnds = <DateTime>[];
|
||||
|
||||
for (final apt in sorted) {
|
||||
final allFree =
|
||||
laneEnds.isNotEmpty && laneEnds.every((end) => !end.isAfter(apt.startTime));
|
||||
if (allFree) {
|
||||
clusters.add(current);
|
||||
current = <({Appointment apt, int lane})>[];
|
||||
laneEnds = <DateTime>[];
|
||||
}
|
||||
|
||||
var laneIdx = -1;
|
||||
for (var i = 0; i < laneEnds.length; i++) {
|
||||
if (!laneEnds[i].isAfter(apt.startTime)) {
|
||||
laneIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (laneIdx == -1) {
|
||||
laneIdx = laneEnds.length;
|
||||
laneEnds.add(apt.endTime);
|
||||
} else {
|
||||
laneEnds[laneIdx] = apt.endTime;
|
||||
}
|
||||
|
||||
current.add((apt: apt, lane: laneIdx));
|
||||
}
|
||||
if (current.isNotEmpty) clusters.add(current);
|
||||
|
||||
// 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);
|
||||
|
||||
if (laneCount <= maxLanes) {
|
||||
for (final entry in cluster) {
|
||||
result.add(LaidOutAppointment(entry.apt, entry.lane, laneCount));
|
||||
}
|
||||
} else {
|
||||
// Too many parallel appointments: keep the highest-priority
|
||||
// (maxLanes - 1) and collapse the rest into a single overflow cell in
|
||||
// the trailing lane. Sorting by priority first means custom and
|
||||
// cancelled lessons stay visible when the cluster has to be trimmed,
|
||||
// matching the requested left-to-right order in the visible lanes.
|
||||
final visibleCount = maxLanes - 1;
|
||||
final byPriority = [...cluster.map((e) => e.apt)]
|
||||
..sort((a, b) {
|
||||
final p = _appointmentPriority(a).compareTo(_appointmentPriority(b));
|
||||
if (p != 0) return p;
|
||||
return a.startTime.compareTo(b.startTime);
|
||||
});
|
||||
|
||||
for (var i = 0; i < visibleCount; i++) {
|
||||
result.add(LaidOutAppointment(byPriority[i], i, maxLanes));
|
||||
}
|
||||
|
||||
final overflow = byPriority.sublist(visibleCount);
|
||||
var earliest = overflow.first.startTime;
|
||||
var latest = overflow.first.endTime;
|
||||
for (final a in overflow.skip(1)) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user