|
| 1 | +// Copyright (c) 2021 Simform Solutions. All rights reserved. |
| 2 | +// Use of this source code is governed by a MIT-style license |
| 3 | +// that can be found in the LICENSE file. |
| 4 | + |
| 5 | +import 'package:flutter/material.dart'; |
| 6 | + |
| 7 | +import 'zoom_scroll_controller.dart'; |
| 8 | + |
| 9 | +/// Provides scroll-to-current-time functionality shared by DayView, WeekView, |
| 10 | +/// and MultiDayView state classes. |
| 11 | +mixin ScrollToCurrentTimeMixin<W extends StatefulWidget> on State<W> { |
| 12 | + // --- Abstract interface (implemented by the host state) --- |
| 13 | + |
| 14 | + /// Returns the [currentTimeProvider] from the view's |
| 15 | + /// [LiveTimeIndicatorSettings], or null to use [DateTime.now]. |
| 16 | + DateTime Function()? get currentTimeProvider; |
| 17 | + |
| 18 | + /// First hour shown in the timeline (0–23). |
| 19 | + int get viewStartHour; |
| 20 | + |
| 21 | + /// Last hour shown in the timeline (1–24). |
| 22 | + int get viewEndHour; |
| 23 | + |
| 24 | + /// Pixels per minute used to calculate scroll offsets. |
| 25 | + double get viewHeightPerMinute; |
| 26 | + |
| 27 | + /// Currently active scroll controller, or null if not yet attached. |
| 28 | + ZoomScrollController? get activeScrollController; |
| 29 | + |
| 30 | + /// Called by [jumpToCurrentTime] so the host can persist the new offset |
| 31 | + /// (e.g. update _lastScrollOffset and _pageOffsets[_currentIndex]). |
| 32 | + void onCurrentTimeJumped(double offset); |
| 33 | + |
| 34 | + // --- Shared implementation --- |
| 35 | + |
| 36 | + int _currentTimeScrollAttempts = 0; |
| 37 | + |
| 38 | + /// Current time, honoring [currentTimeProvider] when provided. |
| 39 | + DateTime get currentTime => currentTimeProvider?.call() ?? DateTime.now(); |
| 40 | + |
| 41 | + /// Top-aligned pixel offset of [time] within the visible timeline range. |
| 42 | + /// |
| 43 | + /// Accounts for [viewStartHour]/[viewEndHour] and clamps the result so that |
| 44 | + /// times outside the range map to the nearest edge. |
| 45 | + double offsetForTime(DateTime time) { |
| 46 | + final minutesFromStart = (time.hour - viewStartHour) * 60 + time.minute; |
| 47 | + final visibleMinutes = (viewEndHour - viewStartHour) * 60; |
| 48 | + // Guard against an inverted range (endHour < startHour). Asserts that are |
| 49 | + // meant to prevent this are stripped in release builds, and clamp() throws |
| 50 | + // when its lower bound exceeds the upper bound, so fail gracefully instead. |
| 51 | + if (visibleMinutes <= 0) return 0; |
| 52 | + final clampedMinutes = minutesFromStart.clamp(0, visibleMinutes); |
| 53 | + return viewHeightPerMinute * clampedMinutes; |
| 54 | + } |
| 55 | + |
| 56 | + double? _currentTimeScrollOffset({required bool center}) { |
| 57 | + final controller = activeScrollController; |
| 58 | + if (controller == null || !controller.hasClients) return null; |
| 59 | + final position = controller.position; |
| 60 | + var offset = offsetForTime(currentTime); |
| 61 | + if (center) offset -= position.viewportDimension / 2; |
| 62 | + return offset.clamp( |
| 63 | + position.minScrollExtent, |
| 64 | + position.maxScrollExtent, |
| 65 | + ); |
| 66 | + } |
| 67 | + |
| 68 | + /// Centers the current time once the scrollable is ready. |
| 69 | + /// |
| 70 | + /// Retries for a few frames if the controller is not attached yet, then |
| 71 | + /// gives up to avoid an endless frame-scheduling loop. |
| 72 | + void scrollToCurrentTimeAfterLayout() { |
| 73 | + if (!mounted) return; |
| 74 | + final controller = activeScrollController; |
| 75 | + if (controller == null || !controller.hasClients) { |
| 76 | + if (_currentTimeScrollAttempts++ >= 5) return; |
| 77 | + WidgetsBinding.instance.addPostFrameCallback( |
| 78 | + (_) => scrollToCurrentTimeAfterLayout(), |
| 79 | + ); |
| 80 | + return; |
| 81 | + } |
| 82 | + jumpToCurrentTime(); |
| 83 | + } |
| 84 | + |
| 85 | + /// Instantly positions the timeline so the current time is visible. |
| 86 | + /// |
| 87 | + /// When [center] is true (default) the current time is placed at the |
| 88 | + /// vertical center of the viewport; otherwise it aligns to the top. |
| 89 | + /// Does nothing if the scrollable is not yet attached. |
| 90 | + void jumpToCurrentTime({bool center = true}) { |
| 91 | + final offset = _currentTimeScrollOffset(center: center); |
| 92 | + if (offset == null) return; |
| 93 | + onCurrentTimeJumped(offset); |
| 94 | + activeScrollController?.jumpTo(offset); |
| 95 | + } |
| 96 | + |
| 97 | + /// Animates the timeline so that the current time becomes visible. |
| 98 | + /// |
| 99 | + /// When [center] is true (default) the current time is positioned at the |
| 100 | + /// vertical center of the viewport; otherwise it aligns to the top. The |
| 101 | + /// target is clamped to the scrollable range. Does nothing if the view has |
| 102 | + /// not been laid out yet. |
| 103 | + Future<void> animateToCurrentTime({ |
| 104 | + bool center = true, |
| 105 | + Duration duration = const Duration(milliseconds: 200), |
| 106 | + Curve curve = Curves.linear, |
| 107 | + }) async { |
| 108 | + final offset = _currentTimeScrollOffset(center: center); |
| 109 | + if (offset == null) return; |
| 110 | + final controller = activeScrollController; |
| 111 | + if (controller == null || !controller.hasClients) return; |
| 112 | + // Persist the target so a rebuild mid-animation (which seeds the page from |
| 113 | + // the stored offset) doesn't reset the position, matching jumpToCurrentTime. |
| 114 | + onCurrentTimeJumped(offset); |
| 115 | + await controller.animateTo(offset, duration: duration, curve: curve); |
| 116 | + } |
| 117 | +} |
0 commit comments