feat: wire date navigation controls

This commit is contained in:
Ashley Venn 2026-07-02 08:23:17 -07:00
parent 16c460db0a
commit 98fdd0792b
8 changed files with 272 additions and 15 deletions

View file

@ -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';

View file

@ -66,6 +66,31 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
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<void> _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<FocusFlowHome> {
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<FocusFlowHome> {
onCompleteCard: _toggleCardCompletion,
onAddTask: _addGenericFlexibleTask,
onClearSelection: controller.clearSelection,
onPreviousDate: _selectPreviousDate,
onNextDate: _selectNextDate,
onChooseDate: () {
unawaited(_chooseDate());
},
),
};
},
@ -106,4 +141,33 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
),
);
}
/// 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);
}
}

View file

@ -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

View file

@ -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,

View file

@ -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,21 +65,48 @@ 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: 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,
@ -73,10 +115,13 @@ class TopBar extends StatelessWidget {
),
),
),
),
),
_IconButton(
icon: Icons.chevron_right,
metrics: metrics,
onPressed: () {},
tooltip: 'Next day',
onPressed: onNextDate,
),
const Spacer(),
_ModeToggle(metrics: metrics),

View file

@ -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,

View file

@ -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<void> _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));
}

View file

@ -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 {