From 8d6576b6260bf687573d2cf4dafcf3c42af6b3b2 Mon Sep 17 00:00:00 2001 From: lavigarg-simform Date: Thu, 11 Jun 2026 19:54:15 +0530 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Fixes=20issue=20#331:=20Add=20auto-?= =?UTF-8?q?scroll=20to=20current=20time=20for=20Day,=20Week,=20and=20Multi?= =?UTF-8?q?-Day=20views?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + doc/documentation.md | 3 + example/lib/widgets/day_view_widget.dart | 2 +- .../lib/widgets/multi_day_view_widget.dart | 1 + example/lib/widgets/week_view_widget.dart | 8 +- lib/src/day_view/day_view.dart | 53 +++- lib/src/multi_day_view/multi_day_view.dart | 53 +++- lib/src/scroll_to_current_time_mixin.dart | 117 +++++++ lib/src/week_view/week_view.dart | 52 ++- test/scroll_to_current_time_test.dart | 300 ++++++++++++++++++ 10 files changed, 577 insertions(+), 13 deletions(-) create mode 100644 lib/src/scroll_to_current_time_mixin.dart create mode 100644 test/scroll_to_current_time_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index aa0dec09..84711bc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Updated documentation for `DayView`, `WeekView`, `MonthView` and `MultiDayView` to add more details about the parameters and their usage. [#448](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/pull/448) - Fixed `onlyShowToday` parameter in `WeekView` to update `liveTimeIndicator` properly. [#518](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/518) - Added `ZoomScrollController` to `DayView`, `WeekView` and `MultiDayView` for programmatic control of zoom level and scroll position. [#522](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/522) +- Added `scrollToCurrentTime` to `DayView`, `WeekView` and `MultiDayView` to auto-center the timeline on the current time, along with `animateToCurrentTime()` and `jumpToCurrentTime()` methods to trigger it imperatively. [#331](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/331) # [2.0.0 - 17 Mar 2026](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/tree/2.0.0) diff --git a/doc/documentation.md b/doc/documentation.md index f250e407..551147a3 100644 --- a/doc/documentation.md +++ b/doc/documentation.md @@ -254,6 +254,7 @@ DayView( backgroundColor: Colors.white, // Background color of day view showLiveTimeLineInAllDays: true, // Display live time line in all pages scrollOffset: 0, // Initial scroll position + scrollToCurrentTime: true, // Auto-center the timeline on the current time width: 400, // Width of day view page timeLineOffset: 0, // Offset for timeline // Event handling @@ -424,6 +425,7 @@ WeekView( ), // Scroll configuration scrollOffset: 0.0, + scrollToCurrentTime: true, // Auto-center the timeline on the current time scrollPhysics: ScrollPhysics(), // ScrollPhysics for vertical scrolling pageViewPhysics: ScrollPhysics(), // ScrollPhysics for page view keepScrollOffset: true, // Maintain scroll offset when the page changes @@ -543,6 +545,7 @@ MultiDayView( ), // Scroll configuration scrollOffset: 0.0, + scrollToCurrentTime: true, // Auto-center the timeline on the current time scrollPhysics: ScrollPhysics(), // ScrollPhysics for vertical scrolling pageViewPhysics: ScrollPhysics(), // ScrollPhysics for page view keepScrollOffset: true, // Maintain scroll offset when the page changes diff --git a/example/lib/widgets/day_view_widget.dart b/example/lib/widgets/day_view_widget.dart index 9c438d1c..0344d83f 100644 --- a/example/lib/widgets/day_view_widget.dart +++ b/example/lib/widgets/day_view_widget.dart @@ -16,7 +16,7 @@ class DayViewWidget extends StatelessWidget { return DayView( key: state, width: width, - startDuration: Duration(hours: 8), + scrollToCurrentTime: true, showHalfHours: true, heightPerMinute: 3, timeLineBuilder: (date) => _timeLineBuilder(date, isLtr), diff --git a/example/lib/widgets/multi_day_view_widget.dart b/example/lib/widgets/multi_day_view_widget.dart index fb812969..b1e4ad42 100644 --- a/example/lib/widgets/multi_day_view_widget.dart +++ b/example/lib/widgets/multi_day_view_widget.dart @@ -16,6 +16,7 @@ class MultiDayViewWidget extends StatelessWidget { daysInView: 3, width: width, showLiveTimeLineInAllDays: true, + scrollToCurrentTime: true, eventArranger: SideEventArranger(maxWidth: 30), timeLineWidth: 65, scrollPhysics: const BouncingScrollPhysics(), diff --git a/example/lib/widgets/week_view_widget.dart b/example/lib/widgets/week_view_widget.dart index 0a668082..45656d34 100644 --- a/example/lib/widgets/week_view_widget.dart +++ b/example/lib/widgets/week_view_widget.dart @@ -23,19 +23,15 @@ class WeekViewWidget extends StatelessWidget { heightPerMinute: heightPerMinute, showWeekends: true, showMidnightHour: true, + scrollToCurrentTime: true, showLiveTimeLineInAllDays: true, keepScrollOffset: true, timeSlotColorBuilder: (_, slotStartTime, __, ___) { final hour = slotStartTime.hour; final isBusinessHours = hour >= 9 && hour < 17; final isLunchBreak = hour == 12; - final isWeekend = - slotStartTime.weekday == DateTime.saturday || - slotStartTime.weekday == DateTime.sunday; - return isWeekend - ? Colors.grey.shade100 - : isLunchBreak + return isLunchBreak ? Colors.orange.shade100 : isBusinessHours ? Colors.green.shade50 diff --git a/lib/src/day_view/day_view.dart b/lib/src/day_view/day_view.dart index 734968b1..4907b766 100644 --- a/lib/src/day_view/day_view.dart +++ b/lib/src/day_view/day_view.dart @@ -10,6 +10,7 @@ import '../../calendar_view.dart'; import '../constants.dart'; import '../extensions.dart'; import '../painters.dart'; +import '../scroll_to_current_time_mixin.dart'; import '../zoom_scroll_controller.dart'; import '_internal_day_view_page.dart'; @@ -179,6 +180,20 @@ class DayView extends StatefulWidget { /// rather than relying on the [startDuration] parameter. final double? scrollOffset; + /// When true, the timeline automatically scrolls so that the current time is + /// centered in the viewport on the first build. + /// + /// The current time honors [LiveTimeIndicatorSettings.currentTimeProvider] + /// when provided and is clamped to the visible [startHour]/[endHour] range. + /// This takes precedence over [scrollOffset] and [startDuration] for the + /// initial scroll position. + /// + /// To trigger this imperatively at any time (e.g. from a "Now" button) use + /// [DayViewState.animateToCurrentTime] or [DayViewState.jumpToCurrentTime]. + /// + /// Default value is false. + final bool scrollToCurrentTime; + /// This method will be called when user taps on timestamp in timeline. /// /// Called when user taps on a time value in the timeline (left side of view). @@ -327,6 +342,7 @@ class DayView extends StatefulWidget { this.verticalLineOffset = 10, this.backgroundColor, this.scrollOffset, + this.scrollToCurrentTime = false, this.onEventTap, this.onEventLongTap, this.onDateLongPress, @@ -381,7 +397,8 @@ class DayView extends StatefulWidget { DayViewState createState() => DayViewState(); } -class DayViewState extends State> { +class DayViewState extends State> + with ScrollToCurrentTimeMixin> { /// Width of the Day View widget in pixels. /// Calculated from widget width or device constraint width. late double _width; @@ -504,6 +521,30 @@ class DayViewState extends State> { return controller; } + // --- ScrollToCurrentTimeMixin implementation --- + + @override + DateTime Function()? get currentTimeProvider => + widget.liveTimeIndicatorSettings?.currentTimeProvider; + + @override + int get viewStartHour => widget.startHour; + + @override + int get viewEndHour => widget.endHour; + + @override + double get viewHeightPerMinute => widget.heightPerMinute; + + @override + ZoomScrollController? get activeScrollController => _activeScrollController; + + @override + void onCurrentTimeJumped(double offset) { + _lastScrollOffset = offset; + _pageOffsets[_currentIndex] = offset; + } + /// Callback function triggered when the controller changes or events are modified. /// Used to rebuild the view when event data changes. late VoidCallback _reloadCallback; @@ -515,7 +556,9 @@ class DayViewState extends State> { @override void initState() { super.initState(); - _lastScrollOffset = _defaultPageOffset; + _lastScrollOffset = widget.scrollToCurrentTime + ? offsetForTime(currentTime) + : _defaultPageOffset; _reloadCallback = _reload; _setDateRange(); @@ -529,6 +572,12 @@ class DayViewState extends State> { _pageOffsets[_currentIndex] = _lastScrollOffset; _eventArranger = widget.eventArranger ?? SideEventArranger(); _assignBuilders(); + + if (widget.scrollToCurrentTime) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => scrollToCurrentTimeAfterLayout(), + ); + } } @override diff --git a/lib/src/multi_day_view/multi_day_view.dart b/lib/src/multi_day_view/multi_day_view.dart index 9772f8b4..cfdae000 100644 --- a/lib/src/multi_day_view/multi_day_view.dart +++ b/lib/src/multi_day_view/multi_day_view.dart @@ -8,6 +8,7 @@ import '../../calendar_view.dart'; import '../constants.dart'; import '../extensions.dart'; import '../painters.dart'; +import '../scroll_to_current_time_mixin.dart'; import '../zoom_scroll_controller.dart'; import '_internal_multi_day_view_page.dart'; @@ -129,6 +130,20 @@ class MultiDayView extends StatefulWidget { /// Scroll offset of week view page. final double scrollOffset; + /// When true, the timeline automatically scrolls so that the current time is + /// centered in the viewport on the first build. + /// + /// The current time honors [LiveTimeIndicatorSettings.currentTimeProvider] + /// when provided and is clamped to the visible [startHour]/[endHour] range. + /// This takes precedence over [scrollOffset] for the initial scroll position. + /// + /// To trigger this imperatively at any time (e.g. from a "Now" button) use + /// [MultiDayViewState.animateToCurrentTime] or + /// [MultiDayViewState.jumpToCurrentTime]. + /// + /// Default value is false. + final bool scrollToCurrentTime; + /// This method will be called when user taps on timestamp in timeline. final TimestampCallback? onTimestampTap; @@ -241,6 +256,7 @@ class MultiDayView extends StatefulWidget { this.backgroundColor, this.scrollPhysics, this.scrollOffset = 0.0, + this.scrollToCurrentTime = false, this.onEventTap, this.onEventLongTap, this.onDateLongPress, @@ -298,7 +314,8 @@ class MultiDayView extends StatefulWidget { MultiDayViewState createState() => MultiDayViewState(); } -class MultiDayViewState extends State> { +class MultiDayViewState extends State> + with ScrollToCurrentTimeMixin> { late double _width; late double _height; late double _timeLineWidth; @@ -355,6 +372,30 @@ class MultiDayViewState extends State> { return controller; } + // --- ScrollToCurrentTimeMixin implementation --- + + @override + DateTime Function()? get currentTimeProvider => + widget.liveTimeIndicatorSettings?.currentTimeProvider; + + @override + int get viewStartHour => widget.startHour; + + @override + int get viewEndHour => widget.endHour; + + @override + double get viewHeightPerMinute => widget.heightPerMinute; + + @override + ZoomScrollController? get activeScrollController => _activeScrollController; + + @override + void onCurrentTimeJumped(double offset) { + _lastScrollOffset = offset; + _pageOffsets[_currentIndex] = offset; + } + late List _weekDays; late int _startHour; @@ -365,7 +406,9 @@ class MultiDayViewState extends State> { @override void initState() { super.initState(); - _lastScrollOffset = _defaultPageOffset; + _lastScrollOffset = widget.scrollToCurrentTime + ? offsetForTime(currentTime) + : _defaultPageOffset; _startHour = widget.startHour; _endHour = widget.endHour; @@ -389,6 +432,12 @@ class MultiDayViewState extends State> { _fullDayHeaderTitle = widget.fullDayHeaderTitle; _fullDayHeaderTextConfig = widget.fullDayHeaderTextConfig ?? FullDayHeaderTextConfig(); + + if (widget.scrollToCurrentTime) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => scrollToCurrentTimeAfterLayout(), + ); + } } @override diff --git a/lib/src/scroll_to_current_time_mixin.dart b/lib/src/scroll_to_current_time_mixin.dart new file mode 100644 index 00000000..1ef4435e --- /dev/null +++ b/lib/src/scroll_to_current_time_mixin.dart @@ -0,0 +1,117 @@ +// Copyright (c) 2021 Simform Solutions. All rights reserved. +// Use of this source code is governed by a MIT-style license +// that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; + +import 'zoom_scroll_controller.dart'; + +/// Provides scroll-to-current-time functionality shared by DayView, WeekView, +/// and MultiDayView state classes. +mixin ScrollToCurrentTimeMixin on State { + // --- Abstract interface (implemented by the host state) --- + + /// Returns the [currentTimeProvider] from the view's + /// [LiveTimeIndicatorSettings], or null to use [DateTime.now]. + DateTime Function()? get currentTimeProvider; + + /// First hour shown in the timeline (0–23). + int get viewStartHour; + + /// Last hour shown in the timeline (1–24). + int get viewEndHour; + + /// Pixels per minute used to calculate scroll offsets. + double get viewHeightPerMinute; + + /// Currently active scroll controller, or null if not yet attached. + ZoomScrollController? get activeScrollController; + + /// Called by [jumpToCurrentTime] so the host can persist the new offset + /// (e.g. update _lastScrollOffset and _pageOffsets[_currentIndex]). + void onCurrentTimeJumped(double offset); + + // --- Shared implementation --- + + int _currentTimeScrollAttempts = 0; + + /// Current time, honoring [currentTimeProvider] when provided. + DateTime get currentTime => currentTimeProvider?.call() ?? DateTime.now(); + + /// Top-aligned pixel offset of [time] within the visible timeline range. + /// + /// Accounts for [viewStartHour]/[viewEndHour] and clamps the result so that + /// times outside the range map to the nearest edge. + double offsetForTime(DateTime time) { + final minutesFromStart = (time.hour - viewStartHour) * 60 + time.minute; + final visibleMinutes = (viewEndHour - viewStartHour) * 60; + // Guard against an inverted range (endHour < startHour). Asserts that are + // meant to prevent this are stripped in release builds, and clamp() throws + // when its lower bound exceeds the upper bound, so fail gracefully instead. + if (visibleMinutes <= 0) return 0; + final clampedMinutes = minutesFromStart.clamp(0, visibleMinutes); + return viewHeightPerMinute * clampedMinutes; + } + + double? _currentTimeScrollOffset({required bool center}) { + final controller = activeScrollController; + if (controller == null || !controller.hasClients) return null; + final position = controller.position; + var offset = offsetForTime(currentTime); + if (center) offset -= position.viewportDimension / 2; + return offset.clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + } + + /// Centers the current time once the scrollable is ready. + /// + /// Retries for a few frames if the controller is not attached yet, then + /// gives up to avoid an endless frame-scheduling loop. + void scrollToCurrentTimeAfterLayout() { + if (!mounted) return; + final controller = activeScrollController; + if (controller == null || !controller.hasClients) { + if (_currentTimeScrollAttempts++ >= 5) return; + WidgetsBinding.instance.addPostFrameCallback( + (_) => scrollToCurrentTimeAfterLayout(), + ); + return; + } + jumpToCurrentTime(); + } + + /// Instantly positions the timeline so the current time is visible. + /// + /// When [center] is true (default) the current time is placed at the + /// vertical center of the viewport; otherwise it aligns to the top. + /// Does nothing if the scrollable is not yet attached. + void jumpToCurrentTime({bool center = true}) { + final offset = _currentTimeScrollOffset(center: center); + if (offset == null) return; + onCurrentTimeJumped(offset); + activeScrollController?.jumpTo(offset); + } + + /// Animates the timeline so that the current time becomes visible. + /// + /// When [center] is true (default) the current time is positioned at the + /// vertical center of the viewport; otherwise it aligns to the top. The + /// target is clamped to the scrollable range. Does nothing if the view has + /// not been laid out yet. + Future animateToCurrentTime({ + bool center = true, + Duration duration = const Duration(milliseconds: 200), + Curve curve = Curves.linear, + }) async { + final offset = _currentTimeScrollOffset(center: center); + if (offset == null) return; + final controller = activeScrollController; + if (controller == null || !controller.hasClients) return; + // Persist the target so a rebuild mid-animation (which seeds the page from + // the stored offset) doesn't reset the position, matching jumpToCurrentTime. + onCurrentTimeJumped(offset); + await controller.animateTo(offset, duration: duration, curve: curve); + } +} diff --git a/lib/src/week_view/week_view.dart b/lib/src/week_view/week_view.dart index 26be7ecf..ac132d4a 100644 --- a/lib/src/week_view/week_view.dart +++ b/lib/src/week_view/week_view.dart @@ -8,6 +8,7 @@ import '../../calendar_view.dart'; import '../constants.dart'; import '../extensions.dart'; import '../painters.dart'; +import '../scroll_to_current_time_mixin.dart'; import '../zoom_scroll_controller.dart'; import '_internal_week_view_page.dart'; @@ -133,6 +134,19 @@ class WeekView extends StatefulWidget { /// Scroll offset of week view page. final double scrollOffset; + /// When true, the timeline automatically scrolls so that the current time is + /// centered in the viewport on the first build. + /// + /// The current time honors [LiveTimeIndicatorSettings.currentTimeProvider] + /// when provided and is clamped to the visible [startHour]/[endHour] range. + /// This takes precedence over [scrollOffset] for the initial scroll position. + /// + /// To trigger this imperatively at any time (e.g. from a "Now" button) use + /// [WeekViewState.animateToCurrentTime] or [WeekViewState.jumpToCurrentTime]. + /// + /// Default value is false. + final bool scrollToCurrentTime; + /// This method will be called when user taps on timestamp in timeline. final TimestampCallback? onTimestampTap; @@ -274,6 +288,7 @@ class WeekView extends StatefulWidget { this.backgroundColor, this.scrollPhysics, this.scrollOffset = 0.0, + this.scrollToCurrentTime = false, this.onEventTap, this.onEventLongTap, this.onDateLongPress, @@ -334,7 +349,8 @@ class WeekView extends StatefulWidget { WeekViewState createState() => WeekViewState(); } -class WeekViewState extends State> { +class WeekViewState extends State> + with ScrollToCurrentTimeMixin> { /// Width of the Week View widget in pixels. late double _width; @@ -461,6 +477,30 @@ class WeekViewState extends State> { return controller; } + // --- ScrollToCurrentTimeMixin implementation --- + + @override + DateTime Function()? get currentTimeProvider => + widget.liveTimeIndicatorSettings?.currentTimeProvider; + + @override + int get viewStartHour => widget.startHour; + + @override + int get viewEndHour => widget.endHour; + + @override + double get viewHeightPerMinute => widget.heightPerMinute; + + @override + ZoomScrollController? get activeScrollController => _activeScrollController; + + @override + void onCurrentTimeJumped(double offset) { + _lastScrollOffset = offset; + _pageOffsets[_currentIndex] = offset; + } + /// List of days in a week with their properties (name, order, etc.). /// Used for rendering day headers and determining week layout. late List _weekDays; @@ -479,7 +519,9 @@ class WeekViewState extends State> { @override void initState() { super.initState(); - _lastScrollOffset = _defaultPageOffset; + _lastScrollOffset = widget.scrollToCurrentTime + ? offsetForTime(currentTime) + : _defaultPageOffset; _startHour = widget.startHour; _endHour = widget.endHour; @@ -503,6 +545,12 @@ class WeekViewState extends State> { _fullDayHeaderTitle = widget.fullDayHeaderTitle; _fullDayHeaderTextConfig = widget.fullDayHeaderTextConfig ?? FullDayHeaderTextConfig(); + + if (widget.scrollToCurrentTime) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => scrollToCurrentTimeAfterLayout(), + ); + } } @override diff --git a/test/scroll_to_current_time_test.dart b/test/scroll_to_current_time_test.dart new file mode 100644 index 00000000..a970030d --- /dev/null +++ b/test/scroll_to_current_time_test.dart @@ -0,0 +1,300 @@ +import 'package:calendar_view/calendar_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // A fixed "now" on an old date. Using a date outside the visible page range + // keeps the live-time indicator (and its periodic timer) from mounting, while + // still driving the time-of-day used for the auto-scroll math. + DateTime fixedNow(int hour, [int minute = 0]) => + DateTime(2020, 1, 1, hour, minute); + + // Single visible day so exactly one page (and one scroll position) attaches. + final visibleDay = DateTime(2026, 3, 30); + + Future pumpDayView( + WidgetTester tester, { + required DateTime Function()? now, + bool scrollToCurrentTime = false, + double heightPerMinute = 1, + int startHour = 0, + int endHour = 24, + double height = 600, + }) async { + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 300, + height: height, + child: DayView( + key: key, + controller: EventController(), + initialDay: visibleDay, + minDay: visibleDay, + maxDay: visibleDay, + heightPerMinute: heightPerMinute, + startHour: startHour, + endHour: endHour, + timeLineWidth: 60, + verticalLineOffset: 0, + scrollToCurrentTime: scrollToCurrentTime, + liveTimeIndicatorSettings: + LiveTimeIndicatorSettings(currentTimeProvider: now), + ), + ), + ), + ), + ); + // Let the post-frame auto-scroll (and any retry) run. + await tester.pump(); + await tester.pump(); + return key.currentState!; + } + + Future pumpWeekView( + WidgetTester tester, { + required DateTime Function()? now, + bool scrollToCurrentTime = false, + double heightPerMinute = 1, + }) async { + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + height: 700, + child: WeekView( + key: key, + controller: EventController(), + initialDay: visibleDay, + minDay: visibleDay, + maxDay: visibleDay, + heightPerMinute: heightPerMinute, + timeLineWidth: 60, + weekTitleHeight: 0, + scrollToCurrentTime: scrollToCurrentTime, + liveTimeIndicatorSettings: + LiveTimeIndicatorSettings(currentTimeProvider: now), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + return key.currentState!; + } + + Future pumpMultiDayView( + WidgetTester tester, { + required DateTime Function()? now, + bool scrollToCurrentTime = false, + double heightPerMinute = 1, + }) async { + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + height: 700, + child: MultiDayView( + key: key, + controller: EventController(), + daysInView: 3, + initialDay: visibleDay, + minDay: visibleDay, + maxDay: visibleDay, + heightPerMinute: heightPerMinute, + timeLineWidth: 60, + weekTitleHeight: 0, + scrollToCurrentTime: scrollToCurrentTime, + liveTimeIndicatorSettings: + LiveTimeIndicatorSettings(currentTimeProvider: now), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + return key.currentState!; + } + + double centeredExpectation(ScrollPosition position, double topOffset) => + (topOffset - position.viewportDimension / 2) + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(); + + group('scrollToCurrentTime - DayView', () { + testWidgets('auto-centers the current time on first build', (tester) async { + final state = await pumpDayView( + tester, + now: () => fixedNow(12), + scrollToCurrentTime: true, + ); + + final position = state.scrollController.position; + // 12:00 => 720px from the top of a 1px/min timeline. + expect(state.scrollController.offset, + closeTo(centeredExpectation(position, 720), 0.5)); + }); + + testWidgets('does not auto-scroll when the flag is false', (tester) async { + final state = await pumpDayView(tester, now: () => fixedNow(12)); + + // Default initial offset (no scrollOffset/startDuration) is the top. + expect(state.scrollController.offset, 0); + }); + + testWidgets('jumpToCurrentTime(center: false) aligns time to the top', + (tester) async { + final state = await pumpDayView(tester, now: () => fixedNow(12)); + + state.jumpToCurrentTime(center: false); + await tester.pump(); + + // Top-aligned 12:00 => 720px, well within the scroll range. + expect(state.scrollController.offset, closeTo(720, 0.5)); + }); + + testWidgets('animateToCurrentTime centers after the animation settles', + (tester) async { + final state = await pumpDayView(tester, now: () => fixedNow(12)); + expect(state.scrollController.offset, 0); + + // ignore: unawaited_futures + state.animateToCurrentTime(); + // Drive the animation past its default 200ms duration. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + + final position = state.scrollController.position; + expect(state.scrollController.offset, + closeTo(centeredExpectation(position, 720), 0.5)); + }); + + testWidgets('falls back to DateTime.now() when no currentTimeProvider', + (tester) async { + final state = await pumpDayView( + tester, + now: null, + scrollToCurrentTime: true, + ); + + // Read currentTime after the build so it exercises the same DateTime.now() + // fallback the widget used; this keeps the drift window minimal instead of + // snapshotting the clock before the post-frame scroll math runs. + final now = state.currentTime; + final position = state.scrollController.position; + // offsetForTime only depends on hour/minute; 1px/min so a minute rollover + // between the widget's read and this one is at most ~1px of drift. + final topOffset = (now.hour * 60 + now.minute).toDouble(); + expect(state.scrollController.offset, + closeTo(centeredExpectation(position, topOffset), 1.5)); + }); + + testWidgets('clamps to the top for early-morning times', (tester) async { + final state = await pumpDayView( + tester, + now: () => fixedNow(0, 5), + scrollToCurrentTime: true, + ); + + // 00:05 centered would be negative, so it clamps to the start. + expect(state.scrollController.offset, 0); + }); + + testWidgets('clamps to the bottom for late-night times', (tester) async { + final state = await pumpDayView( + tester, + now: () => fixedNow(23, 55), + scrollToCurrentTime: true, + ); + + final position = state.scrollController.position; + expect(state.scrollController.offset, position.maxScrollExtent); + }); + + testWidgets('honors startHour when computing the offset', (tester) async { + // startHour 8 => 14:00 is 360 minutes into the visible range. + // With 2px/min that is 720px; without the startHour shift it would be + // 1680px (and clamp to the bottom), so this distinguishes the two. + final state = await pumpDayView( + tester, + now: () => fixedNow(14), + heightPerMinute: 2, + startHour: 8, + endHour: 20, + ); + + state.jumpToCurrentTime(center: false); + await tester.pump(); + + expect(state.scrollController.offset, closeTo(720, 0.5)); + }); + }); + + group('scrollToCurrentTime - WeekView', () { + testWidgets('auto-centers the current time on first build', (tester) async { + final state = await pumpWeekView( + tester, + now: () => fixedNow(12), + scrollToCurrentTime: true, + ); + + final position = state.scrollController.position; + expect(state.scrollController.offset, + closeTo(centeredExpectation(position, 720), 0.5)); + }); + + testWidgets('does not auto-scroll when the flag is false', (tester) async { + final state = await pumpWeekView(tester, now: () => fixedNow(12)); + + expect(state.scrollController.offset, 0); + }); + + testWidgets('jumpToCurrentTime(center: false) aligns time to the top', + (tester) async { + final state = await pumpWeekView(tester, now: () => fixedNow(12)); + + state.jumpToCurrentTime(center: false); + await tester.pump(); + + expect(state.scrollController.offset, closeTo(720, 0.5)); + }); + }); + + group('scrollToCurrentTime - MultiDayView', () { + testWidgets('auto-centers the current time on first build', (tester) async { + final state = await pumpMultiDayView( + tester, + now: () => fixedNow(12), + scrollToCurrentTime: true, + ); + + final position = state.scrollController.position; + expect(state.scrollController.offset, + closeTo(centeredExpectation(position, 720), 0.5)); + }); + + testWidgets('does not auto-scroll when the flag is false', (tester) async { + final state = await pumpMultiDayView(tester, now: () => fixedNow(12)); + + expect(state.scrollController.offset, 0); + }); + + testWidgets('jumpToCurrentTime(center: false) aligns time to the top', + (tester) async { + final state = await pumpMultiDayView(tester, now: () => fixedNow(12)); + + state.jumpToCurrentTime(center: false); + await tester.pump(); + + expect(state.scrollController.offset, closeTo(720, 0.5)); + }); + }); +}