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,84 @@
|
||||
part of '../custom_workweek_calendar.dart';
|
||||
|
||||
class _DayHeaderStrip extends StatelessWidget {
|
||||
final DateTime weekStart;
|
||||
final DateTime today;
|
||||
final double rulerWidth;
|
||||
|
||||
const _DayHeaderStrip({
|
||||
super.key,
|
||||
required this.weekStart,
|
||||
required this.today,
|
||||
required this.rulerWidth,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
children: [
|
||||
SizedBox(width: rulerWidth),
|
||||
for (var d = 0; d < 5; d++)
|
||||
Expanded(
|
||||
child: _DayHeaderCell(
|
||||
date: weekStart.add(Duration(days: d)),
|
||||
today: today,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _DayHeaderCell extends StatelessWidget {
|
||||
final DateTime date;
|
||||
final DateTime today;
|
||||
|
||||
const _DayHeaderCell({required this.date, required this.today});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isToday = date.isSameDay(today);
|
||||
final dayName = DateFormat('EE', Localizations.localeOf(context).toString()).format(date).toUpperCase();
|
||||
|
||||
final accent = theme.colorScheme.primary;
|
||||
final onAccent = theme.colorScheme.onPrimary;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
dayName,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: isToday ? accent : theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isToday ? accent : Colors.transparent,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'${date.day}',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: isToday ? onAccent : theme.colorScheme.onSurface,
|
||||
fontWeight: isToday ? FontWeight.bold : FontWeight.normal,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
part of '../custom_workweek_calendar.dart';
|
||||
|
||||
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) {
|
||||
showDetailsBottomSheet(
|
||||
context,
|
||||
children: (sheetCtx) {
|
||||
final tiles = <Widget>[];
|
||||
for (var i = 0; i < hidden.length; i++) {
|
||||
if (i > 0) tiles.add(const Divider(height: 1));
|
||||
final apt = hidden[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(_subtitleFor(apt)),
|
||||
onTap: () {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
onAppointmentTap(apt);
|
||||
},
|
||||
));
|
||||
}
|
||||
return tiles;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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')}';
|
||||
|
||||
// Past chips fade further, future/ongoing ones get a more saturated tint
|
||||
// so the strip no longer reads as one uniform grey block.
|
||||
final isPast = appointment.endTime.isBefore(DateTime.now());
|
||||
final backgroundAlpha = isPast ? 38 : 120;
|
||||
final subjectColor = isPast
|
||||
? theme.colorScheme.onSurfaceVariant
|
||||
: theme.colorScheme.onSurface;
|
||||
final subjectWeight = isPast ? FontWeight.w400 : FontWeight.w600;
|
||||
|
||||
return Material(
|
||||
color: appointment.color.withAlpha(backgroundAlpha),
|
||||
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: subjectColor,
|
||||
fontWeight: subjectWeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user