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

View file

@ -6,7 +6,14 @@ import 'package:scheduler_core/scheduler_core.dart';
/// Presentation model for the next-required-task banner. /// Presentation model for the next-required-task banner.
class RequiredBannerModel { class RequiredBannerModel {
/// Creates a required banner model. /// 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. /// Title of the next required task.
final String title; final String title;
@ -79,6 +86,7 @@ class TodayScreenData {
requiredBanner: nextRequired == null requiredBanner: nextRequired == null
? null ? null
: RequiredBannerModel( : RequiredBannerModel(
timelineCardId: nextRequired.id,
title: nextRequired.item.displayTitle, title: nextRequired.item.displayTitle,
timeText: _formatTime(nextRequired.start), 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. /// Banner that highlights the next required task, if one exists.
class RequiredBanner extends StatelessWidget { class RequiredBanner extends StatelessWidget {
/// Creates a required-task banner. /// 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. /// Optional required task model to render.
final RequiredBannerModel? model; final RequiredBannerModel? model;
/// Callback invoked when the highlighted required task should be shown.
final VoidCallback? onShowUpcoming;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final banner = model; final banner = model;
@ -85,7 +88,7 @@ class RequiredBanner extends StatelessWidget {
), ),
), ),
SizedBox(width: metrics.buttonGap), SizedBox(width: metrics.buttonGap),
_UpcomingButton(metrics: metrics), _UpcomingButton(metrics: metrics, onPressed: onShowUpcoming),
], ],
), ),
); );
@ -127,14 +130,15 @@ class _RequiredBannerMetrics {
} }
class _UpcomingButton extends StatelessWidget { class _UpcomingButton extends StatelessWidget {
const _UpcomingButton({required this.metrics}); const _UpcomingButton({required this.metrics, required this.onPressed});
final _RequiredBannerMetrics metrics; final _RequiredBannerMetrics metrics;
final VoidCallback? onPressed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OutlinedButton( return OutlinedButton(
onPressed: () {}, onPressed: onPressed,
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: FocusFlowTokens.textPrimary, foregroundColor: FocusFlowTokens.textPrimary,
side: BorderSide( side: BorderSide(

View file

@ -17,6 +17,8 @@ class TimelineView extends StatefulWidget {
required this.range, required this.range,
required this.onCardSelected, required this.onCardSelected,
this.now, this.now,
this.scrollTargetCardId,
this.scrollRequest = 0,
super.key, super.key,
}); });
@ -32,6 +34,12 @@ class TimelineView extends StatefulWidget {
/// Clock used to center the initial scroll position. /// Clock used to center the initial scroll position.
final DateTime Function()? now; 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 @override
State<TimelineView> createState() => _TimelineViewState(); State<TimelineView> createState() => _TimelineViewState();
} }
@ -39,6 +47,7 @@ class TimelineView extends StatefulWidget {
class _TimelineViewState extends State<TimelineView> { class _TimelineViewState extends State<TimelineView> {
final _scrollController = ScrollController(); final _scrollController = ScrollController();
bool _didSetInitialScroll = false; bool _didSetInitialScroll = false;
var _handledScrollRequest = 0;
@override @override
void didUpdateWidget(covariant TimelineView oldWidget) { void didUpdateWidget(covariant TimelineView oldWidget) {
@ -67,6 +76,7 @@ class _TimelineViewState extends State<TimelineView> {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
_scheduleInitialScroll(geometry, constraints.maxHeight); _scheduleInitialScroll(geometry, constraints.maxHeight);
_scheduleTargetScroll(geometry);
return SingleChildScrollView( return SingleChildScrollView(
controller: _scrollController, controller: _scrollController,
child: SizedBox( child: SizedBox(
@ -131,4 +141,33 @@ class _TimelineViewState extends State<TimelineView> {
_didSetInitialScroll = true; _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); 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( testWidgets(
'timeline defaults near the current time while keeping full day', 'timeline defaults near the current time while keeping full day',
(tester) async { (tester) async {
@ -171,7 +182,11 @@ void main() {
tester, tester,
size: const Size(440, 72), size: const Size(440, 72),
child: const RequiredBanner( child: const RequiredBanner(
model: RequiredBannerModel(title: 'Pay bill', timeText: '6:00 PM'), model: RequiredBannerModel(
timelineCardId: 'pay-bill',
title: 'Pay bill',
timeText: '6:00 PM',
),
), ),
); );