@@ -6,20 +6,27 @@ import 'package:book/model/read_model.dart';
66import 'package:book/view/page_turn/touch_event.dart' ;
77import 'package:flutter/foundation.dart' ;
88import 'package:flutter/material.dart' ;
9+ import 'package:flutter/scheduler.dart' ;
910
1011/// Page-turn orchestrator.
1112///
1213/// **Single commit rule:** the only place that advances the page is
1314/// [_commitTurn] → [ReadModel.commitPageTurn] . Cover / Simulation / Static
1415/// paint only; they never call commitPageTurn themselves.
16+ ///
17+ /// Rapid taps while animating are queued (capped) and drained as soon as the
18+ /// current turn settles so consecutive clicks feel responsive.
1519class ReaderPageManager {
1620 static const int TYPE_ANIMATION_NONE = 0 ;
1721 static const int TYPE_ANIMATION_SIMULATION_TURN = 1 ;
1822 static const int TYPE_ANIMATION_COVER_TURN = 2 ;
1923 static const int TYPE_ANIMATION_SLIDE_TURN = 3 ;
2024
21- /// Short anti double-submit window only (not a hard UI lock).
22- static const Duration _cooldown = Duration (milliseconds: 220 );
25+ /// Anti double-submit only — short enough that queued taps drain quickly.
26+ static const Duration _cooldown = Duration (milliseconds: 70 );
27+
28+ /// Max queued tap directions (prevents runaway multi-page jump).
29+ static const int _maxQueued = 2 ;
2330
2431 late BaseAnimationPage currentAnimationPage;
2532 TouchEvent currentTouchData = TouchEvent (TouchEvent .ACTION_UP , Offset .zero);
@@ -41,6 +48,10 @@ class ReaderPageManager {
4148 int _turnEpoch = 0 ;
4249 DateTime ? _lastCommitAt;
4350
51+ /// Directions accepted while busy; drained after settle.
52+ final List <int > _queuedDirs = < int > [];
53+ bool _drainScheduled = false ;
54+
4455 /// True only while confirm/cancel animation is running.
4556 bool get isAnimating => currentState == PageTurnState .animating;
4657
@@ -53,13 +64,23 @@ class ReaderPageManager {
5364 return DateTime .now ().difference (t) < _cooldown;
5465 }
5566
67+ Duration get _cooldownRemaining {
68+ final t = _lastCommitAt;
69+ if (t == null ) return Duration .zero;
70+ final left = _cooldown - DateTime .now ().difference (t);
71+ return left.isNegative ? Duration .zero : left;
72+ }
73+
5674 bool get _needsController =>
5775 currentAnimationType == TYPE_ANIMATION_COVER_TURN ||
5876 currentAnimationType == TYPE_ANIMATION_SIMULATION_TURN ;
5977
6078 void _log (String msg) {
6179 if (kDebugMode) {
62- debugPrint ('[PageTurn] $msg state=$currentState epoch=$_turnEpoch ' );
80+ debugPrint (
81+ '[PageTurn] $msg state=$currentState epoch=$_turnEpoch '
82+ 'q=$_queuedDirs ' ,
83+ );
6384 }
6485 }
6586
@@ -131,26 +152,53 @@ class ReaderPageManager {
131152 _markPaint ();
132153 }
133154
155+ /// Start (or queue) a tap-driven turn. Returns true if accepted or queued.
134156 bool triggerTapTurn (int direction) {
157+ if (direction == 0 ) return false ;
158+ final dir = direction > 0 ? 1 : - 1 ;
159+
160+ // While a turn is in flight / cooling down, keep the latest intents so
161+ // rapid clicks still advance instead of being dropped.
135162 if (isBusy) {
136- _log ('triggerTapTurn ignored (busy animating=$isAnimating cd=$_inCooldown )' );
137- return false ;
163+ return _enqueueTap (dir);
164+ }
165+
166+ return _startTapTurn (dir);
167+ }
168+
169+ bool _enqueueTap (int dir) {
170+ if (_queuedDirs.length >= _maxQueued) {
171+ // Replace tail with latest intent (keeps 跟手 without multi-page jump).
172+ _queuedDirs[_queuedDirs.length - 1 ] = dir;
173+ _log ('triggerTapTurn queue full → replace tail dir=$dir ' );
174+ _scheduleDrain ();
175+ return true ;
138176 }
177+ // Collapse consecutive same-direction spam into one extra step max is
178+ // already capped by _maxQueued; still allow stacking up to the cap.
179+ _queuedDirs.add (dir);
180+ _log ('triggerTapTurn queued dir=$dir ' );
181+ _scheduleDrain ();
182+ return true ;
183+ }
139184
140- final goNext = direction > 0 ;
185+ bool _startTapTurn (int dir) {
186+ final goNext = dir > 0 ;
141187 if (goNext && ! currentAnimationPage.canTurnNext ()) {
142188 _log ('triggerTapTurn blocked: cannot go next' );
189+ _queuedDirs.clear ();
143190 return false ;
144191 }
145192 if (! goNext && ! currentAnimationPage.canTurnPrevious ()) {
146193 _log ('triggerTapTurn blocked: cannot go pre' );
194+ _queuedDirs.clear ();
147195 return false ;
148196 }
149197
150198 if (currentAnimationType == TYPE_ANIMATION_NONE ||
151199 animationController == null ) {
152- _log ('triggerTapTurn static dir=${ goNext ? 1 : - 1 } ' );
153- _commitTurn (goNext ? 1 : - 1 );
200+ _log ('triggerTapTurn static dir=$dir ' );
201+ _commitTurn (dir );
154202 return true ;
155203 }
156204
@@ -167,7 +215,7 @@ class ReaderPageManager {
167215 final h2 = currentAnimationPage.currentSize.height;
168216 if (w2 <= 0 || h2 <= 0 ) {
169217 _log ('triggerTapTurn fallback static (no size)' );
170- _commitTurn (goNext ? 1 : - 1 );
218+ _commitTurn (dir );
171219 return true ;
172220 }
173221
@@ -190,17 +238,49 @@ class ReaderPageManager {
190238 currentAnimationPage.onTouchEvent (TouchEvent (TouchEvent .ACTION_MOVE , end));
191239 currentTouchData = TouchEvent (TouchEvent .ACTION_MOVE , end);
192240
193- _log ('triggerTapTurn anim dir=${ goNext ? 1 : - 1 } size=${w2 }x$h2 ' );
194- final ok = startConfirmAnimation (goNext ? 1 : - 1 );
241+ _log ('triggerTapTurn anim dir=$dir size=${w2 }x$h2 ' );
242+ final ok = startConfirmAnimation (dir );
195243 if (! ok) {
196244 // Animation failed to start — still advance page so taps never "die".
197245 _log ('triggerTapTurn anim failed → instant commit' );
198- _commitTurn (goNext ? 1 : - 1 );
246+ _commitTurn (dir );
199247 return true ;
200248 }
201249 return true ;
202250 }
203251
252+ void _scheduleDrain () {
253+ if (_drainScheduled || _queuedDirs.isEmpty) return ;
254+ _drainScheduled = true ;
255+ final wait = _cooldownRemaining;
256+ void run () {
257+ _drainScheduled = false ;
258+ _drainQueue ();
259+ }
260+
261+ if (wait > Duration .zero) {
262+ Future <void >.delayed (wait, run);
263+ } else {
264+ // Defer out of animation status / commit re-entrancy.
265+ SchedulerBinding .instance.addPostFrameCallback ((_) => run ());
266+ }
267+ }
268+
269+ void _drainQueue () {
270+ if (_queuedDirs.isEmpty) return ;
271+ if (isAnimating) {
272+ // Will retry after current turn settles.
273+ return ;
274+ }
275+ if (_inCooldown) {
276+ _scheduleDrain ();
277+ return ;
278+ }
279+ final dir = _queuedDirs.removeAt (0 );
280+ _log ('drain queue dir=$dir remaining=$_queuedDirs ' );
281+ _startTapTurn (dir);
282+ }
283+
204284 void setPageSize (Size size) {
205285 currentAnimationPage.setSize (size);
206286 }
@@ -215,6 +295,7 @@ class ReaderPageManager {
215295
216296 void setCurrentAnimation (int animationType) {
217297 _abortAnimation ();
298+ _queuedDirs.clear ();
218299 currentAnimationType = animationType;
219300 switch (animationType) {
220301 case TYPE_ANIMATION_SIMULATION_TURN :
@@ -360,6 +441,8 @@ class ReaderPageManager {
360441 );
361442 _markPaint ();
362443 onTurnSettled? .call ();
444+ // Kick off any taps that arrived during this animation.
445+ _scheduleDrain ();
363446 };
364447
365448 animation.addListener (_tickListener! );
@@ -377,6 +460,7 @@ class ReaderPageManager {
377460 // Instant (static) turns settle immediately.
378461 if (currentState != PageTurnState .animating) {
379462 onTurnSettled? .call ();
463+ _scheduleDrain ();
380464 }
381465 }
382466
@@ -386,6 +470,7 @@ class ReaderPageManager {
386470
387471 void interruptCancelAnimation () {
388472 _abortAnimation ();
473+ _queuedDirs.clear ();
389474 _markPaint ();
390475 onTurnSettled? .call ();
391476 }
@@ -397,7 +482,8 @@ class ReaderPageManager {
397482 }
398483
399484 void setAnimationController (AnimationController controller) {
400- controller.duration ?? = const Duration (milliseconds: 280 );
485+ // Slightly snappier default so queued taps feel closer to 跟手.
486+ controller.duration ?? = const Duration (milliseconds: 220 );
401487 animationController = controller;
402488 if (_needsController) {
403489 currentAnimationPage.setAnimationController (controller);
0 commit comments