forked from eva/focus-flow
fix: scale timeline task card contents
This commit is contained in:
parent
f985b3ee84
commit
dacd189435
2 changed files with 324 additions and 93 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue