272 lines
8.4 KiB
Dart
272 lines
8.4 KiB
Dart
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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|