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 8466ff4..979169f 100644 --- a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart +++ b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart @@ -7,6 +7,7 @@ library; import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart'; import '../controllers/scheduler_command_controller.dart'; import '../controllers/selected_date_controller.dart'; diff --git a/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart index 8b6ec14..6e48d31 100644 --- a/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart +++ b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart @@ -66,6 +66,31 @@ class _FocusFlowHomeState extends State { await commandController.addGenericFlexibleTask(); } + /// Moves the visible planning date backward by one day. + void _selectPreviousDate() { + selectedDateController.selectPreviousDay(); + } + + /// Moves the visible planning date forward by one day. + void _selectNextDate() { + selectedDateController.selectNextDay(); + } + + /// Opens the date picker and selects the chosen owner-local civil date. + Future _chooseDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _dateTimeForCivilDate(selectedDateController.date), + firstDate: DateTime(2000), + lastDate: DateTime(2100), + builder: _buildDatePickerTheme, + ); + if (picked == null) { + return; + } + selectedDateController.selectDate(_civilDateForDateTime(picked)); + } + /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override @@ -91,6 +116,11 @@ class _FocusFlowHomeState extends State { onCompleteCard: _toggleCardCompletion, onAddTask: _addGenericFlexibleTask, onClearSelection: controller.clearSelection, + onPreviousDate: _selectPreviousDate, + onNextDate: _selectNextDate, + onChooseDate: () { + unawaited(_chooseDate()); + }, ), TodayScreenReady(:final data) => _TodayScreenScaffold( data: data, @@ -99,6 +129,11 @@ class _FocusFlowHomeState extends State { onCompleteCard: _toggleCardCompletion, onAddTask: _addGenericFlexibleTask, onClearSelection: controller.clearSelection, + onPreviousDate: _selectPreviousDate, + onNextDate: _selectNextDate, + onChooseDate: () { + unawaited(_chooseDate()); + }, ), }; }, @@ -106,4 +141,33 @@ class _FocusFlowHomeState extends State { ), ); } + + /// Builds the dark theme wrapper used by the date picker dialog. + Widget _buildDatePickerTheme(BuildContext context, Widget? child) { + final theme = Theme.of(context); + return Theme( + data: theme.copyWith( + colorScheme: theme.colorScheme.copyWith( + primary: FocusFlowTokens.accentMagenta, + onPrimary: FocusFlowTokens.textPrimary, + surface: FocusFlowTokens.elevatedPanel, + onSurface: FocusFlowTokens.textPrimary, + ), + dialogTheme: theme.dialogTheme.copyWith( + backgroundColor: FocusFlowTokens.elevatedPanel, + ), + ), + child: child ?? const SizedBox.shrink(), + ); + } + + /// Converts [date] into a local DateTime for Flutter's date picker. + DateTime _dateTimeForCivilDate(CivilDate date) { + return DateTime(date.year, date.month, date.day); + } + + /// Converts [dateTime] into a civil date without applying a timezone shift. + CivilDate _civilDateForDateTime(DateTime dateTime) { + return CivilDate(dateTime.year, dateTime.month, dateTime.day); + } } diff --git a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart index 01defef..0c24d1a 100644 --- a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart @@ -15,6 +15,9 @@ class _TodayScreenScaffold extends StatefulWidget { required this.onCompleteCard, required this.onAddTask, required this.onClearSelection, + required this.onPreviousDate, + required this.onNextDate, + required this.onChooseDate, }); /// Stores the `data` value for this object. @@ -41,6 +44,18 @@ class _TodayScreenScaffold extends StatefulWidget { /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final VoidCallback onClearSelection; + /// Stores the `onPreviousDate` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final VoidCallback onPreviousDate; + + /// Stores the `onNextDate` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final VoidCallback onNextDate; + + /// Stores the `onChooseDate` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final VoidCallback onChooseDate; + /// Creates the mutable state object used by this stateful widget. /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. @override diff --git a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart index 8682272..5441351 100644 --- a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart @@ -6,6 +6,10 @@ part of '../focus_flow_app.dart'; /// Private implementation type for `_TodayScreenScaffoldState` in this library. /// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly. class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { + /// Shared constant value for `_modalScrimTopInset`. + /// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites. + static const _modalScrimTopInset = 96.0; + /// Private state stored as `_timelineScrollTargetCardId` for this implementation. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. String? _timelineScrollTargetCardId; @@ -55,7 +59,12 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - TopBar(dateLabel: widget.data.dateLabel), + TopBar( + dateLabel: widget.data.dateLabel, + onPreviousDate: widget.onPreviousDate, + onNextDate: widget.onNextDate, + onDatePressed: widget.onChooseDate, + ), if (widget.data.requiredBanner == null) const SizedBox(height: 16) else ...[ @@ -81,7 +90,11 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { ), ), if (widget.selectedCard != null) ...[ - Positioned.fill( + Positioned( + left: 0, + top: _modalScrimTopInset, + right: 0, + bottom: 0, child: GestureDetector( key: const ValueKey('modal-click-away'), behavior: HitTestBehavior.opaque, diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar.dart b/apps/focus_flow_flutter/lib/widgets/top_bar.dart index cf852bd..bca659c 100644 --- a/apps/focus_flow_flutter/lib/widgets/top_bar.dart +++ b/apps/focus_flow_flutter/lib/widgets/top_bar.dart @@ -16,11 +16,26 @@ part 'top_bar/top_bar_settings_button.dart'; /// Header bar for the compact Today view. class TopBar extends StatelessWidget { /// Creates a top bar with a display-ready [dateLabel]. - const TopBar({required this.dateLabel, super.key}); + const TopBar({ + required this.dateLabel, + this.onPreviousDate, + this.onNextDate, + this.onDatePressed, + super.key, + }); /// Date label shown beside the date navigation controls. final String dateLabel; + /// Callback invoked when the previous-day control is activated. + final VoidCallback? onPreviousDate; + + /// Callback invoked when the next-day control is activated. + final VoidCallback? onNextDate; + + /// Callback invoked when the choose-date control is activated. + final VoidCallback? onDatePressed; + /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override @@ -50,24 +65,53 @@ class TopBar extends StatelessWidget { _IconButton( icon: Icons.calendar_today, metrics: metrics, - onPressed: () {}, + tooltip: 'Choose date', + onPressed: onDatePressed, ), _IconButton( icon: Icons.chevron_left, metrics: metrics, - onPressed: () {}, + tooltip: 'Previous day', + onPressed: onPreviousDate, ), SizedBox( width: metrics.dateWidth, height: metrics.controlHeight, - child: Center( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - dateLabel, - style: TextStyle( - color: FocusFlowTokens.textPrimary, - fontSize: metrics.dateSize, + child: Tooltip( + message: 'Choose date', + child: Semantics( + button: true, + label: 'Choose date, $dateLabel', + child: TextButton( + key: const ValueKey('top-bar-date-button'), + onPressed: onDatePressed, + style: TextButton.styleFrom( + foregroundColor: FocusFlowTokens.textPrimary, + padding: EdgeInsets.symmetric( + horizontal: 6 * metrics.scale, + ), + minimumSize: Size( + metrics.dateWidth, + metrics.controlHeight, + ), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + metrics.borderRadius, + ), + ), + ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + dateLabel, + maxLines: 1, + overflow: TextOverflow.visible, + style: TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: metrics.dateSize, + ), + ), ), ), ), @@ -76,7 +120,8 @@ class TopBar extends StatelessWidget { _IconButton( icon: Icons.chevron_right, metrics: metrics, - onPressed: () {}, + tooltip: 'Next day', + onPressed: onNextDate, ), const Spacer(), _ModeToggle(metrics: metrics), diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_icon_button.dart b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_icon_button.dart index 2a6d6cd..dd95b36 100644 --- a/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_icon_button.dart +++ b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_icon_button.dart @@ -11,6 +11,7 @@ class _IconButton extends StatelessWidget { const _IconButton({ required this.icon, required this.metrics, + required this.tooltip, required this.onPressed, }); @@ -22,9 +23,13 @@ class _IconButton extends StatelessWidget { /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final _TopBarMetrics metrics; + /// Stores the `tooltip` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final String tooltip; + /// Stores the `onPressed` value for this object. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. - final VoidCallback onPressed; + final VoidCallback? onPressed; /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @@ -32,6 +37,7 @@ class _IconButton extends StatelessWidget { Widget build(BuildContext context) { return IconButton( onPressed: onPressed, + tooltip: tooltip, constraints: BoxConstraints.tightFor( width: metrics.iconButtonSize, height: metrics.iconButtonSize, diff --git a/apps/focus_flow_flutter/test/app/date_navigation_test.dart b/apps/focus_flow_flutter/test/app/date_navigation_test.dart new file mode 100644 index 0000000..9038355 --- /dev/null +++ b/apps/focus_flow_flutter/test/app/date_navigation_test.dart @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Tests app-level date navigation behavior. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import 'package:focus_flow_flutter/app/demo_scheduler_composition.dart'; +import 'package:focus_flow_flutter/app/focus_flow_app.dart'; + +/// Runs app-level date navigation tests. +void main() { + testWidgets('previous and next arrows reload selected dates', (tester) async { + await _pumpPlanApp(tester); + + expect(find.text('May 20, 2025'), findsOneWidget); + expect(find.text('Clean coffee maker'), findsOneWidget); + + await tester.tap(find.byTooltip('Next day')); + await tester.pumpAndSettle(); + + expect(find.text('May 21, 2025'), findsOneWidget); + expect(find.text('Clean coffee maker'), findsNothing); + + await tester.tap(find.byTooltip('Previous day')); + await tester.pumpAndSettle(); + + expect(find.text('May 20, 2025'), findsOneWidget); + expect(find.text('Clean coffee maker'), findsOneWidget); + }); + + testWidgets('date picker jumps directly to the chosen date', (tester) async { + await _pumpPlanApp(tester); + + await tester.tap(find.byKey(const ValueKey('top-bar-date-button'))); + await tester.pumpAndSettle(); + + expect(find.byType(DatePickerDialog), findsOneWidget); + + await tester.tap(find.text('15').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(find.text('May 15, 2025'), findsOneWidget); + expect(find.text('Clean coffee maker'), findsNothing); + }); + + testWidgets('open modal closes when changing dates', (tester) async { + await _pumpPlanApp(tester); + + await tester.ensureVisible(find.byKey(const ValueKey('pay-bill'))); + await tester.tap(find.byKey(const ValueKey('pay-bill'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); + + await tester.tap(find.byTooltip('Next day')); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); + expect(find.text('May 21, 2025'), findsOneWidget); + }); +} + +/// Pumps the FocusFlow app at the desktop test size. +Future _pumpPlanApp(WidgetTester tester) async { + await tester.binding.setSurfaceSize(const Size(1586, 992)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(FocusFlowApp(composition: _composition())); + await tester.pumpAndSettle(); +} + +/// Builds the seeded demo composition used by date navigation tests. +DemoSchedulerComposition _composition() { + return DemoSchedulerComposition.seeded(selectedDate: CivilDate(2025, 5, 20)); +} diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index 35a847d..6eb790b 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -418,6 +418,39 @@ void main() { expect(tester.getSize(find.byType(OutlinedButton)).width, lessThan(136)); }); + testWidgets('top bar date controls invoke callbacks', (tester) async { + var previousCount = 0; + var nextCount = 0; + var datePressedCount = 0; + + await _pumpConstrainedWidget( + tester, + size: const Size(760, 72), + child: TopBar( + dateLabel: 'June 30, 2026', + onPreviousDate: () { + previousCount += 1; + }, + onNextDate: () { + nextCount += 1; + }, + onDatePressed: () { + datePressedCount += 1; + }, + ), + ); + + await tester.tap(find.byTooltip('Previous day')); + await tester.tap(find.byTooltip('Next day')); + await tester.tap(find.byKey(const ValueKey('top-bar-date-button'))); + await tester.pumpAndSettle(); + + expect(previousCount, 1); + expect(nextCount, 1); + expect(datePressedCount, 1); + expect(find.byTooltip('Choose date'), findsWidgets); + }); + testWidgets('required banner scales inside a compact header width', ( tester, ) async {