custom login implementation, period-based timetable layout with overlap handling, enhanced error dialogs, and unified bottom sheets
This commit is contained in:
@@ -4,6 +4,8 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
import 'cross_painter.dart';
|
||||
|
||||
class AppointmentTile extends StatelessWidget {
|
||||
static const _radius = BorderRadius.all(Radius.circular(7));
|
||||
|
||||
final Appointment appointment;
|
||||
final bool crossedOut;
|
||||
|
||||
@@ -14,54 +16,51 @@ class AppointmentTile extends StatelessWidget {
|
||||
final isPast = appointment.endTime.isBefore(DateTime.now());
|
||||
final color = appointment.color.withAlpha(isPast ? 160 : 255);
|
||||
|
||||
final locationLines = (appointment.location ?? '')
|
||||
.split('\n')
|
||||
.where((p) => p.isNotEmpty)
|
||||
.take(2)
|
||||
.toList(growable: false);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(1),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
alignment: Alignment.topLeft,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.rectangle,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(7)),
|
||||
borderRadius: _radius,
|
||||
color: color,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FittedBox(
|
||||
fit: BoxFit.fitWidth,
|
||||
child: Text(
|
||||
appointment.subject,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
),
|
||||
FittedBox(
|
||||
fit: BoxFit.fitWidth,
|
||||
child: Text(
|
||||
appointment.location?.isNotEmpty == true ? appointment.location! : ' ',
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 10),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ScaledLine(
|
||||
text: appointment.subject,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
for (final line in locationLines)
|
||||
_ScaledLine(text: line, fontSize: 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (crossedOut)
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(width: 2, color: Colors.red.withAlpha(200)),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(7)),
|
||||
child: ClipRRect(
|
||||
borderRadius: _radius,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(width: 2, color: Colors.red.withAlpha(200)),
|
||||
borderRadius: _radius,
|
||||
),
|
||||
child: CustomPaint(painter: CrossPainter()),
|
||||
),
|
||||
child: CustomPaint(painter: CrossPainter()),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -69,3 +68,35 @@ class AppointmentTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of appointment text. The FittedBox scales **only this line** down
|
||||
/// when the text is wider than the tile, so a long teacher name does not
|
||||
/// shrink the room number above it.
|
||||
class _ScaledLine extends StatelessWidget {
|
||||
final String text;
|
||||
final double fontSize;
|
||||
final FontWeight? fontWeight;
|
||||
|
||||
const _ScaledLine({
|
||||
required this.text,
|
||||
required this.fontSize,
|
||||
this.fontWeight,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: fontSize,
|
||||
fontWeight: fontWeight,
|
||||
height: 1.1,
|
||||
),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,11 +132,23 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final hours = kCalendarEndHour - kCalendarStartHour;
|
||||
final fitPxPerHour = constraints.maxHeight / hours;
|
||||
final pxPerHour =
|
||||
fitPxPerHour < kCalendarMinPxPerHour ? kCalendarMinPxPerHour : fitPxPerHour;
|
||||
final gridHeight = pxPerHour * hours;
|
||||
final periods = widget.schedule.periods;
|
||||
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 lessonH = fitLessonH < kLessonBlockMinHeight
|
||||
? kLessonBlockMinHeight
|
||||
: fitLessonH;
|
||||
final layout = _PeriodLayout(
|
||||
periods: periods,
|
||||
lessonHeight: lessonH,
|
||||
breakHeight: kBreakBlockHeight,
|
||||
);
|
||||
final gridHeight = layout.totalHeight;
|
||||
|
||||
return SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
@@ -163,7 +175,7 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||
today: _today,
|
||||
nowNotifier: _nowNotifier,
|
||||
rulerWidth: _rulerWidth,
|
||||
pxPerHour: pxPerHour,
|
||||
layout: layout,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -271,7 +283,7 @@ class _WeekGrid extends StatelessWidget {
|
||||
final DateTime today;
|
||||
final ValueListenable<DateTime> nowNotifier;
|
||||
final double rulerWidth;
|
||||
final double pxPerHour;
|
||||
final _PeriodLayout layout;
|
||||
|
||||
const _WeekGrid({
|
||||
required this.weekStart,
|
||||
@@ -284,7 +296,7 @@ class _WeekGrid extends StatelessWidget {
|
||||
required this.today,
|
||||
required this.nowNotifier,
|
||||
required this.rulerWidth,
|
||||
required this.pxPerHour,
|
||||
required this.layout,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -296,7 +308,7 @@ class _WeekGrid extends StatelessWidget {
|
||||
children: [
|
||||
_PeriodRuler(
|
||||
schedule: schedule,
|
||||
pxPerHour: pxPerHour,
|
||||
layout: layout,
|
||||
width: rulerWidth,
|
||||
),
|
||||
for (var d = 0; d < 5; d++)
|
||||
@@ -306,7 +318,7 @@ class _WeekGrid extends StatelessWidget {
|
||||
schedule: schedule,
|
||||
appointments: perDay[d],
|
||||
timeRegions: timeRegions,
|
||||
pxPerHour: pxPerHour,
|
||||
layout: layout,
|
||||
today: today,
|
||||
nowNotifier: nowNotifier,
|
||||
onAppointmentTap: onAppointmentTap,
|
||||
@@ -321,18 +333,15 @@ class _WeekGrid extends StatelessWidget {
|
||||
|
||||
class _PeriodRuler extends StatelessWidget {
|
||||
final LessonPeriodSchedule schedule;
|
||||
final double pxPerHour;
|
||||
final _PeriodLayout layout;
|
||||
final double width;
|
||||
|
||||
const _PeriodRuler({
|
||||
required this.schedule,
|
||||
required this.pxPerHour,
|
||||
required this.layout,
|
||||
required this.width,
|
||||
});
|
||||
|
||||
double _y(TimeOfDay t) =>
|
||||
(t.hour + t.minute / 60 - kCalendarStartHour) * pxPerHour;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -343,8 +352,8 @@ class _PeriodRuler extends StatelessWidget {
|
||||
children: [
|
||||
for (final period in schedule.periods)
|
||||
Positioned(
|
||||
top: _y(period.start),
|
||||
height: (_y(period.end) - _y(period.start)).clamp(0, double.infinity),
|
||||
top: layout.topOf(period),
|
||||
height: layout.heightOf(period),
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: _PeriodLabel(period: period, theme: theme),
|
||||
@@ -450,7 +459,7 @@ class _DayColumn extends StatelessWidget {
|
||||
final LessonPeriodSchedule schedule;
|
||||
final List<Appointment> appointments;
|
||||
final List<TimeRegion> timeRegions;
|
||||
final double pxPerHour;
|
||||
final _PeriodLayout layout;
|
||||
final DateTime today;
|
||||
final ValueListenable<DateTime> nowNotifier;
|
||||
final void Function(Appointment) onAppointmentTap;
|
||||
@@ -462,7 +471,7 @@ class _DayColumn extends StatelessWidget {
|
||||
required this.schedule,
|
||||
required this.appointments,
|
||||
required this.timeRegions,
|
||||
required this.pxPerHour,
|
||||
required this.layout,
|
||||
required this.today,
|
||||
required this.nowNotifier,
|
||||
required this.onAppointmentTap,
|
||||
@@ -470,66 +479,6 @@ class _DayColumn extends StatelessWidget {
|
||||
required this.onCreateEvent,
|
||||
});
|
||||
|
||||
double _y(int hour, int minute) =>
|
||||
(hour + minute / 60 - kCalendarStartHour) * pxPerHour;
|
||||
|
||||
double _yFromDate(DateTime t) => _y(t.hour, t.minute);
|
||||
|
||||
/// Snaps an appointment edge to the nearest period boundary if the gap is small,
|
||||
/// so small inter-period transitions (Wechselzeiten) appear as part of the lesson visually.
|
||||
double _yForAppointmentEdge(DateTime t, {required bool isStart}) {
|
||||
final tMin = t.hour * 60 + t.minute;
|
||||
for (final period in schedule.periods) {
|
||||
if (period.isBreak) continue;
|
||||
final pStart = period.start.hour * 60 + period.start.minute;
|
||||
final pEnd = period.end.hour * 60 + period.end.minute;
|
||||
if (isStart) {
|
||||
final delta = tMin - pStart;
|
||||
if (delta >= 0 && delta < 5) {
|
||||
return _y(period.start.hour, period.start.minute);
|
||||
}
|
||||
} else {
|
||||
final delta = pEnd - tMin;
|
||||
if (delta >= 0 && delta < 5) {
|
||||
// Snap to the next non-break period's start when the gap is short
|
||||
// (Wechselzeit). Skips into a break never extends the lesson.
|
||||
final idx = schedule.periods.indexOf(period);
|
||||
if (idx + 1 < schedule.periods.length) {
|
||||
final next = schedule.periods[idx + 1];
|
||||
if (!next.isBreak) {
|
||||
final nextStart = next.start.hour * 60 + next.start.minute;
|
||||
if (nextStart - pEnd < 10) {
|
||||
return _y(next.start.hour, next.start.minute);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return _yFromDate(t);
|
||||
}
|
||||
|
||||
/// Returns the lesson period (non-break) that the given y-offset falls into,
|
||||
/// or the next upcoming non-break period if y falls inside a break or before
|
||||
/// the first period. Returns null if y is past the last period of the day.
|
||||
LessonPeriod? _periodAt(double y) {
|
||||
final hoursDecimal = y / pxPerHour + kCalendarStartHour;
|
||||
final tappedMinutes = (hoursDecimal * 60).round();
|
||||
|
||||
LessonPeriod? upcoming;
|
||||
for (final p in schedule.periods) {
|
||||
if (p.isBreak) continue;
|
||||
final pStart = p.start.hour * 60 + p.start.minute;
|
||||
final pEnd = p.end.hour * 60 + p.end.minute;
|
||||
if (tappedMinutes >= pStart && tappedMinutes < pEnd) return p;
|
||||
if (tappedMinutes < pStart) {
|
||||
upcoming = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return upcoming;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -539,7 +488,7 @@ class _DayColumn extends StatelessWidget {
|
||||
|
||||
void _handleLongPress(LongPressStartDetails details, List<Appointment> dayAppts) {
|
||||
if (onCreateEvent == null) return;
|
||||
final period = _periodAt(details.localPosition.dy);
|
||||
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);
|
||||
@@ -550,6 +499,56 @@ class _DayColumn extends StatelessWidget {
|
||||
onCreateEvent!(start, end);
|
||||
}
|
||||
|
||||
void _showOverflowSheet(BuildContext context, List<Appointment> appointments) {
|
||||
final sorted = [...appointments]
|
||||
..sort((a, b) => a.startTime.compareTo(b.startTime));
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
itemCount: sorted.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (_, i) {
|
||||
final apt = sorted[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(_overflowSubtitle(apt)),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
onAppointmentTap(apt);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _overflowSubtitle(Appointment apt) {
|
||||
final time = '${_formatHm(apt.startTime)}–${_formatHm(apt.endTime)}';
|
||||
final loc = apt.location?.replaceAll('\n', ' · ');
|
||||
return loc != null && loc.isNotEmpty ? '$time · $loc' : time;
|
||||
}
|
||||
|
||||
static String _formatHm(DateTime t) =>
|
||||
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -558,6 +557,8 @@ class _DayColumn extends StatelessWidget {
|
||||
final dayRegions = _expandRegionsForDay(timeRegions, date);
|
||||
final isToday = _isSameDay(date, today);
|
||||
|
||||
final laidOut = _assignLanes(dayAppointments);
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onLongPressStart: (details) => _handleLongPress(details, dayAppointments),
|
||||
@@ -566,52 +567,66 @@ class _DayColumn extends StatelessWidget {
|
||||
color: isToday ? theme.colorScheme.primary.withAlpha(14) : null,
|
||||
border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)),
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
for (final period in schedule.periods)
|
||||
Positioned(
|
||||
top: _y(period.start.hour, period.start.minute),
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
height: 0.5,
|
||||
color: theme.dividerColor.withAlpha(60),
|
||||
),
|
||||
),
|
||||
for (final region in dayRegions)
|
||||
Positioned(
|
||||
top: _yFromDate(region.start),
|
||||
height:
|
||||
(_yFromDate(region.end) - _yFromDate(region.start)).clamp(0, double.infinity),
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: TimeRegionTile(region: region.region),
|
||||
),
|
||||
for (final apt in dayAppointments)
|
||||
Positioned(
|
||||
top: _yForAppointmentEdge(apt.startTime, isStart: true),
|
||||
height: (_yForAppointmentEdge(apt.endTime, isStart: false) -
|
||||
_yForAppointmentEdge(apt.startTime, isStart: true))
|
||||
.clamp(0, double.infinity),
|
||||
left: 1,
|
||||
right: 1,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => onAppointmentTap(apt),
|
||||
child: AppointmentTile(
|
||||
appointment: apt,
|
||||
crossedOut: isCrossedOut(apt),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isToday)
|
||||
ValueListenableBuilder<DateTime>(
|
||||
valueListenable: nowNotifier,
|
||||
builder: (_, now, child) =>
|
||||
_CurrentTimeMarker(now: now, pxPerHour: pxPerHour, theme: theme),
|
||||
),
|
||||
],
|
||||
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(
|
||||
appointment: appointment,
|
||||
crossedOut: isCrossedOut(appointment),
|
||||
),
|
||||
),
|
||||
_LaidOutOverflow(:final appointments) => GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -620,20 +635,27 @@ class _DayColumn extends StatelessWidget {
|
||||
|
||||
class _CurrentTimeMarker extends StatelessWidget {
|
||||
final DateTime now;
|
||||
final double pxPerHour;
|
||||
final _PeriodLayout layout;
|
||||
final ThemeData theme;
|
||||
|
||||
const _CurrentTimeMarker({
|
||||
required this.now,
|
||||
required this.pxPerHour,
|
||||
required this.layout,
|
||||
required this.theme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final y = (now.hour + now.minute / 60 + now.second / 3600 - kCalendarStartHour) * pxPerHour;
|
||||
final maxY = (kCalendarEndHour - kCalendarStartHour) * pxPerHour;
|
||||
if (y < 0 || y > maxY) return const SizedBox.shrink();
|
||||
final periods = layout.periods;
|
||||
if (periods.isEmpty) return const SizedBox.shrink();
|
||||
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;
|
||||
if (tMin < firstStart || tMin > lastEnd) return const SizedBox.shrink();
|
||||
|
||||
final y = layout.yOfDateTime(now);
|
||||
|
||||
return AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
@@ -759,3 +781,278 @@ List<List<Appointment>> _expandAppointmentsForWeek(
|
||||
}
|
||||
return perDay;
|
||||
}
|
||||
|
||||
/// 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 yOf(TimeOfDay t) {
|
||||
final tMin = t.hour * 60 + t.minute;
|
||||
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;
|
||||
}
|
||||
|
||||
double yOfDateTime(DateTime t) =>
|
||||
yOf(TimeOfDay(hour: t.hour, minute: t.minute));
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum number of cells shown side by side in a single time slot. When a
|
||||
/// cluster needs more lanes than this, the first appointment (by start time)
|
||||
/// keeps lane 0 and the rest are collapsed into a single "+N" overflow cell
|
||||
/// in lane 1.
|
||||
const int _kMaxVisibleCells = 2;
|
||||
|
||||
class _OverflowTile extends StatelessWidget {
|
||||
final int count;
|
||||
const _OverflowTile({required this.count});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
const radius = BorderRadius.all(Radius.circular(7));
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(1),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Card peeking out at the bottom — visual hint that more cards lie
|
||||
// underneath the visible one.
|
||||
Positioned(
|
||||
top: 4,
|
||||
left: 2,
|
||||
right: 2,
|
||||
bottom: 0,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: radius,
|
||||
color: scheme.secondaryContainer.withAlpha(120),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Front card with the "+N" indicator.
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 4,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: radius,
|
||||
color: scheme.secondaryContainer,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.unfold_more_rounded,
|
||||
size: 18,
|
||||
color: scheme.onSecondaryContainer,
|
||||
),
|
||||
Text(
|
||||
'+$count',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: scheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// Assigns each appointment a lane index using a greedy sweep, then collapses
|
||||
/// clusters that exceed [_kMaxVisibleCells] into 1 visible appointment + 1
|
||||
/// overflow cell side by side.
|
||||
///
|
||||
/// Greedy sweep:
|
||||
/// 1. Sort by `startTime` ascending, `endTime` descending on ties.
|
||||
/// 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) {
|
||||
if (appts.isEmpty) return const <_LaidOutCell>[];
|
||||
|
||||
final sorted = [...appts]..sort((a, b) {
|
||||
final c = a.startTime.compareTo(b.startTime);
|
||||
if (c != 0) return c;
|
||||
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 <= _kMaxVisibleCells) {
|
||||
for (final entry in cluster) {
|
||||
result.add(_LaidOutAppointment(entry.apt, entry.lane, laneCount));
|
||||
}
|
||||
} else {
|
||||
// 3+ parallel appointments: keep the earliest, collapse the rest.
|
||||
final byStart = [...cluster.map((e) => e.apt)]
|
||||
..sort((a, b) => a.startTime.compareTo(b.startTime));
|
||||
result.add(_LaidOutAppointment(byStart[0], 0, _kMaxVisibleCells));
|
||||
|
||||
final overflow = byStart.sublist(1);
|
||||
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, _kMaxVisibleCells - 1, _kMaxVisibleCells, earliest, latest));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user