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:
2026-05-08 19:05:16 +02:00
parent 3b1b0d0c19
commit c62a14645a
68 changed files with 4633 additions and 3141 deletions
@@ -0,0 +1,489 @@
part of '../custom_workweek_calendar.dart';
class _WeekGrid extends StatelessWidget {
final DateTime weekStart;
final LessonPeriodSchedule schedule;
final List<Appointment> appointments;
final List<TimeRegion> timeRegions;
final void Function(Appointment) onAppointmentTap;
final bool Function(Appointment) isCrossedOut;
final void Function(DateTime start, DateTime end)? onCreateEvent;
final DateTime today;
final ValueListenable<DateTime> 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<Appointment> appointments;
final List<TimeRegion> timeRegions;
final PeriodLayout layout;
final DateTime today;
final ValueListenable<DateTime> 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<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) {
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<Appointment> appointments) {
final sorted = [...appointments]
..sort((a, b) => a.startTime.compareTo(b.startTime));
showDetailsBottomSheet(
context,
children: (sheetContext) {
final tiles = <Widget>[];
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<DateTime>(
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: [
// 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,
),
),
],
),
),
),
),
),
],
),
);
}
}