fix: jump required banner to linked task

This commit is contained in:
Ashley Venn 2026-06-30 13:52:04 -07:00
parent adf5422917
commit 1e0e76d72f
5 changed files with 106 additions and 16 deletions

View file

@ -113,7 +113,7 @@ class _PlanIssue extends StatelessWidget {
}
}
class _TodayScreenScaffold extends StatelessWidget {
class _TodayScreenScaffold extends StatefulWidget {
const _TodayScreenScaffold({
required this.data,
required this.selectedCard,
@ -126,6 +126,25 @@ class _TodayScreenScaffold extends StatelessWidget {
final ValueChanged<TimelineCardModel> onSelectCard;
final VoidCallback onClearSelection;
@override
State<_TodayScreenScaffold> createState() => _TodayScreenScaffoldState();
}
class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
String? _timelineScrollTargetCardId;
var _timelineScrollRequest = 0;
void _showUpcomingRequiredTask() {
final targetCardId = widget.data.requiredBanner?.timelineCardId;
if (targetCardId == null) {
return;
}
setState(() {
_timelineScrollTargetCardId = targetCardId;
_timelineScrollRequest += 1;
});
}
@override
Widget build(BuildContext context) {
return Stack(
@ -140,33 +159,38 @@ class _TodayScreenScaffold extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TopBar(dateLabel: data.dateLabel),
TopBar(dateLabel: widget.data.dateLabel),
const SizedBox(height: 24),
RequiredBanner(model: data.requiredBanner),
RequiredBanner(
model: widget.data.requiredBanner,
onShowUpcoming: _showUpcomingRequiredTask,
),
const SizedBox(height: 22),
Expanded(
child: TimelineView(
cards: data.cards,
range: data.timelineRange,
onCardSelected: onSelectCard,
cards: widget.data.cards,
range: widget.data.timelineRange,
scrollTargetCardId: _timelineScrollTargetCardId,
scrollRequest: _timelineScrollRequest,
onCardSelected: widget.onSelectCard,
),
),
],
),
),
if (selectedCard != null) ...[
if (widget.selectedCard != null) ...[
Positioned.fill(
child: GestureDetector(
key: const ValueKey('modal-click-away'),
behavior: HitTestBehavior.opaque,
onTap: onClearSelection,
onTap: widget.onClearSelection,
child: const ColoredBox(color: Colors.transparent),
),
),
Center(
child: TaskSelectionModal(
card: selectedCard!,
onClose: onClearSelection,
card: widget.selectedCard!,
onClose: widget.onClearSelection,
),
),
],

View file

@ -6,7 +6,14 @@ import 'package:scheduler_core/scheduler_core.dart';
/// Presentation model for the next-required-task banner.
class RequiredBannerModel {
/// Creates a required banner model.
const RequiredBannerModel({required this.title, required this.timeText});
const RequiredBannerModel({
required this.timelineCardId,
required this.title,
required this.timeText,
});
/// Timeline card id linked to the highlighted required task.
final String timelineCardId;
/// Title of the next required task.
final String title;
@ -79,6 +86,7 @@ class TodayScreenData {
requiredBanner: nextRequired == null
? null
: RequiredBannerModel(
timelineCardId: nextRequired.id,
title: nextRequired.item.displayTitle,
timeText: _formatTime(nextRequired.start),
),

View file

@ -9,11 +9,14 @@ import '../theme/focus_flow_tokens.dart';
/// Banner that highlights the next required task, if one exists.
class RequiredBanner extends StatelessWidget {
/// Creates a required-task banner.
const RequiredBanner({this.model, super.key});
const RequiredBanner({this.model, this.onShowUpcoming, super.key});
/// Optional required task model to render.
final RequiredBannerModel? model;
/// Callback invoked when the highlighted required task should be shown.
final VoidCallback? onShowUpcoming;
@override
Widget build(BuildContext context) {
final banner = model;
@ -85,7 +88,7 @@ class RequiredBanner extends StatelessWidget {
),
),
SizedBox(width: metrics.buttonGap),
_UpcomingButton(metrics: metrics),
_UpcomingButton(metrics: metrics, onPressed: onShowUpcoming),
],
),
);
@ -127,14 +130,15 @@ class _RequiredBannerMetrics {
}
class _UpcomingButton extends StatelessWidget {
const _UpcomingButton({required this.metrics});
const _UpcomingButton({required this.metrics, required this.onPressed});
final _RequiredBannerMetrics metrics;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return OutlinedButton(
onPressed: () {},
onPressed: onPressed,
style: OutlinedButton.styleFrom(
foregroundColor: FocusFlowTokens.textPrimary,
side: BorderSide(

View file

@ -17,6 +17,8 @@ class TimelineView extends StatefulWidget {
required this.range,
required this.onCardSelected,
this.now,
this.scrollTargetCardId,
this.scrollRequest = 0,
super.key,
});
@ -32,6 +34,12 @@ class TimelineView extends StatefulWidget {
/// Clock used to center the initial scroll position.
final DateTime Function()? now;
/// Card id requested by another surface, such as the required banner.
final String? scrollTargetCardId;
/// Monotonic request number that allows repeated jumps to the same card.
final int scrollRequest;
@override
State<TimelineView> createState() => _TimelineViewState();
}
@ -39,6 +47,7 @@ class TimelineView extends StatefulWidget {
class _TimelineViewState extends State<TimelineView> {
final _scrollController = ScrollController();
bool _didSetInitialScroll = false;
var _handledScrollRequest = 0;
@override
void didUpdateWidget(covariant TimelineView oldWidget) {
@ -67,6 +76,7 @@ class _TimelineViewState extends State<TimelineView> {
return LayoutBuilder(
builder: (context, constraints) {
_scheduleInitialScroll(geometry, constraints.maxHeight);
_scheduleTargetScroll(geometry);
return SingleChildScrollView(
controller: _scrollController,
child: SizedBox(
@ -131,4 +141,33 @@ class _TimelineViewState extends State<TimelineView> {
_didSetInitialScroll = true;
});
}
void _scheduleTargetScroll(TimelineGeometry geometry) {
final targetCardId = widget.scrollTargetCardId;
if (targetCardId == null || widget.scrollRequest == _handledScrollRequest) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollController.hasClients) {
return;
}
TimelineCardModel? targetCard;
for (final card in widget.cards) {
if (card.id == targetCardId) {
targetCard = card;
break;
}
}
if (targetCard == null) {
_handledScrollRequest = widget.scrollRequest;
return;
}
final targetOffset =
geometry.yForMinutesSinceMidnight(targetCard.startMinutes) - 24;
final maxOffset = _scrollController.position.maxScrollExtent;
final offset = targetOffset.clamp(0, maxOffset).toDouble();
_scrollController.jumpTo(offset);
_handledScrollRequest = widget.scrollRequest;
});
}
}

View file

@ -123,6 +123,17 @@ void main() {
expect(TodayScreenData.defaultRange.endMinutes, 24 * 60);
});
testWidgets('required banner jumps timeline to linked task', (tester) async {
await _pumpPlanApp(tester);
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
await tester.tap(find.text('Show upcoming'));
await tester.pumpAndSettle();
expect(scrollable.position.pixels, closeTo(2622, 1));
expect(find.byKey(const ValueKey('pay-bill')), findsOneWidget);
});
testWidgets(
'timeline defaults near the current time while keeping full day',
(tester) async {
@ -171,7 +182,11 @@ void main() {
tester,
size: const Size(440, 72),
child: const RequiredBanner(
model: RequiredBannerModel(title: 'Pay bill', timeText: '6:00 PM'),
model: RequiredBannerModel(
timelineCardId: 'pay-bill',
title: 'Pay bill',
timeText: '6:00 PM',
),
),
);