diff --git a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart index af749f1..6837123 100644 --- a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart +++ b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart @@ -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 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, ), ), ], diff --git a/apps/focus_flow_flutter/lib/models/today_screen_models.dart b/apps/focus_flow_flutter/lib/models/today_screen_models.dart index 4cdc533..eb9f28e 100644 --- a/apps/focus_flow_flutter/lib/models/today_screen_models.dart +++ b/apps/focus_flow_flutter/lib/models/today_screen_models.dart @@ -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), ), diff --git a/apps/focus_flow_flutter/lib/widgets/required_banner.dart b/apps/focus_flow_flutter/lib/widgets/required_banner.dart index 817df32..0bbb5c7 100644 --- a/apps/focus_flow_flutter/lib/widgets/required_banner.dart +++ b/apps/focus_flow_flutter/lib/widgets/required_banner.dart @@ -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( diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart index eb09690..96ccdf4 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -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 createState() => _TimelineViewState(); } @@ -39,6 +47,7 @@ class TimelineView extends StatefulWidget { class _TimelineViewState extends State { final _scrollController = ScrollController(); bool _didSetInitialScroll = false; + var _handledScrollRequest = 0; @override void didUpdateWidget(covariant TimelineView oldWidget) { @@ -67,6 +76,7 @@ class _TimelineViewState extends State { 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 { _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; + }); + } } diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index b286cd5..b5e9b64 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -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(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', + ), ), );