part of '../custom_workweek_calendar.dart'; class _WeekGrid extends StatelessWidget { final DateTime weekStart; final LessonPeriodSchedule schedule; final List appointments; final List timeRegions; final void Function(Appointment) onAppointmentTap; final bool Function(Appointment) isCrossedOut; final void Function(DateTime start, DateTime end)? onCreateEvent; final DateTime today; final ValueListenable nowNotifier; final double rulerWidth; final PeriodLayout layout; const _WeekGrid({ required this.weekStart, required this.schedule, required this.appointments, required this.timeRegions, required this.onAppointmentTap, required this.isCrossedOut, required this.onCreateEvent, required this.today, required this.nowNotifier, required this.rulerWidth, required this.layout, }); @override Widget build(BuildContext context) { final partitioned = partitionAppointmentsForWeek(appointments, weekStart); return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _PeriodRuler( schedule: schedule, layout: layout, width: rulerWidth, ), for (var d = 0; d < 5; d++) Expanded( child: _DayColumn( date: weekStart.add(Duration(days: d)), schedule: schedule, appointments: partitioned.inside[d], timeRegions: timeRegions, layout: layout, today: today, nowNotifier: nowNotifier, onAppointmentTap: onAppointmentTap, isCrossedOut: isCrossedOut, onCreateEvent: onCreateEvent, ), ), ], ); } } class _PeriodRuler extends StatelessWidget { final LessonPeriodSchedule schedule; final PeriodLayout layout; final double width; const _PeriodRuler({ required this.schedule, required this.layout, required this.width, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); return SizedBox( width: width, child: Stack( clipBehavior: Clip.none, children: [ for (final period in schedule.periods) Positioned( top: layout.topOf(period), height: layout.heightOf(period), left: 0, right: 0, child: _PeriodLabel(period: period, theme: theme), ), ], ), ); } } class _PeriodLabel extends StatelessWidget { final LessonPeriod period; final ThemeData theme; const _PeriodLabel({required this.period, required this.theme}); @override Widget build(BuildContext context) { final dividerColor = theme.dividerColor.withAlpha(110); final secondaryTextColor = theme.colorScheme.onSurfaceVariant; if (period.isBreak) { return Container( decoration: BoxDecoration( border: Border( top: BorderSide(color: dividerColor, width: 0.5), bottom: BorderSide(color: dividerColor, width: 0.5), ), ), alignment: Alignment.center, child: Icon(Icons.coffee_outlined, size: 12, color: secondaryTextColor.withAlpha(180)), ); } final timeStyle = theme.textTheme.labelSmall?.copyWith( color: secondaryTextColor.withAlpha(140), height: 1.0, fontSize: 9, ); const tightTextHeight = TextHeightBehavior( applyHeightToFirstAscent: false, applyHeightToLastDescent: false, ); return LayoutBuilder( builder: (context, constraints) { final showTimes = constraints.maxHeight >= 38; return DecoratedBox( decoration: BoxDecoration( border: Border(top: BorderSide(color: dividerColor, width: 0.5)), ), child: Stack( clipBehavior: Clip.none, alignment: Alignment.center, children: [ if (showTimes) Positioned( top: 3, left: 0, right: 0, child: Text( _format(period.start), style: timeStyle, textAlign: TextAlign.center, textHeightBehavior: tightTextHeight, ), ), Text( period.name, style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onSurface, fontWeight: FontWeight.w500, height: 1.0, ), textAlign: TextAlign.center, textHeightBehavior: tightTextHeight, ), if (showTimes) Positioned( bottom: 3, left: 0, right: 0, child: Text( _format(period.end), style: timeStyle, textAlign: TextAlign.center, textHeightBehavior: tightTextHeight, ), ), ], ), ); }, ); } static String _format(TimeOfDay t) => '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; } class _DayColumn extends StatelessWidget { final DateTime date; final LessonPeriodSchedule schedule; final List appointments; final List timeRegions; final PeriodLayout layout; final DateTime today; final ValueListenable nowNotifier; final void Function(Appointment) onAppointmentTap; final bool Function(Appointment) isCrossedOut; final void Function(DateTime start, DateTime end)? onCreateEvent; const _DayColumn({ required this.date, required this.schedule, required this.appointments, required this.timeRegions, required this.layout, required this.today, required this.nowNotifier, required this.onAppointmentTap, required this.isCrossedOut, required this.onCreateEvent, }); bool _overlapsExistingAppointment(DateTime start, DateTime end, List dayAppts) { for (final a in dayAppts) { if (a.endTime.isAfter(start) && a.startTime.isBefore(end)) return true; } return false; } void _handleLongPress(LongPressStartDetails details, List 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); if (_overlapsExistingAppointment(start, end, dayAppts)) return; HapticFeedback.mediumImpact(); onCreateEvent!(start, end); } void _showOverflowSheet(BuildContext context, List appointments) { final sorted = [...appointments] ..sort((a, b) => a.startTime.compareTo(b.startTime)); showDetailsBottomSheet( context, children: (sheetContext) { final tiles = []; 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), ), ), 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; }, ); } 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); final dayAppointments = appointments; final dayRegions = expandRegionsForDay(timeRegions, date); final isToday = date.isSameDay(today); final isTablet = MediaQuery.of(context).size.shortestSide >= 600; final laidOut = assignLanes(dayAppointments, maxLanes: isTablet ? 3 : 2); return GestureDetector( 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), ), ), 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( valueListenable: nowNotifier, builder: (_, now, child) => _CurrentTimeMarker(now: now, layout: layout, theme: theme), ), ], ); }, ), ), ); } } class _CurrentTimeMarker extends StatelessWidget { final DateTime now; final PeriodLayout layout; final ThemeData theme; const _CurrentTimeMarker({ required this.now, required this.layout, required this.theme, }); @override Widget build(BuildContext context) { 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), curve: Curves.easeInOut, top: y - 1, left: 0, right: 0, child: IgnorePointer( child: Stack( clipBehavior: Clip.none, children: [ Container( height: 2, color: theme.colorScheme.primary, ), Positioned( top: -3, left: -4, child: Container( width: 8, height: 8, decoration: BoxDecoration( color: theme.colorScheme.primary, shape: BoxShape.circle, ), ), ), ], ), ), ); } } 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: [ // Stacked-cards effect: a darker layer peeks out below the front card. Positioned( top: 4, left: 2, right: 2, bottom: 0, child: DecoratedBox( decoration: BoxDecoration( borderRadius: radius, color: scheme.secondaryContainer.withAlpha(120), ), ), ), 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, ), ), ], ), ), ), ), ), ], ), ); } }