feat: wire date navigation controls
This commit is contained in:
parent
16c460db0a
commit
98fdd0792b
8 changed files with 272 additions and 15 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
80
apps/focus_flow_flutter/test/app/date_navigation_test.dart
Normal file
80
apps/focus_flow_flutter/test/app/date_navigation_test.dart
Normal 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));
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue