diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart index d7046a1..9e4ff41 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart @@ -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, diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index 3b111f0..90960f2 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -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( + 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 _pumpPlanApp(WidgetTester tester) async { @@ -136,3 +179,52 @@ Future _readSeededToday() async { TaskVisualKind _kindFor(TodayScreenData data, String id) { return data.cards.singleWhere((card) => card.id == id).visualKind; } + +Future _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, + ); +}