// SPDX-FileCopyrightText: 2026 FocusFlow contributors // SPDX-License-Identifier: AGPL-3.0-only /// Tests Widget behavior in the FocusFlow Flutter app. library; import 'dart:async'; import 'package:flutter/gestures.dart'; 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'; import 'package:focus_flow_flutter/models/today_screen_models.dart'; import 'package:focus_flow_flutter/theme/focus_flow_theme.dart'; import 'package:focus_flow_flutter/widgets/required_banner.dart'; import 'package:focus_flow_flutter/widgets/task_selection_modal.dart'; import 'package:focus_flow_flutter/widgets/timeline/difficulty_bars.dart'; import 'package:focus_flow_flutter/widgets/timeline/reward_icon.dart'; import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.dart'; import 'package:focus_flow_flutter/widgets/timeline/timeline_axis.dart'; import 'package:focus_flow_flutter/widgets/timeline/timeline_geometry.dart'; import 'package:focus_flow_flutter/widgets/timeline/timeline_view.dart'; import 'package:focus_flow_flutter/widgets/top_bar.dart'; /// Runs FocusFlow widget and visual adapter tests. void main() { testWidgets('app shell renders compact Today frame', (tester) async { await _pumpPlanApp(tester); expect(find.text('FocusFlow'), findsOneWidget); expect(find.text('Today'), findsWidgets); expect(find.text('Backlog'), findsOneWidget); expect(find.text('Projects'), findsOneWidget); expect(find.text('Reports'), findsOneWidget); expect(find.text('Settings'), findsWidgets); expect(find.text('Compact'), findsOneWidget); expect(find.text('Normal'), findsOneWidget); expect(find.byKey(const ValueKey('nav-today-active')), findsOneWidget); }); testWidgets('sidebar switches to Backlog shell', (tester) async { await _pumpPlanApp(tester); await tester.tap(find.text('Backlog').first); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('nav-backlog-active')), findsOneWidget); expect(find.byKey(const ValueKey('nav-today-active')), findsNothing); expect( find.text('Choose work that fits your current energy.'), findsOneWidget, ); expect(find.byKey(const ValueKey('backlog-search-field')), findsOneWidget); expect(find.text('Board'), findsOneWidget); expect(find.text('New Task'), findsOneWidget); expect(find.text('Backlog summary'), findsOneWidget); expect(find.text('24 tasks'), findsOneWidget); }); testWidgets('seeded Today renders backend-backed compact mockup data', ( tester, ) async { await _pumpPlanApp(tester); expect(find.text('May 20, 2025'), findsOneWidget); expect( find.textContaining('Next required task:', findRichText: true), findsOneWidget, ); expect(find.text('Clean coffee maker'), findsOneWidget); expect(find.text('Cleaned kitchen counter'), findsOneWidget); expect(find.text('Completed at: 8:05 AM'), findsOneWidget); expect(find.textContaining('Surprise task'), findsNothing); expect(find.text('Sort mail'), findsOneWidget); expect(find.text('Start laundry'), findsOneWidget); expect(find.text('Water plants'), findsOneWidget); expect(find.text('Pay bill'), findsOneWidget); expect(find.text('Doctor appointment'), findsOneWidget); expect(find.text('Free Slot'), findsOneWidget); expect(find.text('Intentional rest'), findsOneWidget); }); test( 'visual token adapter maps seeded items to expected visual kinds', () async { final state = await _readSeededToday(); final data = TodayScreenData.fromTodayState( state, now: DateTime.utc(2025, 5, 20, 15, 20), ); expect(_kindFor(data, 'clean-coffee-maker'), TaskVisualKind.flexible); expect(_kindFor(data, 'sort-mail'), TaskVisualKind.flexible); expect(_kindFor(data, 'start-laundry'), TaskVisualKind.flexible); expect(_kindFor(data, 'water-plants'), TaskVisualKind.flexible); expect(_kindFor(data, 'pay-bill'), TaskVisualKind.required); expect(_kindFor(data, 'doctor-appointment'), TaskVisualKind.appointment); expect(_kindFor(data, 'free-slot'), TaskVisualKind.freeSlot); expect( _kindFor(data, 'cleaned-kitchen-counter'), TaskVisualKind.completedSurprise, ); }, ); test('difficulty bars map every difficulty level to five-bar fill count', () { expect(DifficultyBars.fillCountForLevel(DifficultyLevel.notSet), 0); expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryEasy), 1); expect(DifficultyBars.fillCountForLevel(DifficultyLevel.easy), 2); expect(DifficultyBars.fillCountForLevel(DifficultyLevel.medium), 3); expect(DifficultyBars.fillCountForLevel(DifficultyLevel.hard), 4); expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5); }); testWidgets('task modal opens closes and keeps compact actions inert', ( tester, ) async { await _pumpPlanApp(tester); expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); await _scrollIntoView(tester, 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); expect(find.text('Pay bill'), findsNWidgets(2)); expect(find.textContaining('Required'), findsWidgets); expect(find.byTooltip('Reward level 2'), findsOneWidget); expect(find.byTooltip('Medium effort'), findsOneWidget); expect(find.byTooltip('Mark complete'), findsNothing); expect(find.text('Reward level 2'), findsNothing); expect(find.text('Medium effort'), findsNothing); expect(find.text('Done'), findsNothing); expect(find.text('Push'), findsWidgets); expect(find.text('Move to Backlog'), findsNothing); expect(find.text('Backlog'), findsWidgets); expect(find.text('Break up'), findsOneWidget); await tester.tap(find.text('Break up')); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); expect(find.text('Pay bill'), findsNWidgets(2)); await tester.tap(find.byKey(const ValueKey('close-task-modal'))); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); await _scrollIntoView(tester, const ValueKey('pay-bill')); await tester.tap(find.byKey(const ValueKey('pay-bill'))); await tester.pumpAndSettle(); await tester.tapAt(const Offset(1450, 140)); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); expect(find.text('Pay bill'), findsOneWidget); }); testWidgets('task modal Push exposes destination labels and callbacks', ( tester, ) async { var nextCount = 0; var tomorrowCount = 0; var backlogCount = 0; Future openMenu() async { await tester.tap( find.byKey(const ValueKey('modal-push-modal-push-button')), ); await tester.pumpAndSettle(); } await _pumpConstrainedWidget( tester, size: const Size(640, 380), child: TaskSelectionModal( card: _timelineCard( id: 'modal-push', title: 'Modal push task', rewardIconToken: TimelineRewardIconToken.notSet, difficultyIconToken: TimelineDifficultyIconToken.notSet, ), onClose: () {}, onPushToNext: (_) async { nextCount += 1; }, onPushToTomorrow: (_) async { tomorrowCount += 1; }, onPushToBacklog: (_) async { backlogCount += 1; }, ), ); expect(find.byTooltip('Not Set'), findsNWidgets(2)); expect(find.byTooltip('Push task'), findsNothing); expect(find.text('Remove'), findsOneWidget); await openMenu(); expect(find.text('Push to next'), findsOneWidget); expect(find.text('Push to tomorrow'), findsOneWidget); expect(find.text('Push to backlog'), findsOneWidget); await tester.tap(find.text('Push to next')); await tester.pumpAndSettle(); expect(nextCount, 1); await openMenu(); await tester.tap(find.text('Push to tomorrow')); await tester.pumpAndSettle(); expect(tomorrowCount, 1); await openMenu(); await tester.tap(find.text('Push to backlog')); await tester.pumpAndSettle(); expect(backlogCount, 1); }); testWidgets('task modal Remove deletes task and closes modal', ( tester, ) async { final composition = _composition(); await _pumpPlanApp(tester, composition: composition); await _scrollIntoView(tester, const ValueKey('water-plants')); await tester.tap(find.byKey(const ValueKey('water-plants'))); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); expect(find.text('Water plants'), findsWidgets); await tester.tap(find.text('Remove')); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); expect(find.text('Water plants'), findsNothing); expect( composition.store.currentTasks.any((task) => task.id == 'water-plants'), isFalse, ); }); test('Today screen timeline range covers the full day', () { expect(TodayScreenData.defaultRange.startMinutes, 0); expect(TodayScreenData.defaultRange.endMinutes, 24 * 60); }); testWidgets('timeline task bubbles match duration height exactly', ( tester, ) async { await _pumpConstrainedWidget( tester, size: const Size(680, 360), child: TimelineView( cards: [ _timelineCard( id: 'five-minute-task', title: 'Five minute task', subtitle: '5 min', startMinutes: 8 * 60, endMinutes: 8 * 60 + 5, ), _timelineCard( id: 'fifteen-minute-task', title: 'Fifteen minute task', subtitle: '15 min', startMinutes: 8 * 60 + 5, endMinutes: 8 * 60 + 20, ), ], range: TodayScreenData.defaultRange, now: () => DateTime(2026, 6, 30, 8), onCardSelected: (_) {}, ), ); expect(tester.takeException(), isNull); final fiveMinute = tester.getRect( find.byKey(const ValueKey('five-minute-task')), ); final fifteenMinute = tester.getRect( find.byKey(const ValueKey('fifteen-minute-task')), ); expect(fiveMinute.height, closeTo(5 * 2.45, 0.5)); expect(fifteenMinute.height, closeTo(15 * 2.45, 0.5)); expect(fiveMinute.left, closeTo(fifteenMinute.left, 1)); expect(fiveMinute.bottom, closeTo(fifteenMinute.top, 1)); }); 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(2634, 1)); expect(find.byKey(const ValueKey('pay-bill')), findsOneWidget); }); testWidgets( 'timeline defaults with current time one quarter down the viewport', (tester) async { await _pumpConstrainedWidget( tester, size: const Size(680, 400), child: TimelineView( cards: const [], range: TodayScreenData.defaultRange, now: () => DateTime(2026, 6, 30, 12), onCardSelected: (_) {}, ), ); expect(tester.takeException(), isNull); expect(find.text('11:00 PM'), findsOneWidget); expect(find.text('12:00 AM'), findsWidgets); final scrollable = tester.state(find.byType(Scrollable)); final position = scrollable.position; expect(position.maxScrollExtent, greaterThan(3000)); expect(position.pixels, closeTo(1676, 1)); }, ); testWidgets('timeline scroll-to-now request reuses initial scroll math', ( tester, ) async { var request = 0; var currentTime = DateTime(2026, 6, 30, 12); late StateSetter updateTimeline; await _pumpConstrainedWidget( tester, size: const Size(680, 400), child: StatefulBuilder( builder: (context, setState) { updateTimeline = setState; return TimelineView( cards: const [], range: TodayScreenData.defaultRange, now: () => currentTime, scrollToNowRequest: request, onCardSelected: (_) {}, ); }, ), ); final scrollable = tester.state(find.byType(Scrollable)); expect(scrollable.position.pixels, closeTo(1676, 1)); scrollable.position.jumpTo(0); await tester.pump(); updateTimeline(() { currentTime = DateTime(2026, 6, 30, 18); request += 1; }); await tester.pumpAndSettle(); expect(scrollable.position.pixels, closeTo(2558, 1)); }); testWidgets('timeline hour labels stay on one line', (tester) async { await _pumpConstrainedWidget( tester, size: const Size(140, 220), child: const TimelineAxis( geometry: TimelineGeometry( startMinutes: 0, endMinutes: 60, pixelsPerMinute: 2.45, ), ), ); expect(tester.takeException(), isNull); final midnight = tester.widget(find.text('12:00 AM')); final oneAm = tester.widget(find.text('1:00 AM')); expect(midnight.maxLines, 1); expect(oneAm.maxLines, 1); expect(midnight.softWrap, isFalse); expect(oneAm.softWrap, isFalse); expect(midnight.overflow, TextOverflow.visible); expect(oneAm.overflow, TextOverflow.visible); expect( tester.getSize(find.text('12:00 AM')).height, closeTo(tester.getSize(find.text('1:00 AM')).height, 0.1), ); expect( tester.getRect(find.text('12:00 AM')).top, greaterThanOrEqualTo(tester.getRect(find.byType(TimelineAxis)).top), ); }); testWidgets('timeline scrolls with a mouse drag', (tester) async { await _pumpConstrainedWidget( tester, size: const Size(680, 400), child: TimelineView( cards: const [], range: TodayScreenData.defaultRange, now: () => DateTime(2026, 6, 30, 12), onCardSelected: (_) {}, ), ); expect(tester.takeException(), isNull); final scrollable = tester.state(find.byType(Scrollable)); final beforeDrag = scrollable.position.pixels; await tester.drag( find.byType(SingleChildScrollView), const Offset(0, -180), kind: PointerDeviceKind.mouse, ); await tester.pumpAndSettle(); expect(scrollable.position.pixels, greaterThan(beforeDrag + 80)); }); testWidgets('timeline context menu adds a generic flexible task', ( tester, ) async { final composition = _composition(); await _pumpPlanApp(tester, composition: composition); await tester.tapAt( tester.getCenter(find.byType(TimelineView)), buttons: kSecondaryMouseButton, ); await tester.pumpAndSettle(); expect(find.text('Add new task'), findsOneWidget); await tester.tap(find.text('Add new task')); await tester.pumpAndSettle(); final addedTask = composition.store.currentTasks.singleWhere( (task) => task.title == 'New flexible task', ); expect(addedTask.type, TaskType.flexible); expect(addedTask.status, TaskStatus.planned); expect(addedTask.durationMinutes, 30); expect(find.text('New flexible task'), findsOneWidget); }); testWidgets('timeline splits partial overlaps and reuses empty lanes', ( tester, ) async { await _pumpConstrainedWidget( tester, size: const Size(680, 520), child: TimelineView( cards: [ _timelineCard( id: 'left-first', title: 'Left first', startMinutes: 9 * 60, endMinutes: 10 * 60, ), _timelineCard( id: 'right-middle', title: 'Right middle', startMinutes: 9 * 60 + 30, endMinutes: 10 * 60 + 30, ), _timelineCard( id: 'left-reused', title: 'Left reused', startMinutes: 10 * 60, endMinutes: 11 * 60, ), ], range: TodayScreenData.defaultRange, now: () => DateTime(2026, 6, 30, 9, 30), onCardSelected: (_) {}, ), ); expect(tester.takeException(), isNull); final leftFirst = tester.getRect(find.byKey(const ValueKey('left-first'))); final rightMiddle = tester.getRect( find.byKey(const ValueKey('right-middle')), ); final leftReused = tester.getRect( find.byKey(const ValueKey('left-reused')), ); expect(rightMiddle.left, greaterThan(leftFirst.left)); expect(leftReused.left, closeTo(leftFirst.left, 1)); expect(leftFirst.width, closeTo(rightMiddle.width, 1)); expect(leftReused.width, closeTo(leftFirst.width, 1)); expect(leftFirst.right, lessThan(rightMiddle.left)); expect(leftReused.right, lessThan(rightMiddle.left)); }); testWidgets('seeded demo partial overlaps pack into two lanes', ( tester, ) async { final state = await _readSeededToday(); final data = TodayScreenData.fromTodayState( state, now: DateTime.utc(2025, 5, 20, 15, 20), ); await _pumpConstrainedWidget( tester, size: const Size(900, 620), child: TimelineView( cards: data.cards, range: data.timelineRange, now: () => DateTime(2025, 5, 20, 15, 20), onCardSelected: (_) {}, ), ); expect(tester.takeException(), isNull); final sortMail = tester.getRect(find.byKey(const ValueKey('sort-mail'))); final startLaundry = tester.getRect( find.byKey(const ValueKey('start-laundry')), ); final waterPlants = tester.getRect( find.byKey(const ValueKey('water-plants')), ); final cleanCoffee = tester.getRect( find.byKey(const ValueKey('clean-coffee-maker')), ); final cleanedKitchenCounter = tester.getRect( find.byKey(const ValueKey('cleaned-kitchen-counter')), ); expect(startLaundry.left, greaterThan(sortMail.left)); expect(waterPlants.left, closeTo(sortMail.left, 1)); expect(sortMail.width, closeTo(startLaundry.width, 1)); expect(waterPlants.width, closeTo(sortMail.width, 1)); expect(sortMail.right, lessThan(startLaundry.left)); expect(waterPlants.right, lessThan(startLaundry.left)); expect(cleanCoffee.height, closeTo(15 * 2.45, 0.5)); expect(cleanedKitchenCounter.height, closeTo(15 * 2.45, 0.5)); expect( data.cards .singleWhere((card) => card.id == 'cleaned-kitchen-counter') .startMinutes, 8 * 60 + 15, ); }); testWidgets('top bar controls scale inside a compact header width', ( tester, ) async { await _pumpConstrainedWidget( tester, size: const Size(520, 72), child: const TopBar(dateLabel: 'June 30, 2026'), ); expect(tester.takeException(), isNull); expect(find.text('Compact'), findsOneWidget); expect(find.text('Normal'), findsOneWidget); expect(find.text('Settings'), findsOneWidget); 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; var dateLabelPressedCount = 0; await _pumpConstrainedWidget( tester, size: const Size(760, 72), child: TopBar( dateLabel: 'June 30, 2026', onPreviousDate: () { previousCount += 1; }, onNextDate: () { nextCount += 1; }, onDatePressed: () { datePressedCount += 1; }, onDateLabelPressed: () { dateLabelPressedCount += 1; }, ), ); await tester.tap(find.byTooltip('Previous day')); await tester.tap(find.byTooltip('Next day')); await tester.tap(find.byTooltip('Choose date')); await tester.tap(find.byKey(const ValueKey('top-bar-date-button'))); await tester.pumpAndSettle(); expect(previousCount, 1); expect(nextCount, 1); expect(datePressedCount, 1); expect(dateLabelPressedCount, 1); expect(find.byTooltip('Choose date'), findsOneWidget); expect(find.byTooltip('Scroll to current time'), findsOneWidget); }); testWidgets('required banner scales inside a compact header width', ( tester, ) async { await _pumpConstrainedWidget( tester, size: const Size(440, 72), child: const RequiredBanner( model: RequiredBannerModel( timelineCardId: 'pay-bill', title: 'Pay bill', timeText: '6:00 PM', ), ), ); expect(tester.takeException(), isNull); expect( find.textContaining('Next required task:', findRichText: true), findsOneWidget, ); expect(find.text('Show upcoming'), findsOneWidget); expect(tester.getSize(find.byType(OutlinedButton)).width, lessThan(176)); }); testWidgets('empty required banner does not reserve vertical space', ( tester, ) async { await tester.binding.setSurfaceSize(const Size(480, 160)); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( MaterialApp( theme: FocusFlowTheme.dark(), home: const Scaffold( body: Align(alignment: Alignment.topLeft, child: RequiredBanner()), ), ), ); await tester.pumpAndSettle(); expect(tester.getSize(find.byType(RequiredBanner)).height, 0); }); testWidgets('task card controls scale inside a narrow task bubble', ( tester, ) async { await _pumpTimelineCard( tester, card: _timelineCard( id: 'narrow-actions', title: 'A task title that would otherwise crowd the controls', ), size: const Size(320, 88), ); expect(tester.takeException(), isNull); expect( tester .widget( find.byKey(const ValueKey('narrow-actions-quick-actions-opacity')), ) .opacity, 0, ); final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); await gesture.addPointer( location: tester.getCenter(find.byKey(const ValueKey('narrow-actions'))), ); await tester.pumpAndSettle(); expect( tester .widget( find.byKey(const ValueKey('narrow-actions-quick-actions-opacity')), ) .opacity, 1, ); final actionBackground = tester.widget( find.byKey(const ValueKey('narrow-actions-quick-actions-background')), ); final decoration = actionBackground.decoration; expect(decoration, isA()); final boxDecoration = decoration! as BoxDecoration; expect(boxDecoration.color, const Color(0xFF021A0C)); expect(boxDecoration.border, isNull); final icon = tester.widget(find.byIcon(Icons.arrow_forward)); expect(icon.size, lessThan(18)); expect( tester.getSize(find.byIcon(Icons.arrow_forward)).width, lessThan(32), ); await gesture.removePointer(); }); testWidgets('past incomplete flexible card renders Push', (tester) async { await _pumpTimelineCard( tester, card: _timelineCard( id: 'past-flexible', title: 'Past flexible task', showPastTaskPushButton: true, ), size: const Size(360, 88), onPushToNext: (_) async {}, ); expect(find.text('Push'), findsOneWidget); expect( find.byKey(const ValueKey('past-flexible-push-button')), findsOneWidget, ); }); testWidgets('completed and future cards do not render Push', (tester) async { await _pumpTimelineCard( tester, card: _timelineCard( id: 'completed-flexible', title: 'Completed flexible task', isCompleted: true, showsQuickActions: false, ), size: const Size(360, 88), ); expect(find.text('Push'), findsNothing); await _pumpTimelineCard( tester, card: _timelineCard(id: 'future-flexible', title: 'Future flexible task'), size: const Size(360, 88), ); expect(find.text('Push'), findsNothing); }); testWidgets('past incomplete flexible card suppresses hover quick actions', ( tester, ) async { await _pumpTimelineCard( tester, card: _timelineCard( id: 'past-no-hover', title: 'Past task', showPastTaskPushButton: true, ), size: const Size(360, 88), onPushToNext: (_) async {}, ); final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); await gesture.addPointer( location: tester.getCenter(find.byKey(const ValueKey('past-no-hover'))), ); await tester.pumpAndSettle(); expect(find.byType(AnimatedOpacity), findsNothing); expect(find.byIcon(Icons.arrow_forward), findsNothing); await gesture.removePointer(); }); testWidgets('Push menu exposes destination labels and callbacks', ( tester, ) async { var nextCount = 0; var tomorrowCount = 0; var backlogCount = 0; Future openMenu() async { await tester.tap(find.byKey(const ValueKey('push-menu-push-button'))); await tester.pumpAndSettle(); } await _pumpTimelineCard( tester, card: _timelineCard( id: 'push-menu', title: 'Past task', showPastTaskPushButton: true, ), size: const Size(420, 88), onPushToNext: (_) async { nextCount += 1; }, onPushToTomorrow: (_) async { tomorrowCount += 1; }, onPushToBacklog: (_) async { backlogCount += 1; }, ); await openMenu(); expect(find.text('Push to next'), findsOneWidget); expect(find.text('Push to tomorrow'), findsOneWidget); expect(find.text('Push to backlog'), findsOneWidget); expect(find.textContaining('Push to '), findsNWidgets(3)); await tester.tap(find.text('Push to next')); await tester.pumpAndSettle(); expect(nextCount, 1); await openMenu(); await tester.tap(find.text('Push to tomorrow')); await tester.pumpAndSettle(); expect(tomorrowCount, 1); await openMenu(); await tester.tap(find.text('Push to backlog')); await tester.pumpAndSettle(); expect(backlogCount, 1); }); testWidgets('hover arrow exposes the same Push destination menu', ( tester, ) async { var nextCount = 0; var tomorrowCount = 0; var backlogCount = 0; Future openMenu() async { await tester.tap( find.byKey(const ValueKey('quick-push-menu-quick-push-button')), ); await tester.pumpAndSettle(); } await _pumpTimelineCard( tester, card: _timelineCard(id: 'quick-push-menu', title: 'Current task'), size: const Size(420, 88), onPushToNext: (_) async { nextCount += 1; }, onPushToTomorrow: (_) async { tomorrowCount += 1; }, onPushToBacklog: (_) async { backlogCount += 1; }, ); final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); await gesture.addPointer( location: tester.getCenter(find.byKey(const ValueKey('quick-push-menu'))), ); await tester.pumpAndSettle(); expect(find.byIcon(Icons.check), findsNothing); await openMenu(); expect(find.text('Push to next'), findsOneWidget); expect(find.text('Push to tomorrow'), findsOneWidget); expect(find.text('Push to backlog'), findsOneWidget); await tester.tap(find.text('Push to next')); await tester.pumpAndSettle(); expect(nextCount, 1); await openMenu(); await tester.tap(find.text('Push to tomorrow')); await tester.pumpAndSettle(); expect(tomorrowCount, 1); await openMenu(); await tester.tap(find.text('Push to backlog')); await tester.pumpAndSettle(); expect(backlogCount, 1); await gesture.removePointer(); }); testWidgets('Push menu dismisses on outside click without callback', ( tester, ) async { var count = 0; await _pumpTimelineCard( tester, card: _timelineCard( id: 'push-dismiss', title: 'Past task', showPastTaskPushButton: true, ), size: const Size(420, 88), onPushToNext: (_) async { count += 1; }, ); await tester.tap(find.byKey(const ValueKey('push-dismiss-push-button'))); await tester.pumpAndSettle(); await tester.tapAt(Offset.zero); await tester.pumpAndSettle(); expect(find.text('Push to next'), findsNothing); expect(count, 0); }); testWidgets('Push selection is one shot while command is running', ( tester, ) async { final completer = Completer(); var count = 0; await _pumpTimelineCard( tester, card: _timelineCard( id: 'push-running', title: 'Past task', showPastTaskPushButton: true, ), size: const Size(420, 88), onPushToNext: (_) async { count += 1; await completer.future; }, ); await tester.tap(find.byKey(const ValueKey('push-running-push-button'))); await tester.pumpAndSettle(); await tester.tap(find.text('Push to next')); await tester.pump(); await tester.tap(find.byKey(const ValueKey('push-running-push-button'))); await tester.pump(); expect(count, 1); completer.complete(); await tester.pumpAndSettle(); }); testWidgets('short task card keeps time inline with title', (tester) async { await _pumpTimelineCard( tester, card: _timelineCard( id: 'short-inline-time', title: 'Five minute reset', subtitle: '5 min', timeText: '6:00 PM - 6:05 PM', startMinutes: 18 * 60, endMinutes: 18 * 60 + 5, ), size: const Size(360, 24), ); expect(tester.takeException(), isNull); final title = tester.getRect(find.text('Five minute reset')); final time = tester.getRect(find.text('5 min')); final statusCircle = tester.getRect( find.byKey(const ValueKey('short-inline-time-status')), ); expect(statusCircle.height, lessThan(20)); expect(statusCircle.right, lessThan(title.left)); expect(title.right, lessThan(time.left)); expect((title.center.dy - time.center.dy).abs(), lessThan(2)); expect(find.byType(RewardIcon), findsOneWidget); expect(find.byType(DifficultyBars), findsOneWidget); final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); await gesture.addPointer( location: tester.getCenter( find.byKey(const ValueKey('short-inline-time')), ), ); await tester.pumpAndSettle(); expect( tester.widget(find.byType(AnimatedOpacity)).opacity, 1, ); expect(find.text('5 min'), findsOneWidget); await gesture.removePointer(); }); testWidgets('status circle toggles task completion without opening modal', ( tester, ) async { final composition = _composition(); await _pumpPlanApp(tester, composition: composition); await _scrollIntoView(tester, const ValueKey('clean-coffee-maker')); var statusDecoration = tester .widget( find.byKey(const ValueKey('clean-coffee-maker-status')), ) .decoration as BoxDecoration; expect(statusDecoration.boxShadow, isEmpty); final hover = await tester.createGesture(kind: PointerDeviceKind.mouse); await hover.addPointer( location: tester.getCenter( find.byKey(const ValueKey('clean-coffee-maker-status')), ), ); await tester.pumpAndSettle(); statusDecoration = tester .widget( find.byKey(const ValueKey('clean-coffee-maker-status')), ) .decoration as BoxDecoration; expect(statusDecoration.boxShadow, isNotEmpty); await hover.removePointer(); await tester.pumpAndSettle(); final beforeClick = DateTime.now().toUtc(); await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status'))); await tester.pumpAndSettle(); final afterClick = DateTime.now().toUtc(); final completedTask = composition.store.currentTasks.singleWhere( (task) => task.id == 'clean-coffee-maker', ); final scheduledStart = completedTask.scheduledStart; final scheduledEnd = completedTask.scheduledEnd; expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); expect(completedTask.status, TaskStatus.completed); expect(completedTask.completedAt, isNotNull); expect(completedTask.completedAt!.isBefore(beforeClick), isFalse); expect(completedTask.completedAt!.isAfter(afterClick), isFalse); expect(find.textContaining('Completed at:'), findsWidgets); final completedHover = await tester.createGesture( kind: PointerDeviceKind.mouse, ); await completedHover.addPointer( location: tester.getCenter( find.byKey(const ValueKey('clean-coffee-maker-status')), ), ); await tester.pumpAndSettle(); statusDecoration = tester .widget( find.byKey(const ValueKey('clean-coffee-maker-status')), ) .decoration as BoxDecoration; expect(statusDecoration.boxShadow, isNotEmpty); await completedHover.removePointer(); await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status'))); await tester.pumpAndSettle(); final reopenedTask = composition.store.currentTasks.singleWhere( (task) => task.id == 'clean-coffee-maker', ); expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); expect(reopenedTask.status, TaskStatus.planned); expect(reopenedTask.scheduledStart, scheduledStart); expect(reopenedTask.scheduledEnd, scheduledEnd); expect(reopenedTask.actualStart, isNull); expect(reopenedTask.actualEnd, isNull); expect(reopenedTask.completedAt, isNull); }); testWidgets('task modal status circle toggles and keeps details in sync', ( tester, ) async { final composition = _composition(); await _pumpPlanApp(tester, composition: composition); await _scrollIntoView(tester, const ValueKey('water-plants')); await tester.tap(find.byKey(const ValueKey('water-plants'))); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget); expect(find.textContaining('Completed -'), findsNothing); var modalStatusDecoration = tester .widget( find.byKey(const ValueKey('task-modal-status')), ) .decoration as BoxDecoration; expect(modalStatusDecoration.boxShadow, isEmpty); final hover = await tester.createGesture(kind: PointerDeviceKind.mouse); await hover.addPointer( location: tester.getCenter( find.byKey(const ValueKey('task-modal-status')), ), ); await tester.pumpAndSettle(); modalStatusDecoration = tester .widget( find.byKey(const ValueKey('task-modal-status')), ) .decoration as BoxDecoration; expect(modalStatusDecoration.boxShadow, isNotEmpty); await hover.removePointer(); await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('task-modal-status'))); await tester.pumpAndSettle(); final completedTask = composition.store.currentTasks.singleWhere( (task) => task.id == 'water-plants', ); expect(completedTask.status, TaskStatus.completed); expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); expect(find.text('Water plants'), findsWidgets); expect(find.textContaining('Completed -'), findsOneWidget); expect(find.text('Planned: 3:20 PM - 4:00 PM'), findsOneWidget); expect(find.byIcon(Icons.check), findsWidgets); final completedHover = await tester.createGesture( kind: PointerDeviceKind.mouse, ); await completedHover.addPointer( location: tester.getCenter( find.byKey(const ValueKey('task-modal-status')), ), ); await tester.pumpAndSettle(); modalStatusDecoration = tester .widget( find.byKey(const ValueKey('task-modal-status')), ) .decoration as BoxDecoration; expect(modalStatusDecoration.boxShadow, isNotEmpty); await completedHover.removePointer(); await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('task-modal-status'))); await tester.pumpAndSettle(); final reopenedTask = composition.store.currentTasks.singleWhere( (task) => task.id == 'water-plants', ); expect(reopenedTask.status, TaskStatus.planned); expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget); expect(find.textContaining('Completed -'), findsNothing); }); testWidgets('free slot text scales inside a short task bubble', ( tester, ) async { await _pumpTimelineCard( tester, card: _timelineCard( id: 'short-free-slot', title: 'Free Slot', subtitle: 'Intentional rest', timeText: '6:15 PM - 6:30 PM', visualKind: TaskVisualKind.freeSlot, showsQuickActions: false, ), size: const Size(360, 88), ); expect(tester.takeException(), isNull); expect(find.byKey(const ValueKey('short-free-slot-status')), findsNothing); expect(find.text('Intentional rest'), findsOneWidget); expect(find.text('6:15 PM - 6:30 PM'), findsOneWidget); }); } /// Top-level helper that performs the `_pumpPlanApp` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Future _pumpPlanApp( WidgetTester tester, { DemoSchedulerComposition? composition, }) async { await tester.binding.setSurfaceSize(const Size(1586, 992)); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( FocusFlowApp(composition: composition ?? _composition()), ); await tester.pumpAndSettle(); } /// Top-level helper that performs the `_composition` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. DemoSchedulerComposition _composition() { return DemoSchedulerComposition.seeded(selectedDate: CivilDate(2025, 5, 20)); } /// Top-level helper that performs the `_readSeededToday` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Future _readSeededToday() async { final composition = _composition(); final result = await composition.todayQuery.execute( GetTodayStateRequest( context: composition.context('test-read-today'), date: composition.date, ), ); return result.requireValue; } /// Top-level helper that performs the `_kindFor` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. TaskVisualKind _kindFor(TodayScreenData data, String id) { return data.cards.singleWhere((card) => card.id == id).visualKind; } /// Top-level helper that performs the `_scrollIntoView` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Future _scrollIntoView(WidgetTester tester, ValueKey key) async { await tester.ensureVisible(find.byKey(key)); await tester.pumpAndSettle(); } /// Top-level helper that performs the `_pumpTimelineCard` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Future _pumpTimelineCard( WidgetTester tester, { required TimelineCardModel card, required Size size, Future Function(TimelineCardModel card)? onPushToNext, Future Function(TimelineCardModel card)? onPushToTomorrow, Future Function(TimelineCardModel card)? onPushToBacklog, }) async { await tester.binding.setSurfaceSize( Size(size.width + 120, size.height + 220), ); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( MaterialApp( home: Scaffold( body: Center( child: SizedBox( width: size.width, height: size.height, child: TaskTimelineCard( card: card, onTap: () {}, onStatusPressed: () {}, onPushToNext: onPushToNext, onPushToTomorrow: onPushToTomorrow, onPushToBacklog: onPushToBacklog, ), ), ), ), ), ); await tester.pumpAndSettle(); } /// Top-level helper that performs the `_pumpConstrainedWidget` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Future _pumpConstrainedWidget( WidgetTester tester, { required Size size, required Widget child, }) async { await tester.binding.setSurfaceSize(Size(size.width + 40, size.height + 40)); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( MaterialApp( theme: FocusFlowTheme.dark(), home: Scaffold( body: Center( child: SizedBox(width: size.width, height: size.height, child: child), ), ), ), ); await tester.pumpAndSettle(); } /// Top-level helper that performs the `_timelineCard` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. TimelineCardModel _timelineCard({ required String id, required String title, String subtitle = '30 min', String timeText = '6:00 PM - 6:30 PM', int startMinutes = 18 * 60, int endMinutes = 18 * 60 + 30, TaskVisualKind visualKind = TaskVisualKind.flexible, TaskType? taskType, bool showsQuickActions = true, bool showPastTaskPushButton = false, bool isCompleted = false, String? completedTimeText, TimelineRewardIconToken rewardIconToken = TimelineRewardIconToken.medium, TimelineDifficultyIconToken difficultyIconToken = TimelineDifficultyIconToken.medium, }) { final resolvedTaskType = taskType ?? switch (visualKind) { TaskVisualKind.flexible => TaskType.flexible, TaskVisualKind.required => TaskType.critical, TaskVisualKind.appointment => TaskType.inflexible, TaskVisualKind.freeSlot => TaskType.freeSlot, TaskVisualKind.completedSurprise => TaskType.flexible, }; return TimelineCardModel( id: id, title: title, typeLabel: 'Flexible', subtitle: subtitle, timeText: timeText, startMinutes: startMinutes, endMinutes: endMinutes, taskType: resolvedTaskType, visualKind: visualKind, rewardIconToken: rewardIconToken, difficultyIconToken: difficultyIconToken, completedTimeText: completedTimeText, isCompleted: isCompleted, isSelectable: true, showsQuickActions: showPastTaskPushButton ? false : showsQuickActions, showPastTaskPushButton: showPastTaskPushButton, durationMinutes: endMinutes - startMinutes, ); }