fix: scale timeline task card contents

This commit is contained in:
Ashley Venn 2026-06-30 13:09:48 -07:00
parent f985b3ee84
commit dacd189435
2 changed files with 324 additions and 93 deletions

View file

@ -1,6 +1,8 @@
/// Renders Task Timeline Card pieces for the FocusFlow Flutter timeline.
library;
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../models/today_screen_models.dart';
@ -22,87 +24,198 @@ class TaskTimelineCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = _CardColors.forKind(card.visualKind);
return Material(
color: Colors.transparent,
child: InkWell(
key: ValueKey(card.id),
borderRadius: BorderRadius.circular(FocusFlowTokens.cardRadius),
onTap: onTap,
child: Container(
padding: const EdgeInsets.fromLTRB(18, 10, 14, 10),
decoration: BoxDecoration(
color: colors.background,
borderRadius: BorderRadius.circular(FocusFlowTokens.cardRadius),
border: Border.all(color: colors.accent, width: 1.2),
boxShadow: [
BoxShadow(
color: colors.accent.withValues(alpha: 0.12),
blurRadius: 18,
spreadRadius: 1,
),
],
),
child: Row(
children: [
_StatusRing(card: card, color: colors.accent),
const SizedBox(width: 22),
Expanded(
child: _CardText(card: card, color: colors.accent),
),
if (card.showsQuickActions) ...[
_NoOpIcon(icon: Icons.check, color: colors.accent),
_NoOpIcon(icon: Icons.arrow_forward, color: colors.accent),
_NoOpIcon(icon: Icons.calendar_today, color: colors.accent),
_NoOpIcon(icon: Icons.wb_sunny_outlined, color: colors.accent),
const SizedBox(width: 10),
],
_Badge(child: RewardIcon(color: colors.reward, size: 26)),
const SizedBox(width: 8),
_Badge(
child: DifficultyBars(
difficulty: card.difficultyIconToken,
color: colors.reward,
return LayoutBuilder(
builder: (context, constraints) {
final metrics = _CardMetrics.fromConstraints(
constraints,
kind: card.visualKind,
);
return Material(
color: Colors.transparent,
child: InkWell(
key: ValueKey(card.id),
borderRadius: BorderRadius.circular(metrics.cardRadius),
onTap: onTap,
child: Container(
padding: metrics.padding,
decoration: BoxDecoration(
color: colors.background,
borderRadius: BorderRadius.circular(metrics.cardRadius),
border: Border.all(
color: colors.accent,
width: metrics.borderWidth,
),
boxShadow: [
BoxShadow(
color: colors.accent.withValues(alpha: 0.12),
blurRadius: metrics.shadowBlur,
spreadRadius: metrics.shadowSpread,
),
],
),
],
child: Row(
children: [
_StatusRing(
card: card,
color: colors.accent,
metrics: metrics,
),
SizedBox(width: metrics.statusGap),
Expanded(
child: _CardText(
card: card,
color: colors.accent,
metrics: metrics,
),
),
if (card.showsQuickActions) ...[
_NoOpIcon(
icon: Icons.check,
color: colors.accent,
metrics: metrics,
),
_NoOpIcon(
icon: Icons.arrow_forward,
color: colors.accent,
metrics: metrics,
),
_NoOpIcon(
icon: Icons.calendar_today,
color: colors.accent,
metrics: metrics,
),
_NoOpIcon(
icon: Icons.wb_sunny_outlined,
color: colors.accent,
metrics: metrics,
),
SizedBox(width: metrics.actionGap),
],
_Badge(
metrics: metrics,
child: RewardIcon(
color: colors.reward,
size: metrics.rewardIconSize,
),
),
SizedBox(width: metrics.badgeGap),
_Badge(
metrics: metrics,
child: DifficultyBars(
difficulty: card.difficultyIconToken,
color: colors.reward,
size: metrics.difficultySize,
),
),
],
),
),
),
),
),
);
},
);
}
}
class _CardMetrics {
const _CardMetrics(this.scale);
static const _referenceWidth = 640.0;
static const _referenceHeight = 88.0;
static const _freeSlotReferenceHeight = 108.0;
static const _minimumScale = 0.62;
factory _CardMetrics.fromConstraints(
BoxConstraints constraints, {
required TaskVisualKind kind,
}) {
final width = constraints.hasBoundedWidth
? constraints.maxWidth
: _referenceWidth;
final height = constraints.hasBoundedHeight
? constraints.maxHeight
: _referenceHeight;
final widthScale = width / _referenceWidth;
final heightScale =
height /
(kind == TaskVisualKind.freeSlot
? _freeSlotReferenceHeight
: _referenceHeight);
final scale = math.min(widthScale, heightScale).clamp(_minimumScale, 1.0);
return _CardMetrics(scale.toDouble());
}
final double scale;
double get cardRadius => FocusFlowTokens.cardRadius * scale;
double get borderWidth => math.max(1, 1.2 * scale);
double get shadowBlur => 18 * scale;
double get shadowSpread => scale;
double get statusRingSize => 34 * scale;
double get statusBorderWidth => math.max(1.2, 3 * scale);
double get freeSlotStatusBorderWidth => math.max(1, 2 * scale);
double get statusIconSize => 24 * scale;
double get statusGap => 22 * scale;
double get actionButtonSize => 40 * scale;
double get actionIconSize => 22 * scale;
double get actionGap => 10 * scale;
double get badgeWidth => 62 * scale;
double get badgeHeight => 48 * scale;
double get badgeRadius => 7 * scale;
double get badgeGap => 8 * scale;
double get rewardIconSize => 26 * scale;
double get titleSize => FocusFlowTokens.cardTitleSize * scale;
double get metaSize => FocusFlowTokens.cardMetaSize * scale;
double get titleMetaGap => 4 * scale;
double get freeSlotTimeGap => 10 * scale;
Size get difficultySize => Size(54 * scale, 34 * scale);
EdgeInsets get padding =>
EdgeInsets.fromLTRB(18 * scale, 10 * scale, 14 * scale, 10 * scale);
}
class _StatusRing extends StatelessWidget {
const _StatusRing({required this.card, required this.color});
const _StatusRing({
required this.card,
required this.color,
required this.metrics,
});
final TimelineCardModel card;
final Color color;
final _CardMetrics metrics;
@override
Widget build(BuildContext context) {
final border = Border.all(
color: color,
width: card.visualKind == TaskVisualKind.freeSlot ? 2 : 3,
width: card.visualKind == TaskVisualKind.freeSlot
? metrics.freeSlotStatusBorderWidth
: metrics.statusBorderWidth,
style: card.visualKind == TaskVisualKind.freeSlot
? BorderStyle.solid
: BorderStyle.solid,
);
return Container(
width: 34,
height: 34,
width: metrics.statusRingSize,
height: metrics.statusRingSize,
decoration: BoxDecoration(shape: BoxShape.circle, border: border),
child: card.isCompleted
? Icon(Icons.check, color: color, size: 24)
? Icon(Icons.check, color: color, size: metrics.statusIconSize)
: null,
);
}
}
class _CardText extends StatelessWidget {
const _CardText({required this.card, required this.color});
const _CardText({
required this.card,
required this.color,
required this.metrics,
});
final TimelineCardModel card;
final Color color;
final _CardMetrics metrics;
@override
Widget build(BuildContext context) {
@ -110,85 +223,111 @@ class _CardText extends StatelessWidget {
color: card.isCompleted
? FocusFlowTokens.completedText
: FocusFlowTokens.textPrimary,
fontSize: FocusFlowTokens.cardTitleSize,
fontSize: metrics.titleSize,
fontWeight: FontWeight.w800,
decoration: card.isCompleted ? TextDecoration.lineThrough : null,
decorationColor: FocusFlowTokens.completedText,
decorationThickness: 2,
decorationThickness: 2 * metrics.scale,
);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
card.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: titleStyle,
),
const SizedBox(height: 4),
Text(
card.subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: card.isCompleted ? FocusFlowTokens.completedText : color,
fontSize: FocusFlowTokens.cardMetaSize,
fontStyle: card.visualKind == TaskVisualKind.freeSlot
? FontStyle.italic
: FontStyle.normal,
fontWeight: card.visualKind == TaskVisualKind.flexible
? FontWeight.w700
: FontWeight.w500,
),
),
if (card.visualKind == TaskVisualKind.freeSlot) ...[
const SizedBox(height: 10),
Text(
card.timeText,
style: TextStyle(
color: color,
fontSize: FocusFlowTokens.cardMetaSize,
fontWeight: FontWeight.w600,
return LayoutBuilder(
builder: (context, constraints) {
final textBlock = Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
card.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: titleStyle,
),
),
],
],
SizedBox(height: metrics.titleMetaGap),
Text(
card.subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: card.isCompleted ? FocusFlowTokens.completedText : color,
fontSize: metrics.metaSize,
fontStyle: card.visualKind == TaskVisualKind.freeSlot
? FontStyle.italic
: FontStyle.normal,
fontWeight: card.visualKind == TaskVisualKind.flexible
? FontWeight.w700
: FontWeight.w500,
),
),
if (card.visualKind == TaskVisualKind.freeSlot) ...[
SizedBox(height: metrics.freeSlotTimeGap),
Text(
card.timeText,
style: TextStyle(
color: color,
fontSize: metrics.metaSize,
fontWeight: FontWeight.w600,
),
),
],
],
);
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {
return textBlock;
}
return FittedBox(
alignment: Alignment.centerLeft,
fit: BoxFit.scaleDown,
child: SizedBox(width: constraints.maxWidth, child: textBlock),
);
},
);
}
}
class _NoOpIcon extends StatelessWidget {
const _NoOpIcon({required this.icon, required this.color});
const _NoOpIcon({
required this.icon,
required this.color,
required this.metrics,
});
final IconData icon;
final Color color;
final _CardMetrics metrics;
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () {},
constraints: BoxConstraints.tightFor(
width: metrics.actionButtonSize,
height: metrics.actionButtonSize,
),
color: color,
iconSize: metrics.actionIconSize,
padding: EdgeInsets.zero,
icon: Icon(icon),
tooltip: 'No-op action',
visualDensity: VisualDensity.compact,
);
}
}
class _Badge extends StatelessWidget {
const _Badge({required this.child});
const _Badge({required this.child, required this.metrics});
final Widget child;
final _CardMetrics metrics;
@override
Widget build(BuildContext context) {
return Container(
width: 62,
height: 48,
width: metrics.badgeWidth,
height: metrics.badgeHeight,
alignment: Alignment.center,
decoration: BoxDecoration(
color: FocusFlowTokens.panelBackground.withValues(alpha: 0.84),
borderRadius: BorderRadius.circular(7),
borderRadius: BorderRadius.circular(metrics.badgeRadius),
border: Border.all(color: FocusFlowTokens.subtleBorder),
),
child: child,

View file

@ -9,6 +9,7 @@ import 'package:focus_flow_flutter/app/demo_scheduler_composition.dart';
import 'package:focus_flow_flutter/app/focus_flow_app.dart';
import 'package:focus_flow_flutter/models/today_screen_models.dart';
import 'package:focus_flow_flutter/widgets/timeline/difficulty_bars.dart';
import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.dart';
/// Runs FocusFlow widget and visual adapter tests.
void main() {
@ -109,6 +110,48 @@ void main() {
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
expect(find.text('Pay bill'), findsOneWidget);
});
testWidgets('task card controls scale inside a narrow task bubble', (
tester,
) async {
await _pumpTimelineCard(
tester,
card: _timelineCard(
id: 'narrow-actions',
title: 'A task title that would otherwise crowd the controls',
),
size: const Size(320, 88),
);
expect(tester.takeException(), isNull);
final button = tester.widget<IconButton>(
find.widgetWithIcon(IconButton, Icons.arrow_forward),
);
expect(button.iconSize, lessThan(18));
expect(button.constraints?.maxWidth, lessThan(32));
});
testWidgets('free slot text scales inside a short task bubble', (
tester,
) async {
await _pumpTimelineCard(
tester,
card: _timelineCard(
id: 'short-free-slot',
title: 'Free Slot',
subtitle: 'Intentional rest',
timeText: '6:15 PM - 6:30 PM',
visualKind: TaskVisualKind.freeSlot,
showsQuickActions: false,
),
size: const Size(360, 88),
);
expect(tester.takeException(), isNull);
expect(find.text('Intentional rest'), findsOneWidget);
expect(find.text('6:15 PM - 6:30 PM'), findsOneWidget);
});
}
Future<void> _pumpPlanApp(WidgetTester tester) async {
@ -136,3 +179,52 @@ Future<TodayState> _readSeededToday() async {
TaskVisualKind _kindFor(TodayScreenData data, String id) {
return data.cards.singleWhere((card) => card.id == id).visualKind;
}
Future<void> _pumpTimelineCard(
WidgetTester tester, {
required TimelineCardModel card,
required Size size,
}) async {
await tester.binding.setSurfaceSize(Size(size.width + 40, size.height + 40));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: size.width,
height: size.height,
child: TaskTimelineCard(card: card, onTap: () {}),
),
),
),
),
);
await tester.pumpAndSettle();
}
TimelineCardModel _timelineCard({
required String id,
required String title,
String subtitle = '30 min',
String timeText = '6:00 PM - 6:30 PM',
TaskVisualKind visualKind = TaskVisualKind.flexible,
bool showsQuickActions = true,
}) {
return TimelineCardModel(
id: id,
title: title,
typeLabel: 'Flexible',
subtitle: subtitle,
timeText: timeText,
startMinutes: 18 * 60,
endMinutes: 18 * 60 + 30,
visualKind: visualKind,
rewardIconToken: TimelineRewardIconToken.medium,
difficultyIconToken: TimelineDifficultyIconToken.medium,
isCompleted: false,
isSelectable: true,
showsQuickActions: showsQuickActions,
durationMinutes: 30,
);
}