import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../data/arbitrary_appointment.dart'; import '../data/calendar_layout.dart'; import 'cross_painter.dart'; class AppointmentTile extends StatelessWidget { static const _radius = BorderRadius.all(Radius.circular(7)); final Appointment appointment; final bool crossedOut; const AppointmentTile({super.key, required this.appointment, this.crossedOut = false}); @override Widget build(BuildContext context) { final isPast = appointment.endTime.isBefore(DateTime.now()); final color = appointment.color.withAlpha(isPast ? 160 : 255); final isCustom = appointment.id is CustomAppointment; final description = appointment.location ?? ''; return Padding( padding: const EdgeInsets.all(1), child: Stack( children: [ Positioned.fill( child: Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), alignment: Alignment.topLeft, decoration: BoxDecoration( shape: BoxShape.rectangle, borderRadius: _radius, color: color, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.max, children: [ _AdaptiveTitle( text: appointment.subject, fontSize: kAppointmentTitleFontSize, minFontSize: kAppointmentTitleMinFontSize, fontWeight: FontWeight.w500, ), if (isCustom) ...[ if (description.isNotEmpty) Expanded( child: Padding( padding: const EdgeInsets.only(top: 1), child: _WrappingBody( text: description, fontSize: kAppointmentBodyFontSize, lineHeight: kAppointmentBodyLineHeight, ), ), ), ] else ...[ for (final line in description .split('\n') .where((p) => p.isNotEmpty) .take(2)) _ScaledLine(text: line, fontSize: kAppointmentBodyFontSize), ], ], ), ), ), if (crossedOut) Positioned.fill( child: ClipRRect( borderRadius: _radius, child: DecoratedBox( decoration: BoxDecoration( border: Border.all(width: 2, color: Colors.red.withAlpha(200)), borderRadius: _radius, ), child: CustomPaint(painter: CrossPainter()), ), ), ), ], ), ); } } /// Renders the appointment title. Scales down to fit the available width via /// [FittedBox], but never below [minFontSize] — when even the minimum size /// overflows, the text is rendered at [minFontSize] with an ellipsis. class _AdaptiveTitle extends StatelessWidget { final String text; final double fontSize; final double minFontSize; final FontWeight? fontWeight; const _AdaptiveTitle({ required this.text, required this.fontSize, required this.minFontSize, this.fontWeight, }); @override Widget build(BuildContext context) { final baseStyle = TextStyle( color: Colors.white, fontSize: fontSize, fontWeight: fontWeight, height: 1.1, ); final textScaler = MediaQuery.textScalerOf(context); return LayoutBuilder( builder: (context, constraints) { // Probe at the minimum size: if even that overflows, we have to ellipsize. final probe = TextPainter( text: TextSpan(text: text, style: baseStyle.copyWith(fontSize: minFontSize)), textDirection: TextDirection.ltr, maxLines: 1, textScaler: textScaler, )..layout(); if (probe.width > constraints.maxWidth) { return Text( text, style: baseStyle.copyWith(fontSize: minFontSize), maxLines: 1, softWrap: false, overflow: TextOverflow.ellipsis, ); } return FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Text( text, style: baseStyle, maxLines: 1, softWrap: false, ), ); }, ); } } /// Body text for custom events. Wraps to fill the available height and clips /// trailing content with an ellipsis if there is more than fits. class _WrappingBody extends StatelessWidget { final String text; final double fontSize; final double lineHeight; const _WrappingBody({ required this.text, required this.fontSize, required this.lineHeight, }); @override Widget build(BuildContext context) { final style = TextStyle( color: Colors.white, fontSize: fontSize, height: lineHeight, ); final textScaler = MediaQuery.textScalerOf(context); return LayoutBuilder( builder: (context, constraints) { final lineBox = textScaler.scale(fontSize) * lineHeight; final maxLines = (constraints.maxHeight / lineBox).floor().clamp(1, 99); return Text( text, style: style, maxLines: maxLines, overflow: TextOverflow.ellipsis, softWrap: true, ); }, ); } } /// 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; const _ScaledLine({ required this.text, required this.fontSize, }); @override Widget build(BuildContext context) => FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Text( text, style: TextStyle( color: Colors.white, fontSize: fontSize, height: 1.1, ), maxLines: 1, softWrap: false, ), ); }