forked from eva/focus-flow
fix: size timeline tasks to exact duration
This commit is contained in:
parent
dd6552e68d
commit
f6afe0d793
4 changed files with 228 additions and 54 deletions
|
|
@ -38,41 +38,13 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
|
||||||
constraints,
|
constraints,
|
||||||
kind: card.visualKind,
|
kind: card.visualKind,
|
||||||
);
|
);
|
||||||
return MouseRegion(
|
final content = metrics.isCompact
|
||||||
onEnter: (_) {
|
? _CompactCardText(
|
||||||
setState(() {
|
card: card,
|
||||||
_isHovered = true;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onExit: (_) {
|
|
||||||
setState(() {
|
|
||||||
_isHovered = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: InkWell(
|
|
||||||
key: ValueKey(card.id),
|
|
||||||
borderRadius: BorderRadius.circular(metrics.cardRadius),
|
|
||||||
onTap: widget.onTap,
|
|
||||||
child: Container(
|
|
||||||
padding: metrics.padding,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colors.background,
|
|
||||||
borderRadius: BorderRadius.circular(metrics.cardRadius),
|
|
||||||
border: Border.all(
|
|
||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
width: metrics.borderWidth,
|
metrics: metrics,
|
||||||
),
|
)
|
||||||
boxShadow: [
|
: Row(
|
||||||
BoxShadow(
|
|
||||||
color: colors.accent.withValues(alpha: 0.12),
|
|
||||||
blurRadius: metrics.shadowBlur,
|
|
||||||
spreadRadius: metrics.shadowSpread,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
children: [
|
||||||
_StatusRing(
|
_StatusRing(
|
||||||
card: card,
|
card: card,
|
||||||
|
|
@ -110,7 +82,43 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) {
|
||||||
|
setState(() {
|
||||||
|
_isHovered = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onExit: (_) {
|
||||||
|
setState(() {
|
||||||
|
_isHovered = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
key: ValueKey(card.id),
|
||||||
|
borderRadius: BorderRadius.circular(metrics.cardRadius),
|
||||||
|
onTap: widget.onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: metrics.padding,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
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: content,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -121,12 +129,13 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CardMetrics {
|
class _CardMetrics {
|
||||||
const _CardMetrics(this.scale);
|
const _CardMetrics({required this.scale, required this.height});
|
||||||
|
|
||||||
static const _referenceWidth = 640.0;
|
static const _referenceWidth = 640.0;
|
||||||
static const _referenceHeight = 88.0;
|
static const _referenceHeight = 88.0;
|
||||||
static const _freeSlotReferenceHeight = 108.0;
|
static const _freeSlotReferenceHeight = 108.0;
|
||||||
static const _minimumScale = 0.38;
|
static const _compactHeightThreshold = 56.0;
|
||||||
|
static const _minimumScale = 0.16;
|
||||||
|
|
||||||
factory _CardMetrics.fromConstraints(
|
factory _CardMetrics.fromConstraints(
|
||||||
BoxConstraints constraints, {
|
BoxConstraints constraints, {
|
||||||
|
|
@ -145,15 +154,19 @@ class _CardMetrics {
|
||||||
? _freeSlotReferenceHeight
|
? _freeSlotReferenceHeight
|
||||||
: _referenceHeight);
|
: _referenceHeight);
|
||||||
final scale = math.min(widthScale, heightScale).clamp(_minimumScale, 1.0);
|
final scale = math.min(widthScale, heightScale).clamp(_minimumScale, 1.0);
|
||||||
return _CardMetrics(scale.toDouble());
|
return _CardMetrics(scale: scale.toDouble(), height: height);
|
||||||
}
|
}
|
||||||
|
|
||||||
final double scale;
|
final double scale;
|
||||||
|
final double height;
|
||||||
|
|
||||||
double get cardRadius => FocusFlowTokens.cardRadius * scale;
|
bool get isCompact => height < _compactHeightThreshold;
|
||||||
double get borderWidth => math.max(1, 1.2 * scale);
|
|
||||||
double get shadowBlur => 18 * scale;
|
double get cardRadius =>
|
||||||
double get shadowSpread => scale;
|
math.min(FocusFlowTokens.cardRadius * scale, height / 2);
|
||||||
|
double get borderWidth => math.max(0.75, 1.2 * scale);
|
||||||
|
double get shadowBlur => isCompact ? 0 : 18 * scale;
|
||||||
|
double get shadowSpread => isCompact ? 0 : scale;
|
||||||
double get statusRingSize => 34 * scale;
|
double get statusRingSize => 34 * scale;
|
||||||
double get statusBorderWidth => math.max(1.2, 3 * scale);
|
double get statusBorderWidth => math.max(1.2, 3 * scale);
|
||||||
double get freeSlotStatusBorderWidth => math.max(1, 2 * scale);
|
double get freeSlotStatusBorderWidth => math.max(1, 2 * scale);
|
||||||
|
|
@ -171,9 +184,17 @@ class _CardMetrics {
|
||||||
double get metaSize => FocusFlowTokens.cardMetaSize * scale;
|
double get metaSize => FocusFlowTokens.cardMetaSize * scale;
|
||||||
double get titleMetaGap => 4 * scale;
|
double get titleMetaGap => 4 * scale;
|
||||||
double get freeSlotTimeGap => 10 * scale;
|
double get freeSlotTimeGap => 10 * scale;
|
||||||
|
double get compactTextGap => math.max(6, 16 * scale);
|
||||||
Size get difficultySize => Size(54 * scale, 34 * scale);
|
Size get difficultySize => Size(54 * scale, 34 * scale);
|
||||||
EdgeInsets get padding =>
|
EdgeInsets get padding {
|
||||||
EdgeInsets.fromLTRB(18 * scale, 10 * scale, 14 * scale, 10 * scale);
|
if (isCompact) {
|
||||||
|
return EdgeInsets.symmetric(
|
||||||
|
horizontal: math.max(4, 12 * scale),
|
||||||
|
vertical: math.max(1, 3 * scale),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return EdgeInsets.fromLTRB(18 * scale, 10 * scale, 14 * scale, 10 * scale);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StatusRing extends StatelessWidget {
|
class _StatusRing extends StatelessWidget {
|
||||||
|
|
@ -287,6 +308,87 @@ class _CardText extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CompactCardText extends StatelessWidget {
|
||||||
|
const _CompactCardText({
|
||||||
|
required this.card,
|
||||||
|
required this.color,
|
||||||
|
required this.metrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TimelineCardModel card;
|
||||||
|
final Color color;
|
||||||
|
final _CardMetrics metrics;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final metaText = _compactMetaText(card);
|
||||||
|
final titleStyle = TextStyle(
|
||||||
|
color: card.isCompleted
|
||||||
|
? FocusFlowTokens.completedText
|
||||||
|
: FocusFlowTokens.textPrimary,
|
||||||
|
fontSize: metrics.titleSize,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
decoration: card.isCompleted ? TextDecoration.lineThrough : null,
|
||||||
|
decorationColor: FocusFlowTokens.completedText,
|
||||||
|
decorationThickness: math.max(1, 2 * metrics.scale),
|
||||||
|
);
|
||||||
|
final metaStyle = TextStyle(
|
||||||
|
color: card.isCompleted ? FocusFlowTokens.completedText : color,
|
||||||
|
fontSize: metrics.metaSize,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
);
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final line = Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: Text(
|
||||||
|
card.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: titleStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: metrics.compactTextGap),
|
||||||
|
Flexible(
|
||||||
|
flex: 2,
|
||||||
|
child: Text(
|
||||||
|
metaText,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: metaStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
return FittedBox(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: SizedBox(width: constraints.maxWidth, child: line),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _compactMetaText(TimelineCardModel card) {
|
||||||
|
if (card.visualKind == TaskVisualKind.freeSlot &&
|
||||||
|
card.timeText.isNotEmpty) {
|
||||||
|
return card.timeText;
|
||||||
|
}
|
||||||
|
if (card.subtitle.isNotEmpty) {
|
||||||
|
return card.subtitle;
|
||||||
|
}
|
||||||
|
return card.timeText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _QuickActions extends StatelessWidget {
|
class _QuickActions extends StatelessWidget {
|
||||||
const _QuickActions({
|
const _QuickActions({
|
||||||
required this.color,
|
required this.color,
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,11 @@ class TimelineGeometry {
|
||||||
required this.startMinutes,
|
required this.startMinutes,
|
||||||
required this.endMinutes,
|
required this.endMinutes,
|
||||||
required this.pixelsPerMinute,
|
required this.pixelsPerMinute,
|
||||||
this.minCardHeight = 72,
|
this.minCardHeight = 0,
|
||||||
}) : assert(startMinutes >= 0),
|
}) : assert(startMinutes >= 0),
|
||||||
assert(endMinutes <= 24 * 60),
|
assert(endMinutes <= 24 * 60),
|
||||||
assert(endMinutes > startMinutes);
|
assert(endMinutes > startMinutes),
|
||||||
|
assert(minCardHeight >= 0);
|
||||||
|
|
||||||
/// First visible minute from midnight.
|
/// First visible minute from midnight.
|
||||||
final int startMinutes;
|
final int startMinutes;
|
||||||
|
|
@ -23,6 +24,9 @@ class TimelineGeometry {
|
||||||
final double pixelsPerMinute;
|
final double pixelsPerMinute;
|
||||||
|
|
||||||
/// Minimum rendered height for a timeline card.
|
/// Minimum rendered height for a timeline card.
|
||||||
|
///
|
||||||
|
/// Defaults to zero so scheduled cards match their exact duration on the
|
||||||
|
/// timeline.
|
||||||
final double minCardHeight;
|
final double minCardHeight;
|
||||||
|
|
||||||
/// Total vertical height of the visible timeline.
|
/// Total vertical height of the visible timeline.
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,6 @@ class _TimelineViewState extends State<TimelineView> {
|
||||||
startMinutes: widget.range.startMinutes,
|
startMinutes: widget.range.startMinutes,
|
||||||
endMinutes: widget.range.endMinutes,
|
endMinutes: widget.range.endMinutes,
|
||||||
pixelsPerMinute: 2.45,
|
pixelsPerMinute: 2.45,
|
||||||
minCardHeight: 88,
|
|
||||||
);
|
);
|
||||||
final contentHeight = geometry.totalHeight + 24;
|
final contentHeight = geometry.totalHeight + 24;
|
||||||
final placements = _TimelineCardPlacement.pack(
|
final placements = _TimelineCardPlacement.pack(
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,50 @@ void main() {
|
||||||
expect(TodayScreenData.defaultRange.endMinutes, 24 * 60);
|
expect(TodayScreenData.defaultRange.endMinutes, 24 * 60);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('timeline task bubbles match duration height exactly', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpConstrainedWidget(
|
||||||
|
tester,
|
||||||
|
size: const Size(680, 360),
|
||||||
|
child: TimelineView(
|
||||||
|
cards: [
|
||||||
|
_timelineCard(
|
||||||
|
id: 'five-minute-task',
|
||||||
|
title: 'Five minute task',
|
||||||
|
subtitle: '5 min',
|
||||||
|
startMinutes: 8 * 60,
|
||||||
|
endMinutes: 8 * 60 + 5,
|
||||||
|
),
|
||||||
|
_timelineCard(
|
||||||
|
id: 'fifteen-minute-task',
|
||||||
|
title: 'Fifteen minute task',
|
||||||
|
subtitle: '15 min',
|
||||||
|
startMinutes: 8 * 60 + 5,
|
||||||
|
endMinutes: 8 * 60 + 20,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
range: TodayScreenData.defaultRange,
|
||||||
|
now: () => DateTime(2026, 6, 30, 8),
|
||||||
|
onCardSelected: (_) {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
final fiveMinute = tester.getRect(
|
||||||
|
find.byKey(const ValueKey('five-minute-task')),
|
||||||
|
);
|
||||||
|
final fifteenMinute = tester.getRect(
|
||||||
|
find.byKey(const ValueKey('fifteen-minute-task')),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fiveMinute.height, closeTo(5 * 2.45, 0.5));
|
||||||
|
expect(fifteenMinute.height, closeTo(15 * 2.45, 0.5));
|
||||||
|
expect(fiveMinute.left, closeTo(fifteenMinute.left, 1));
|
||||||
|
expect(fiveMinute.bottom, closeTo(fifteenMinute.top, 1));
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('required banner jumps timeline to linked task', (tester) async {
|
testWidgets('required banner jumps timeline to linked task', (tester) async {
|
||||||
await _pumpPlanApp(tester);
|
await _pumpPlanApp(tester);
|
||||||
|
|
||||||
|
|
@ -255,8 +299,10 @@ void main() {
|
||||||
expect(waterPlants.width, closeTo(sortMail.width, 1));
|
expect(waterPlants.width, closeTo(sortMail.width, 1));
|
||||||
expect(sortMail.right, lessThan(startLaundry.left));
|
expect(sortMail.right, lessThan(startLaundry.left));
|
||||||
expect(waterPlants.right, lessThan(startLaundry.left));
|
expect(waterPlants.right, lessThan(startLaundry.left));
|
||||||
expect(cleanedKitchenEarly.left, greaterThan(cleanCoffee.left));
|
expect(cleanCoffee.left, closeTo(cleanedKitchenEarly.left, 1));
|
||||||
expect(cleanCoffee.right, lessThan(cleanedKitchenEarly.left));
|
expect(cleanCoffee.height, closeTo(15 * 2.45, 0.5));
|
||||||
|
expect(cleanedKitchenEarly.height, closeTo(15 * 2.45, 0.5));
|
||||||
|
expect(cleanCoffee.bottom, lessThan(cleanedKitchenEarly.top));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('top bar controls scale inside a compact header width', (
|
testWidgets('top bar controls scale inside a compact header width', (
|
||||||
|
|
@ -337,6 +383,29 @@ void main() {
|
||||||
await gesture.removePointer();
|
await gesture.removePointer();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('short task card keeps time inline with title', (tester) async {
|
||||||
|
await _pumpTimelineCard(
|
||||||
|
tester,
|
||||||
|
card: _timelineCard(
|
||||||
|
id: 'short-inline-time',
|
||||||
|
title: 'Five minute reset',
|
||||||
|
subtitle: '5 min',
|
||||||
|
timeText: '6:00 PM - 6:05 PM',
|
||||||
|
startMinutes: 18 * 60,
|
||||||
|
endMinutes: 18 * 60 + 5,
|
||||||
|
),
|
||||||
|
size: const Size(360, 24),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
final title = tester.getRect(find.text('Five minute reset'));
|
||||||
|
final time = tester.getRect(find.text('5 min'));
|
||||||
|
|
||||||
|
expect(title.right, lessThan(time.left));
|
||||||
|
expect((title.center.dy - time.center.dy).abs(), lessThan(2));
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('free slot text scales inside a short task bubble', (
|
testWidgets('free slot text scales inside a short task bubble', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
@ -457,6 +526,6 @@ TimelineCardModel _timelineCard({
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
isSelectable: true,
|
isSelectable: true,
|
||||||
showsQuickActions: showsQuickActions,
|
showsQuickActions: showsQuickActions,
|
||||||
durationMinutes: 30,
|
durationMinutes: endMinutes - startMinutes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue