Files
Client/lib/view/pages/timetable/widgets/appointment_tile.dart
T

214 lines
6.4 KiB
Dart

import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../data/arbitrary_appointment.dart';
import 'cross_painter.dart';
class AppointmentTile extends StatelessWidget {
static const _radius = BorderRadius.all(Radius.circular(7));
static const _titleFontSize = 15.0;
static const _titleMinFontSize = 11.0;
static const _bodyFontSize = 10.0;
static const _bodyLineHeight = 1.15;
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: _titleFontSize,
minFontSize: _titleMinFontSize,
fontWeight: FontWeight.w500,
),
if (isCustom) ...[
if (description.isNotEmpty)
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 1),
child: _WrappingBody(
text: description,
fontSize: _bodyFontSize,
lineHeight: _bodyLineHeight,
),
),
),
] else ...[
for (final line in description
.split('\n')
.where((p) => p.isNotEmpty)
.take(2))
_ScaledLine(text: line, fontSize: _bodyFontSize),
],
],
),
),
),
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,
),
);
}