From 350d16db75ce67444e3173a335a9a26aaebf833e Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 21:13:55 +0800 Subject: [PATCH 01/29] =?UTF-8?q?fix(player):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E7=BC=93=E5=86=B2=E6=80=81=E4=B8=8E=E9=A6=96=E5=B8=A7=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E9=97=AA=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 2 + .../player/widgets/home_feed_overlay.dart | 3 + .../widgets/home_feed_overlay_parts.dart | 40 +++++- lib/pages/player/widgets/player_overlay.dart | 3 + .../player/widgets/seek_progress_bar.dart | 59 ++++++--- test/home/home_preview_layout_test.dart | 10 ++ test/playback/seek_progress_bar_test.dart | 118 ++++++++++++++++++ test/player/player_fullscreen_test.dart | 49 +++++++- 8 files changed, 262 insertions(+), 22 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index c825021..b3c2fd3 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -1161,6 +1161,7 @@ class _PlayerPageState extends State : null, thumbnailService: _thumbnailService, spriteAxisService: _spriteAxisService, + suppressBufferLoading: !widget.embedded && !_loadingRemoved, ) else if (!widget.homeMode) _standardPlaybackOverlay(context, index, interactions), @@ -1284,6 +1285,7 @@ class _PlayerPageState extends State onBoostEnd: _endBoost, ), controlsVisible: _chromeVisible, + suppressProgressLoading: !widget.embedded && !_loadingRemoved, ); } diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 15411ee..fdd4aac 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -69,6 +69,7 @@ class HomeFeedOverlay extends StatefulWidget { this.cancelScrubRequest = 0, this.thumbnailService, this.spriteAxisService, + this.suppressBufferLoading = false, }); final AppLocalizations l10n; @@ -104,6 +105,7 @@ class HomeFeedOverlay extends StatefulWidget { final int cancelScrubRequest; final PlaybackThumbnailService? thumbnailService; final SpriteThumbnailAxisService? spriteAxisService; + final bool suppressBufferLoading; @override State createState() => _HomeFeedOverlayState(); @@ -195,6 +197,7 @@ class _HomeFeedOverlayState extends State { spriteAxisService: widget.spriteAxisService, thumbnailsUrl: widget.episode.thumbnailsUrl, episodeId: widget.episode.id, + suppressBufferLoading: widget.suppressBufferLoading, onScrubStart: _startScrub, onScrubEnd: _endScrub, onSeek: widget.onSeek, diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 8934d39..3f534cc 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -23,6 +23,7 @@ class _FeedProgressBar extends StatefulWidget { this.spriteAxisService, this.thumbnailsUrl, this.episodeId, + this.suppressBufferLoading = false, this.onScrubStart, this.onScrubEnd, this.onSeek, @@ -37,6 +38,7 @@ class _FeedProgressBar extends StatefulWidget { final SpriteThumbnailAxisSource? spriteAxisService; final String? thumbnailsUrl; final String? episodeId; + final bool suppressBufferLoading; final VoidCallback? onScrubStart; final VoidCallback? onScrubEnd; final ValueChanged? onSeek; @@ -52,6 +54,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { Timer? _bufferTimer; bool _buffering = false; bool _dragging = false; + Duration? _lastPosition; @override void initState() { @@ -67,6 +70,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { _bufferTimer?.cancel(); _bufferTimer = null; _buffering = false; + _lastPosition = null; _attachSource(); } if (oldWidget.cancelScrubRequest != widget.cancelScrubRequest) { @@ -77,6 +81,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { _bufferTimer?.cancel(); _bufferTimer = null; _buffering = false; + _lastPosition = null; if (widget.locked) { _dragging = false; _preview = null; @@ -84,6 +89,13 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { _syncBufferState(notify: false); } } + if (oldWidget.suppressBufferLoading != widget.suppressBufferLoading) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _buffering = false; + _lastPosition = null; + _syncBufferState(notify: false); + } } @override @@ -100,7 +112,11 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { final width = constraints.maxWidth; final source = widget.controller; final scrubbing = _dragging; - final loading = !widget.locked && !scrubbing && _buffering; + final loading = + !widget.locked && + !widget.suppressBufferLoading && + !scrubbing && + _buffering; final track = widget.locked ? _inactiveTrack() : source == null @@ -199,14 +215,24 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { void _syncBufferState({bool notify = true}) { final source = widget.controller; - if (widget.locked || source == null) { + if (widget.locked || widget.suppressBufferLoading || source == null) { _bufferTimer?.cancel(); _bufferTimer = null; + _lastPosition = null; _setBuffering(false, notify: notify); return; } final value = source.value; if (!value.isInitialized) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _lastPosition = null; + _setBuffering(false, notify: notify); + return; + } + final previousPosition = _lastPosition; + _lastPosition = value.position; + if (previousPosition != null && value.position > previousPosition) { _bufferTimer?.cancel(); _bufferTimer = null; _setBuffering(false, notify: notify); @@ -218,14 +244,22 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { _setBuffering(false, notify: notify); return; } + if (previousPosition == null && + value.isPlaying && + value.position > Duration.zero) { + _setBuffering(false, notify: notify); + return; + } if (_buffering || _bufferTimer != null) return; + final bufferingPosition = value.position; _bufferTimer = Timer(_bufferDebounce, () { _bufferTimer = null; final current = widget.controller; if (!mounted || current != source || !current!.value.isInitialized || - !current.value.isBuffering) { + !current.value.isBuffering || + current.value.position != bufferingPosition) { return; } _setBuffering(true); diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 11ff157..561f486 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -37,6 +37,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { required this.actions, required this.controlsVisible, required this.scrubbing, + this.suppressProgressLoading = false, }); /// 展示数据。 @@ -50,6 +51,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { /// 正在拖动进度条。拖动期间清屏,只留进度条。 final bool scrubbing; + final bool suppressProgressLoading; @override Widget build(BuildContext context) { @@ -306,6 +308,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { episodeId: data.episode.id, inactiveTrackColor: AppColors.inactiveTrack, fallbackDuration: Duration(seconds: data.episode.durationSec), + suppressBufferLoading: suppressProgressLoading, onScrubStart: actions.onScrubStart, onScrubEnd: actions.onScrubEnd, onSeek: actions.onSeek, diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 7e566f9..82e8be9 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -43,6 +43,7 @@ class SeekProgressBar extends StatefulWidget { this.episodeId, this.thumbnailsUrl, this.fallbackDuration = Duration.zero, + this.suppressBufferLoading = false, }); final ValueListenable? valueListenable; @@ -59,6 +60,7 @@ class SeekProgressBar extends StatefulWidget { final String? episodeId; final String? thumbnailsUrl; final Duration fallbackDuration; + final bool suppressBufferLoading; @override State createState() => _SeekProgressBarState(); @@ -74,6 +76,7 @@ class _SeekProgressBarState extends State { bool _pointerMoved = false; Offset? _pointerStart; double? _dragStartFraction; + Duration? _lastPosition; @override void initState() { @@ -90,13 +93,22 @@ class _SeekProgressBarState extends State { _bufferTimer?.cancel(); _bufferTimer = null; _buffering = false; + _lastPosition = null; widget.thumbnailService?.cancelPending(); _attachSource(); } + if (oldWidget.suppressBufferLoading != widget.suppressBufferLoading) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _lastPosition = null; + _buffering = false; + _syncBufferState(notify: false); + } if (oldWidget.locked != widget.locked) { _bufferTimer?.cancel(); _bufferTimer = null; _buffering = false; + _lastPosition = null; if (widget.locked) { widget.thumbnailService?.cancelPending(); _previewFraction.value = null; @@ -130,14 +142,24 @@ class _SeekProgressBarState extends State { void _syncBufferState({bool notify = true}) { final source = widget.valueListenable; - if (widget.locked || source == null) { + if (widget.locked || widget.suppressBufferLoading || source == null) { _bufferTimer?.cancel(); _bufferTimer = null; + _lastPosition = null; _setBuffering(false, notify: notify); return; } final value = source.value; if (!value.isInitialized) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _lastPosition = null; + _setBuffering(false, notify: notify); + return; + } + final previousPosition = _lastPosition; + _lastPosition = value.position; + if (previousPosition != null && value.position > previousPosition) { _bufferTimer?.cancel(); _bufferTimer = null; _setBuffering(false, notify: notify); @@ -149,14 +171,24 @@ class _SeekProgressBarState extends State { _setBuffering(false, notify: notify); return; } + // 首次看到“已播放且有画面”的 buffering 状态时,以 position 为基线, + // 不抢先显示 loading;后续 position 停滞超过阈值才视为真实卡顿。 + if (previousPosition == null && + value.isPlaying && + value.position > Duration.zero) { + _setBuffering(false, notify: notify); + return; + } if (_buffering || _bufferTimer != null) return; + final bufferingPosition = value.position; _bufferTimer = Timer(_bufferDebounce, () { _bufferTimer = null; final current = widget.valueListenable; if (!mounted || current != source || !current!.value.isInitialized || - !current.value.isBuffering) { + !current.value.isBuffering || + current.value.position != bufferingPosition) { return; } _setBuffering(true); @@ -227,7 +259,11 @@ class _SeekProgressBarState extends State { : widget.fallbackDuration; final total = duration.inMilliseconds; final scrubbing = _dragging; - final loading = !widget.locked && !scrubbing && _buffering; + final loading = + !widget.locked && + !widget.suppressBufferLoading && + !scrubbing && + _buffering; final enabled = !widget.locked && video.isInitialized && @@ -243,6 +279,8 @@ class _SeekProgressBarState extends State { final showPreview = scrubbing && widget.showTimes && + video.isInitialized && + total > 0 && widget.thumbnailService != null && (widget.episodeId?.isNotEmpty ?? false); final trackHeight = scrubbing ? 6.0 : 2.0; @@ -253,21 +291,6 @@ class _SeekProgressBarState extends State { final track = Column( mainAxisSize: MainAxisSize.min, children: [ - if (widget.showTimes && !video.isInitialized) - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - '00:00 / ${_format(duration)}', - key: const Key('player-progress-uninitialized-time'), - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 12, - fontWeight: FontWeight.w600, - height: 1.2, - ), - ), - ), if (widget.showTimes && scrubbing && !showPreview) Padding( padding: const EdgeInsets.only(bottom: 2), diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 0c667ac..029414b 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -855,6 +855,16 @@ void main() { await tester.pump(); await gesture.up(); await tester.pumpAndSettle(); + + controller.value = const VideoPlayerValue( + duration: Duration(seconds: 100), + position: Duration(seconds: 40), + isInitialized: true, + isBuffering: true, + ); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byKey(const Key('home-feed-progress-thumb')), findsOneWidget); + expect(find.byKey(const Key('progress-loading')), findsNothing); }); testWidgets('should_show_refresh_spinner_until_refresh_finishes', ( diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index f584ef6..98cd2be 100644 --- a/test/playback/seek_progress_bar_test.dart +++ b/test/playback/seek_progress_bar_test.dart @@ -54,6 +54,11 @@ void main() { expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byKey(const Key('progress-inactive')), findsOneWidget); expect(find.byType(Slider), findsNothing); + expect( + find.byKey(const Key('player-progress-uninitialized-time')), + findsNothing, + ); + expect(find.textContaining('00:00'), findsNothing); }); testWidgets('should_show_inactive_track_and_disable_seek_when_locked', ( @@ -144,6 +149,119 @@ void main() { expect(find.byType(Slider), findsOneWidget); }); + testWidgets( + 'should_hide_buffering_loading_immediately_when_position_advances', + (tester) async { + final video = ValueNotifier( + const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 3), + isInitialized: true, + isPlaying: true, + isBuffering: true, + ), + ); + addTearDown(video.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SeekProgressBar( + valueListenable: video, + semanticsLabel: 'Seek video', + onSeek: (_) async {}, + ), + ), + ), + ); + + expect(find.byKey(const Key('progress-loading')), findsNothing); + await tester.pump(const Duration(milliseconds: 200)); + video.value = const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 4), + isInitialized: true, + isBuffering: true, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byType(Slider), findsOneWidget); + + video.value = const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 4), + isInitialized: true, + isPlaying: true, + isBuffering: true, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byKey(const Key('progress-loading')), findsOneWidget); + expect(find.byType(Slider), findsNothing); + }, + ); + + testWidgets('should_not_show_loading_for_prewarmed_episode', (tester) async { + final video = ValueNotifier( + const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 5), + isInitialized: true, + isPlaying: true, + isBuffering: true, + ), + ); + addTearDown(video.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SeekProgressBar( + valueListenable: video, + semanticsLabel: 'Seek video', + onSeek: (_) async {}, + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byType(Slider), findsOneWidget); + }); + + testWidgets( + 'should_suppress_progress_loading_while_entry_loading_is_visible', + (tester) async { + final video = ValueNotifier( + const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration.zero, + isInitialized: true, + isBuffering: true, + ), + ); + addTearDown(video.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SeekProgressBar( + valueListenable: video, + semanticsLabel: 'Seek video', + suppressBufferLoading: true, + onSeek: (_) async {}, + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byType(Slider), findsOneWidget); + }, + ); + testWidgets('should_keep_slider_while_scrubbing_during_buffering', ( tester, ) async { diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 784bb47..53d4b6f 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -361,7 +361,11 @@ void main() { findsOneWidget, ); expect(find.byKey(const Key('player-progress')), findsOneWidget); - expect(find.text('00:00 / 01:00'), findsOneWidget); + expect( + find.byKey(const Key('player-progress-uninitialized-time')), + findsNothing, + ); + expect(find.text('00:00 / 01:00'), findsNothing); // IgnorePointer 让加载中的播放暂停与上下滑穿透,滑动会关闭入口 loading。 expect(fullLogic.session.userWantsPlay, isTrue); @@ -515,6 +519,49 @@ void main() { expect(find.byKey(const Key('progress-inactive')), findsOneWidget); }); + testWidgets('should_only_show_progress_buffer_after_video_first_frame', ( + tester, + ) async { + final factory = _RouteVideoFactory(); + await _pumpApp(tester, playerFactory: factory.create); + await _openSeriesPlayer(tester); + + final videoIndex = factory.episodes.lastIndexWhere( + (episode) => episode.id == 'city-lights-demo-1', + ); + final video = factory.created[videoIndex]; + expect(video.controller.value.isInitialized, isTrue); + expect(find.byKey(const Key('player-entry-loading-overlay')), findsNothing); + + video.controller.value = const VideoPlayerValue( + duration: Duration(minutes: 1), + size: Size(1280, 720), + position: Duration(seconds: 5), + isInitialized: true, + isPlaying: true, + isBuffering: true, + ); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byKey(const Key('progress-loading')), findsOneWidget); + expect(find.byKey(const Key('player-entry-loading-overlay')), findsNothing); + expect(find.byKey(const Key('player-video-loading')), findsNothing); + expect(find.byKey(const Key('player-ticket-loading')), findsNothing); + + video.controller.value = const VideoPlayerValue( + duration: Duration(minutes: 1), + size: Size(1280, 720), + position: Duration(seconds: 6), + isInitialized: true, + isPlaying: true, + isBuffering: true, + ); + await tester.pump(); + + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byType(Slider), findsOneWidget); + }); + testWidgets('should_pause_feed_when_auth_page_is_pushed_and_resume_on_pop', ( tester, ) async { From 37353dac15565fa8d58495c59dadf545864af5ae Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 21:25:38 +0800 Subject: [PATCH 02/29] =?UTF-8?q?fix(player):=20=E8=A7=82=E7=9C=8B?= =?UTF-8?q?=E5=85=A8=E9=9B=86=E8=BF=9B=E5=85=A5=E5=90=8E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/services/playback_neighbors.dart | 18 ++++++++- lib/core/data/services/playback_session.dart | 40 +++++++++++++++++++ lib/pages/player/player_logic.dart | 2 + lib/pages/player/player_view.dart | 33 +++++++++++++-- test/player/player_fullscreen_test.dart | 39 +++++++++++++++++- 5 files changed, 126 insertions(+), 6 deletions(-) diff --git a/lib/core/data/services/playback_neighbors.dart b/lib/core/data/services/playback_neighbors.dart index 2d54d46..c5c9748 100644 --- a/lib/core/data/services/playback_neighbors.dart +++ b/lib/core/data/services/playback_neighbors.dart @@ -13,8 +13,18 @@ class PlaybackCoordinator { Future _acquire(PlaybackSession session, bool Function() valid) { final result = _handoff.then((_) async { if (!valid()) return false; + final previous = _owner; + if (kDebugMode) { + debugPrint( + '[player-coordinator] phase=acquire ' + 'session=${identityHashCode(session)} owner=${session.owner.name} ' + 'previous=${previous == null ? '-' : identityHashCode(previous)} ' + 'previousOwner=${previous?.owner.name ?? '-'} ' + 'previousLive=${previous?.livePlayerCount ?? 0} ' + 'same=${identical(previous, session)}', + ); + } if (_owner != session) { - final previous = _owner; if (previous != null && previous.owner != session.owner) { if (kDebugMode) { debugPrint( @@ -31,6 +41,12 @@ class PlaybackCoordinator { if (!valid()) return false; _owner = session; } + if (kDebugMode) { + debugPrint( + '[player-coordinator] phase=acquired ' + 'session=${identityHashCode(session)} live=${session.livePlayerCount}', + ); + } return true; }); _handoff = result.then((_) {}); diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index 205a6cd..9480288 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -84,6 +84,11 @@ class PlaybackSession extends ChangeNotifier { bool get canAutoAdvance => _mayPlay && !_loading && active != null; bool get isLocked => !canPlay(currentEpisode); String? get error => _error; + bool get debugAppResumed => _appResumed; + bool get debugRouteVisible => _routeVisible; + bool get debugOverlayOpen => _overlayOpen; + bool get debugVisible => _visible; + bool get debugMayPlay => _mayPlay; // Includes initialization and asynchronous disposal, until native release ends. int get livePlayerCount => _live.length; bool get activeSlotActivated => _active?.activated ?? false; @@ -254,6 +259,16 @@ class PlaybackSession extends ChangeNotifier { bool? userWantsPlay, }) async { if (_closed || index < 0 || index >= episodes.length) return; + if (kDebugMode) { + debugPrint( + '[player-session] phase=select-enter session=${identityHashCode(this)} ' + 'owner=${owner.name} index=$index user=$_userWantsPlay ' + 'selected=$_selected current=$_currentIndex ' + 'appResumed=$_appResumed routeVisible=$_routeVisible ' + 'overlayOpen=$_overlayOpen visible=$_visible mayPlay=$_mayPlay ' + 'live=${_live.length}', + ); + } // 切换到不同剧集时恢复播放;仍停留在同一集时保留用户暂停。 if (userWantsPlay != null) { _userWantsPlay = userWantsPlay; @@ -465,6 +480,22 @@ class PlaybackSession extends ChangeNotifier { Future _applyPlayback(_PlayerSlot slot) async { if (_closed || _active != slot || !slot.activated) return; final shouldPlay = _mayPlay && slot.index == _currentIndex && !isLocked; + final video = slot.player is VideoPlayerPort + ? slot.player as VideoPlayerPort + : null; + if (kDebugMode) { + debugPrint( + '[player-session] phase=apply-playback ' + 'session=${identityHashCode(this)} owner=${owner.name} ' + 'index=${slot.index} shouldPlay=$shouldPlay user=$_userWantsPlay ' + 'appResumed=$_appResumed routeVisible=$_routeVisible ' + 'overlayOpen=$_overlayOpen locked=$isLocked ' + 'player=${identityHashCode(slot.player)} ' + 'controller=${video == null ? '-' : identityHashCode(video.controller)} ' + 'isPlaying=${video?.controller.value.isPlaying ?? '-'} ' + 'slotPlaying=${slot.playing}', + ); + } if (shouldPlay == slot.playing) return; if (shouldPlay) { await slot.player.play(); @@ -472,6 +503,15 @@ class PlaybackSession extends ChangeNotifier { await slot.player.pause(); } slot.playing = shouldPlay; + if (kDebugMode) { + debugPrint( + '[player-session] phase=apply-playback-result ' + 'session=${identityHashCode(this)} index=${slot.index} ' + 'shouldPlay=$shouldPlay ' + 'isPlaying=${video?.controller.value.isPlaying ?? '-'} ' + 'slotPlaying=${slot.playing}', + ); + } } void _syncPlayback() { diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index ac76aa9..86fd109 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -249,6 +249,8 @@ class PlayerLogic extends ChangeNotifier { '[feed-session] phase=$phase session=${identityHashCode(session)} ' 'index=${session.currentIndex} episode=${session.currentEpisode.id} ' 'controller=${video == null ? '-' : identityHashCode(video.controller)} ' + 'user=${session.userWantsPlay} routeVisible=${session.debugRouteVisible} ' + 'appResumed=${session.debugAppResumed} overlayOpen=${session.debugOverlayOpen} ' 'live=${session.livePlayerCount}', ); } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index b3c2fd3..e2feb43 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -148,6 +148,7 @@ class _PlayerPageState extends State bool _loadingRemoved = true; bool _loadingFadeScheduled = false; bool _loggedLoadingUiVisible = false; + bool _loggedStartConditions = false; final PlayerHeartOverlayController _heartOverlayController = PlayerHeartOverlayController(); final Set _precachedPosterUrls = {}; @@ -160,6 +161,7 @@ class _PlayerPageState extends State void initState() { super.initState(); final handoff = widget.handoff; + final initialUserWantsPlay = handoff?.sourceUserWantsPlay ?? true; logic = PlayerBinding().create( history: context.read(), playback: context.read(), @@ -179,8 +181,21 @@ class _PlayerPageState extends State coordinator: widget.embedded ? context.read() : PlaybackCoordinator(), - initialUserWantsPlay: handoff?.sourceUserWantsPlay ?? true, + initialUserWantsPlay: initialUserWantsPlay, ); + if (kDebugMode) { + debugPrint( + '[player-start] phase=initialized ' + 'session=${identityHashCode(logic.session)} ' + 'initialUserWantsPlay=$initialUserWantsPlay ' + 'sessionUserWantsPlay=${logic.session.userWantsPlay} ' + 'active=${logic.active} embedded=${widget.embedded} ' + 'appResumed=${logic.session.debugAppResumed} ' + 'routeVisible=${logic.session.debugRouteVisible} ' + 'overlayOpen=${logic.session.debugOverlayOpen} ' + 'live=${logic.session.livePlayerCount}', + ); + } _pageController = logic.pages; if (!widget.embedded && !logic.isEpisodeLocked(logic.session.currentEpisode)) { @@ -575,6 +590,19 @@ class _PlayerPageState extends State Widget _buildPlayer(BuildContext context) { final interactions = context.watch(); final l10n = AppLocalizations.of(context)!; + if (kDebugMode && !_loggedStartConditions) { + _loggedStartConditions = true; + debugPrint( + '[player-start] phase=first-build ' + 'session=${identityHashCode(logic.session)} ' + 'tickerMode=${TickerMode.valuesOf(context).enabled} ' + 'routeVisible=${logic.session.debugRouteVisible} ' + 'appResumed=${logic.session.debugAppResumed} ' + 'overlayOpen=${logic.session.debugOverlayOpen} ' + 'visible=${logic.session.debugVisible} ' + 'mayPlay=${logic.session.debugMayPlay}', + ); + } final indices = logic.visibleIndices; final activePort = logic.session.active; final activeVideo = activePort is VideoPlayerPort ? activePort : null; @@ -1341,8 +1369,7 @@ class _PlayerPageState extends State seriesEpisodes.insert(0, sourceEpisode); episodeIndex = 0; } - final sourceUserWantsPlay = logic.session.userWantsPlay; - final handoff = PlayerPageHandoff(sourceUserWantsPlay: sourceUserWantsPlay); + final handoff = PlayerPageHandoff(sourceUserWantsPlay: true); _logFullPlayerRoute('present'); setState(() => _presentationActive = true); final route = Navigator.of(context).push( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 53d4b6f..b967a9f 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -229,7 +229,7 @@ void main() { expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget); }); - testWidgets('should_show_one_paused_overlay_when_entering_full_player', ( + testWidgets('should_autoplay_full_player_and_restore_paused_feed', ( tester, ) async { final factory = _RouteVideoFactory(); @@ -245,7 +245,9 @@ void main() { await tester.pumpAndSettle(); expect(_fullPlayerFinder(), findsOneWidget); - expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); + final fullLogic = _pageLogic(tester, _fullPlayerFinder()); + expect(fullLogic.session.userWantsPlay, isTrue); + expect(find.byKey(const Key('player-play-toggle')), findsNothing); expect( find.byKey(const Key('home-feed-center-play')).hitTestable(), findsNothing, @@ -519,6 +521,37 @@ void main() { expect(find.byKey(const Key('progress-inactive')), findsOneWidget); }); + testWidgets('should_auto_play_full_player_when_feed_is_paused', ( + tester, + ) async { + await _pumpApp(tester, playerFactory: (_) => _RecordingPlayer()); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + + final feedPage = find.byKey(const Key('recommend-feed-player')); + final feedLogic = _pageLogic(tester, feedPage); + await feedLogic.session.preloadSettled; + feedLogic.session.setUserWantsPlay(false); + await feedLogic.session.playbackSettled; + await tester.pump(); + expect(feedLogic.session.userWantsPlay, isFalse); + + await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await tester.pumpAndSettle(); + + final fullLogic = _pageLogic(tester, _fullPlayerFinder()); + final fullPlayer = fullLogic.session.active! as _RecordingPlayer; + expect(fullLogic.session, isNot(same(feedLogic.session))); + expect(fullLogic.session.userWantsPlay, isTrue); + expect(fullPlayer.playCalls, greaterThan(0)); + expect(fullPlayer.playing, isTrue); + + await tester.tap(find.byKey(const Key('player-back')).hitTestable()); + await tester.pumpAndSettle(); + expect(feedLogic.session.userWantsPlay, isFalse); + expect(feedLogic.session.activeSlotPlaying, isFalse); + }); + testWidgets('should_only_show_progress_buffer_after_video_first_frame', ( tester, ) async { @@ -2103,12 +2136,14 @@ class _RouteVideoPlayer extends VideoPlayerPort { class _RecordingPlayer implements PlayerPort { bool playing = false; + int playCalls = 0; @override Future initialize() async {} @override Future play() async { + playCalls++; playing = true; } From 188811fde6a3d9015d47cb49af6b57d25c285202 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 21:40:39 +0800 Subject: [PATCH 03/29] =?UTF-8?q?feat(player):=20=E5=88=87=E9=9B=86?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E4=BA=92=E5=8A=A8=E8=AE=A1=E6=95=B0=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/models/drama.dart | 14 + lib/core/data/models/episode_stats.dart | 55 ++++ lib/core/data/remote/playback_api.dart | 8 + .../repositories/playback_repository.dart | 30 +- .../data/services/favorite_controller.dart | 11 + .../data/services/interaction_controller.dart | 18 ++ lib/pages/player/player_logic.dart | 203 +++++++++++- lib/pages/player/player_view.dart | 59 +++- lib/pages/player/player_view_actions.dart | 10 +- test/player/player_episode_stats_test.dart | 301 ++++++++++++++++++ test/player/player_fullscreen_test.dart | 5 + 11 files changed, 694 insertions(+), 20 deletions(-) create mode 100644 lib/core/data/models/episode_stats.dart create mode 100644 test/player/player_episode_stats_test.dart diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart index 0ca516e..c02e6dd 100644 --- a/lib/core/data/models/drama.dart +++ b/lib/core/data/models/drama.dart @@ -60,6 +60,20 @@ class Episode { posterUrl: posterUrl ?? this.posterUrl, ); + Episode withStats({int? likeCount, int? commentCount}) => Episode( + id: id, + number: number, + title: title, + isFree: isFree, + videoUrl: videoUrl, + durationSec: durationSec, + locked: locked, + likeCount: likeCount ?? this.likeCount, + commentCount: commentCount ?? this.commentCount, + thumbnailsUrl: thumbnailsUrl, + posterUrl: posterUrl, + ); + factory Episode.fromJson(Map json) { return Episode( id: (json['id'] ?? '').toString(), diff --git a/lib/core/data/models/episode_stats.dart b/lib/core/data/models/episode_stats.dart new file mode 100644 index 0000000..65a29d6 --- /dev/null +++ b/lib/core/data/models/episode_stats.dart @@ -0,0 +1,55 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// 播放页轻量互动计数快照,不包含播放地址或票据信息。 +class EpisodeStats { + const EpisodeStats({ + required this.episodeId, + required this.dramaId, + required this.likeCount, + required this.commentCount, + required this.favoriteCount, + required this.liked, + required this.favorited, + }); + + final String episodeId; + final String dramaId; + final int likeCount; + final int commentCount; + final int favoriteCount; + final bool liked; + final bool favorited; + + EpisodeStats copyWith({ + String? dramaId, + int? likeCount, + int? commentCount, + int? favoriteCount, + bool? liked, + bool? favorited, + }) { + return EpisodeStats( + episodeId: episodeId, + dramaId: dramaId ?? this.dramaId, + likeCount: likeCount ?? this.likeCount, + commentCount: commentCount ?? this.commentCount, + favoriteCount: favoriteCount ?? this.favoriteCount, + liked: liked ?? this.liked, + favorited: favorited ?? this.favorited, + ); + } + + factory EpisodeStats.fromJson(Map json) { + return EpisodeStats( + episodeId: jsonToId(json['episodeId']), + dramaId: jsonToId(json['dramaId']), + likeCount: jsonToInt(json['likeCount']), + commentCount: jsonToInt(json['commentCount']), + favoriteCount: jsonToInt(json['favoriteCount']), + liked: json['liked'] == true, + favorited: json['favorited'] == true, + ); + } +} diff --git a/lib/core/data/remote/playback_api.dart b/lib/core/data/remote/playback_api.dart index 0122d95..6b40fb7 100644 --- a/lib/core/data/remote/playback_api.dart +++ b/lib/core/data/remote/playback_api.dart @@ -1,5 +1,6 @@ // Author: fruit.com.cn +import '../models/episode_stats.dart'; import '../models/play_ticket.dart'; import 'api_client.dart'; @@ -17,4 +18,11 @@ class PlaybackApi { if (data is Map) return PlayTicket.fromJson(data); throw const ApiException(-1, '返回数据格式异常'); } + + /// 轻量刷新互动计数:不签发地址,也不占用播放票据缓存。 + Future episodeStats(String episodeId) async { + final data = await _client.get('/app/episodes/$episodeId/stats'); + if (data is Map) return EpisodeStats.fromJson(data); + throw const ApiException(-1, '返回数据格式异常'); + } } diff --git a/lib/core/data/repositories/playback_repository.dart b/lib/core/data/repositories/playback_repository.dart index d68065a..c2bc4af 100644 --- a/lib/core/data/repositories/playback_repository.dart +++ b/lib/core/data/repositories/playback_repository.dart @@ -1,5 +1,6 @@ // Author: fruit.com.cn +import '../models/episode_stats.dart'; import '../models/play_ticket.dart'; import '../remote/playback_api.dart'; @@ -10,6 +11,9 @@ import '../remote/playback_api.dart'; abstract interface class PlaybackRepository { Future ticketFor(String episodeId, {bool forceRefresh = false}); + /// 读取互动计数快照;与 [ticketFor] 分离,避免为了刷新计数重新签发地址。 + Future episodeStats(String episodeId); + /// 当前缓存的票据是否还在有效期内(用于"回前台发现过期就重新取")。 bool isFresh(String episodeId); } @@ -42,6 +46,10 @@ class HttpPlaybackRepository implements PlaybackRepository { return ticket; } + @override + Future episodeStats(String episodeId) => + _api.episodeStats(episodeId); + @override bool isFresh(String episodeId) { final cached = _cache[episodeId]; @@ -65,11 +73,22 @@ class _CachedTicket { /// 测试与离线场景用:按集返回固定票据,并记录被请求过几次。 class FakePlaybackRepository implements PlaybackRepository { - FakePlaybackRepository({this.ticket, this.error, this.fresh = true}); + FakePlaybackRepository({ + this.ticket, + this.error, + this.fresh = true, + this.stats, + this.statsError, + Map? statsByEpisode, + }) : statsByEpisode = statsByEpisode ?? {}; PlayTicket? ticket; Object? error; + EpisodeStats? stats; + Object? statsError; + final Map statsByEpisode; final List requests = []; + final List statsRequests = []; final List forced = []; /// 模拟"票据是否还有效":置 false 可验证回前台会重新取。 @@ -95,4 +114,13 @@ class FakePlaybackRepository implements PlaybackRepository { qualities: const ['AUTO', '720P', '1080P'], ); } + + @override + Future episodeStats(String episodeId) async { + statsRequests.add(episodeId); + if (statsError != null) throw statsError!; + final value = statsByEpisode[episodeId] ?? stats; + if (value != null) return value; + throw StateError('episode stats not configured for $episodeId'); + } } diff --git a/lib/core/data/services/favorite_controller.dart b/lib/core/data/services/favorite_controller.dart index 1a30e0e..d57ca21 100644 --- a/lib/core/data/services/favorite_controller.dart +++ b/lib/core/data/services/favorite_controller.dart @@ -32,6 +32,17 @@ class FavoriteController extends ChangeNotifier { bool isSaved(String dramaId) => _saved.contains(dramaId); + /// 用统计快照校准收藏状态;已有列表卡片结构不因一次状态同步而改动。 + void applyServerState(String dramaId, {required bool saved}) { + final changed = _saved.contains(dramaId) != saved; + if (saved) { + _saved.add(dramaId); + } else { + _saved.remove(dramaId); + } + if (changed) notify(); + } + void notify() { if (!_disposed) notifyListeners(); } diff --git a/lib/core/data/services/interaction_controller.dart b/lib/core/data/services/interaction_controller.dart index fa585ea..a21f247 100644 --- a/lib/core/data/services/interaction_controller.dart +++ b/lib/core/data/services/interaction_controller.dart @@ -40,6 +40,24 @@ class InteractionController extends ChangeNotifier { /// 该集服务端最新点赞数。 int? episodeLikeCount(String episodeId) => _episodeLikeCounts[episodeId]; + /// 用轻量统计快照校准当前用户的分集点赞状态与计数。 + void applyEpisodeStats( + String episodeId, { + required bool liked, + required int likeCount, + }) { + final changed = + _likedEpisodeIds.contains(episodeId) != liked || + _episodeLikeCounts[episodeId] != likeCount; + if (liked) { + _likedEpisodeIds.add(episodeId); + } else { + _likedEpisodeIds.remove(episodeId); + } + _episodeLikeCounts[episodeId] = likeCount; + if (changed) _notify(); + } + /// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。 Future> toggleDramaLike(String dramaId) => _toggleLike( diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 86fd109..4bb67af 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -9,6 +9,7 @@ import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/episode_stats.dart'; import '../../core/data/models/play_ticket.dart'; import '../../core/data/models/watch_progress.dart'; import '../../core/data/models/player_port.dart'; @@ -51,9 +52,11 @@ class PlayerLogic extends ChangeNotifier { this.downloads, PlaybackPositionCache? positionCache, Future Function()? waitForFrame, + DateTime Function()? clock, }) : positionCache = positionCache ?? PlaybackPositionCache(), _waitForFrame = - waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame); + waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame), + _clock = clock ?? DateTime.now; final WatchHistoryRepository history; final PlaybackRepository playback; @@ -62,6 +65,7 @@ class PlayerLogic extends ChangeNotifier { final DownloadController? downloads; final PlaybackPositionCache positionCache; final Future Function() _waitForFrame; + final DateTime Function() _clock; late PageController pages; late PlaybackQueue queue; @@ -100,11 +104,19 @@ class PlayerLogic extends ChangeNotifier { bool muted = false; double playbackSpeed = 1.0; bool stoppedAtFeedEnd = false; + Duration episodeStatsTtl = const Duration(seconds: 60); + ValueChanged? onEpisodeStatsUpdated; VideoPlayerPort? scrubbingPlayer; bool pip = false; bool pipPreparing = false; final Map pickedEpisode = {}; final Set _unlockedEpisodeIds = {}; + final Map _episodeStats = {}; + final Map _episodeStatsFetchedAt = {}; + final Map> _episodeStatsInFlight = {}; + final Map _episodeStatsRevision = {}; + final Map _dramaStatsRevision = {}; + final Map _dramaFavoriteCounts = {}; /// 解锁中的集 id 与失败文案;空表示没有进行中的解锁。 String? unlockingEpisodeId; @@ -168,6 +180,10 @@ class PlayerLogic extends ChangeNotifier { return fallback; } + EpisodeStats? episodeStats(String episodeId) => _episodeStats[episodeId]; + + int? dramaFavoriteCount(String dramaId) => _dramaFavoriteCounts[dramaId]; + void notify() { if (!_disposed) notifyListeners(); } @@ -536,6 +552,179 @@ class PlayerLogic extends ChangeNotifier { } } + /// 当前集激活后刷新轻量互动计数;同集命中 TTL 或已有请求时不重复发请求。 + Future refreshEpisodeStats(int index) { + if (_disposed || index < 0 || index >= sessionEpisodes.length) { + return Future.value(); + } + final episodeId = sessionEpisodes[index].id; + final cached = _episodeStats[episodeId]; + final fetchedAt = _episodeStatsFetchedAt[episodeId]; + if (cached != null && + fetchedAt != null && + _clock().difference(fetchedAt) < episodeStatsTtl) { + _logEpisodeStatsRefresh(episodeId, cached: true); + onEpisodeStatsUpdated?.call(cached); + return Future.value(); + } + final inFlight = _episodeStatsInFlight[episodeId]; + if (inFlight != null) { + _logEpisodeStatsRefresh(episodeId, cached: true, inFlight: true); + return inFlight; + } + _logEpisodeStatsRefresh(episodeId, cached: false); + final expectedDramaId = index < queue.length + ? queue.entryAt(index).drama.id + : ''; + final episodeRevision = _episodeStatsRevision[episodeId] ?? 0; + final dramaRevision = _dramaStatsRevision[expectedDramaId] ?? 0; + late final Future request; + request = + _fetchEpisodeStats( + episodeId, + expectedDramaId: expectedDramaId, + episodeRevision: episodeRevision, + dramaRevision: dramaRevision, + ).whenComplete(() { + if (identical(_episodeStatsInFlight[episodeId], request)) { + _episodeStatsInFlight.remove(episodeId); + } + }); + _episodeStatsInFlight[episodeId] = request; + return request; + } + + Future _fetchEpisodeStats( + String episodeId, { + required String expectedDramaId, + required int episodeRevision, + required int dramaRevision, + }) async { + try { + final fetched = await playback.episodeStats(episodeId); + if (_disposed) return; + if ((_episodeStatsRevision[episodeId] ?? 0) != episodeRevision || + (_dramaStatsRevision[expectedDramaId] ?? 0) != dramaRevision) { + if (kDebugMode) { + debugPrint( + '[player] phase=episode-stats-discarded episodeId=$episodeId ' + 'reason=local-state-newer', + ); + } + return; + } + final stats = fetched.copyWith( + dramaId: fetched.dramaId.isEmpty ? expectedDramaId : fetched.dramaId, + ); + _episodeStats[episodeId] = stats; + _episodeStatsFetchedAt[episodeId] = _clock(); + if (stats.dramaId.isNotEmpty) { + _dramaFavoriteCounts[stats.dramaId] = stats.favoriteCount; + } + final episodeIndex = sessionEpisodes.indexWhere( + (episode) => episode.id == episodeId, + ); + if (episodeIndex >= 0) { + sessionEpisodes[episodeIndex] = sessionEpisodes[episodeIndex].withStats( + likeCount: stats.likeCount, + commentCount: stats.commentCount, + ); + } + onEpisodeStatsUpdated?.call(stats); + notify(); + } catch (_) { + // 统计接口只负责校准计数,失败时保留播放与旧值。 + } + } + + void _logEpisodeStatsRefresh( + String episodeId, { + required bool cached, + bool inFlight = false, + }) { + if (!kDebugMode) return; + debugPrint( + '[player] phase=episode-stats-refresh episodeId=$episodeId ' + 'cached=${cached ? 1 : 0}${inFlight ? ' inFlight=1' : ''}', + ); + } + + /// 点赞成功后同步本地统计快照,避免切回时被旧缓存覆盖。 + void applyEpisodeLikeState( + String episodeId, { + required bool liked, + required int likeCount, + }) { + _episodeStatsRevision.update( + episodeId, + (value) => value + 1, + ifAbsent: () => 1, + ); + final stats = _episodeStats[episodeId]; + if (stats != null) { + _episodeStats[episodeId] = stats.copyWith( + liked: liked, + likeCount: likeCount, + ); + } + final index = sessionEpisodes.indexWhere( + (episode) => episode.id == episodeId, + ); + if (index >= 0) { + sessionEpisodes[index] = sessionEpisodes[index].withStats( + likeCount: likeCount, + ); + } + notify(); + } + + /// 评论面板关闭后同步评论数。 + void applyEpisodeCommentCount(String episodeId, int commentCount) { + _episodeStatsRevision.update( + episodeId, + (value) => value + 1, + ifAbsent: () => 1, + ); + final stats = _episodeStats[episodeId]; + if (stats != null) { + _episodeStats[episodeId] = stats.copyWith(commentCount: commentCount); + } + final index = sessionEpisodes.indexWhere( + (episode) => episode.id == episodeId, + ); + if (index >= 0) { + sessionEpisodes[index] = sessionEpisodes[index].withStats( + commentCount: commentCount, + ); + } + notify(); + } + + /// 收藏成功时同时修正剧级计数和所有已缓存的同剧互动状态。 + void applyDramaFavoriteState( + String dramaId, { + required bool favorited, + int? favoriteCount, + }) { + _dramaStatsRevision.update( + dramaId, + (value) => value + 1, + ifAbsent: () => 1, + ); + if (favoriteCount != null) { + _dramaFavoriteCounts[dramaId] = favoriteCount; + } + for (final entry in _episodeStats.entries.toList()) { + final stats = entry.value; + if (stats.dramaId != dramaId) continue; + _episodeStats[entry.key] = stats.copyWith( + favorited: favorited, + favoriteCount: favoriteCount, + ); + } + notify(); + } + String _fileUrl(String path) => path.startsWith('file:') ? path : Uri.file(path).toString(); @@ -1076,7 +1265,10 @@ class PlayerLogic extends ChangeNotifier { !isMounted()) { return; } - if (!ready) return; + if (!ready) { + unawaited(refreshEpisodeStats(index)); + return; + } await _selectVisible(index, userWantsPlay: userWantsPlay); } @@ -1087,13 +1279,16 @@ class PlayerLogic extends ChangeNotifier { } /// 按可见队列选择一集,并预热上一集与下一集。 - Future _selectVisible(int index, {bool? userWantsPlay}) { - return session.select( + Future _selectVisible(int index, {bool? userWantsPlay}) async { + await session.select( index, preloadIndex: nextVisibleIndex(index), previousIndex: previousVisibleIndex(index), userWantsPlay: userWantsPlay, ); + if (!_disposed && session.currentIndex == index) { + unawaited(refreshEpisodeStats(index)); + } } int? nextVisibleIndex(int index) { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index e2feb43..0e9c110 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -9,6 +9,7 @@ import 'package:provider/provider.dart'; import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/episode_stats.dart'; import '../../core/data/repositories/comment_repository.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/favorite_controller.dart'; @@ -153,8 +154,7 @@ class _PlayerPageState extends State PlayerHeartOverlayController(); final Set _precachedPosterUrls = {}; - /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 - final Map _favoriteDelta = {}; + /// 评论接口返回的最新数量;统计快照优先,失败时保留这里的旧值。 final Map _commentCounts = {}; @override @@ -169,6 +169,7 @@ class _PlayerPageState extends State wallet: widget.wallet, downloads: widget.downloads, ); + logic.onEpisodeStatsUpdated = _applyEpisodeStats; logic.attach(widget, isMounted: () => mounted); _thumbnailService = PlaybackThumbnailService( sourceResolver: _resolveThumbnailSource, @@ -550,14 +551,39 @@ class _PlayerPageState extends State setState(() {}); } - /// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。 - void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) { + void _applyEpisodeStats(EpisodeStats stats) { + if (!mounted) return; + context.read().applyEpisodeStats( + stats.episodeId, + liked: stats.liked, + likeCount: stats.likeCount, + ); + if (stats.dramaId.isNotEmpty) { + context.read().applyServerState( + stats.dramaId, + saved: stats.favorited, + ); + } setState(() { - _favoriteDelta[dramaId] = - (_favoriteDelta[dramaId] ?? 0) + (nowSaved ? 1 : -1); + _commentCounts[stats.episodeId] = stats.commentCount; }); } + /// 收藏状态确认后同步右侧剧级计数。 + void _applyFavoriteDelta(Drama drama, {required bool nowSaved}) { + final current = logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount; + final next = current == null + ? null + : nowSaved + ? current + 1 + : (current > 0 ? current - 1 : 0); + logic.applyDramaFavoriteState( + drama.id, + favorited: nowSaved, + favoriteCount: next, + ); + } + Future _openComments( BuildContext context, Drama drama, @@ -573,6 +599,7 @@ class _PlayerPageState extends State try { final count = await comments.count(drama.id, episodeId: episode.id); if (!mounted) return; + logic.applyEpisodeCommentCount(episode.id, count); setState(() => _commentCounts[episode.id] = count); } catch (_) { // 评论面板已经关闭,计数刷新失败时保留旧值即可。 @@ -1159,10 +1186,12 @@ class _PlayerPageState extends State liked: interactions.isEpisodeLiked(episode.id), likeCount: interactions.episodeLikeCount(episode.id) ?? episode.likeCount, - favoriteCount: drama.favoriteCount == null - ? null - : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), - commentCount: _commentCounts[episode.id] ?? episode.commentCount, + favoriteCount: + logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount, + commentCount: + logic.episodeStats(episode.id)?.commentCount ?? + _commentCounts[episode.id] ?? + episode.commentCount, saved: context.watch().isSaved(drama.id), onLike: () async { await _toggleLike(context, interactions, episode); @@ -1265,10 +1294,12 @@ class _PlayerPageState extends State liked: interactions.isEpisodeLiked(episode.id), likeCount: interactions.episodeLikeCount(episode.id) ?? episode.likeCount, - commentCount: _commentCounts[episode.id] ?? episode.commentCount, - favoriteCount: drama.favoriteCount == null - ? null - : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), + commentCount: + logic.episodeStats(episode.id)?.commentCount ?? + _commentCounts[episode.id] ?? + episode.commentCount, + favoriteCount: + logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount, saved: context.watch().isSaved(drama.id), playing: logic.session.userWantsPlay, playbackSpeed: _playbackSpeed, diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 19f6667..5a19507 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -151,6 +151,14 @@ extension _PlayerViewActions on _PlayerPageState { ); } if (result.isSuccess) { + final likeCount = interactions.episodeLikeCount(episode.id); + if (likeCount != null) { + logic.applyEpisodeLikeState( + episode.id, + liked: interactions.isEpisodeLiked(episode.id), + likeCount: likeCount, + ); + } _refreshLikeState(); return; } @@ -171,7 +179,7 @@ extension _PlayerViewActions on _PlayerPageState { if (ok) { final nowSaved = favorites.isSaved(drama.id); if (nowSaved != wasSaved) { - _applyFavoriteDelta(drama.id, nowSaved: nowSaved); + _applyFavoriteDelta(drama, nowSaved: nowSaved); } return; } diff --git a/test/player/player_episode_stats_test.dart b/test/player/player_episode_stats_test.dart new file mode 100644 index 0000000..da1d269 --- /dev/null +++ b/test/player/player_episode_stats_test.dart @@ -0,0 +1,301 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/episode_stats.dart'; +import 'package:fruitshorts/core/data/models/player_port.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/playback_api.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; +import 'package:fruitshorts/core/data/services/playback_session.dart'; +import 'package:fruitshorts/pages/player/player_logic.dart'; +import 'package:fruitshorts/pages/player/player_view.dart'; + +import '../support/fake_http_adapter.dart'; +import '../support/fake_watch_history.dart'; + +Drama _drama(String id) => Drama( + id: id, + title: id, + synopsis: 'synopsis', + posterUrl: '/files/poster.png', + episodes: [ + for (var number = 1; number <= 3; number++) + Episode( + id: '$id-$number', + number: number, + title: 'Episode $number', + isFree: true, + videoUrl: 'https://example.test/$id-$number.mp4', + likeCount: 1, + commentCount: 1, + ), + ], +); + +AndroidPictureInPicture _pip() => AndroidPictureInPicture( + channel: const MethodChannel('test/player_episode_stats/pip'), +); + +PlayerLogic _logic({ + required PlaybackRepository playback, + DateTime Function()? clock, +}) => PlayerLogic( + history: FakeWatchHistory(), + playback: playback, + pictureInPicture: _pip(), + waitForFrame: () async {}, + clock: clock, +); + +Future _drain(PlayerLogic logic) async { + for (var attempt = 0; attempt < 4; attempt++) { + await pumpEventQueue(); + await logic.session.playbackSettled; + await logic.session.preloadSettled; + } + await pumpEventQueue(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'should_refresh_episode_stats_on_up_and_down_swipes_without_reopening_play', + () async { + var now = DateTime(2026, 10, 26, 12); + final adapter = FakeHttpAdapter((options) async { + final path = options.uri.path; + if (path.endsWith('/play')) { + final episodeId = path.split('/')[3]; + return FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'episodeId': episodeId, + 'accessible': true, + 'accessType': 'FREE', + 'playUrl': '/files/$episodeId.mp4', + 'qualities': ['AUTO'], + }, + }), + ); + } + if (path.endsWith('/stats')) { + final episodeId = path.split('/')[3]; + return FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'episodeId': episodeId, + 'dramaId': 'signal', + 'likeCount': episodeId == 'signal-1' ? '11' : '22', + 'commentCount': episodeId == 'signal-1' ? '3' : '4', + 'favoriteCount': '30', + 'liked': episodeId == 'signal-1', + 'favorited': false, + }, + }), + ); + } + throw StateError('unexpected request $path'); + }); + final client = ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: adapter, + ); + final playback = HttpPlaybackRepository( + PlaybackApi(client), + clock: () => now, + ); + final logs = []; + final previousDebugPrint = debugPrint; + debugPrint = (message, {wrapWidth}) { + if (message == null) return; + logs.add(message); + if (message.contains('[player] phase=episode-stats-refresh')) { + previousDebugPrint(message, wrapWidth: wrapWidth); + } + }; + addTearDown(() { + debugPrint = previousDebugPrint; + client.close(); + }); + + final factory = _RecordingFactory(); + final logic = _logic( + playback: playback, + clock: () => now, + ); + logic.attach( + PlayerPage(drama: _drama('signal'), initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: factory.create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + + await logic.requestEpisodeSelection(1); + await _drain(logic); + final playRequestsBeforeBack = adapter.requests + .where((request) => request.uri.path.endsWith('/play')) + .length; + final statsRequestsBeforeBack = adapter.requests + .where((request) => request.uri.path.endsWith('/stats')) + .length; + + await logic.requestEpisodeSelection(0); + await _drain(logic); + + expect( + adapter.requests.where((request) => request.uri.path.endsWith('/play')), + hasLength(playRequestsBeforeBack), + ); + expect( + adapter.requests.where( + (request) => request.uri.path.endsWith('/stats'), + ), + hasLength(statsRequestsBeforeBack), + ); + expect( + logs, + contains( + '[player] phase=episode-stats-refresh episodeId=signal-1 cached=1', + ), + ); + expect(logic.episodeStats('signal-1')?.likeCount, 11); + + now = now.add(const Duration(seconds: 61)); + await logic.requestEpisodeSelection(1); + await _drain(logic); + await logic.requestEpisodeSelection(0); + await _drain(logic); + + expect( + adapter.requests.where( + (request) => request.uri.path.endsWith('/stats'), + ), + hasLength(statsRequestsBeforeBack + 2), + ); + expect( + adapter.requests.where((request) => request.uri.path.endsWith('/play')), + hasLength(playRequestsBeforeBack), + ); + expect( + logs.where( + (line) => line.contains( + 'episode-stats-refresh episodeId=signal-1 cached=0', + ), + ), + hasLength(2), + ); + + logic.dispose(); + await _drain(logic); + expect(factory.live, 0); + }, + ); + + test('should_keep_local_like_count_when_switching_back', () async { + final stats = EpisodeStats( + episodeId: 'signal-1', + dramaId: 'signal', + likeCount: 7, + commentCount: 2, + favoriteCount: 30, + liked: false, + favorited: false, + ); + final playback = FakePlaybackRepository( + statsByEpisode: {'signal-1': stats}, + ); + final logic = _logic(playback: playback); + logic.attach( + PlayerPage(drama: _drama('signal'), initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: _RecordingFactory().create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + expect(logic.episodeStats('signal-1')?.likeCount, 7); + + logic.applyEpisodeLikeState('signal-1', liked: true, likeCount: 8); + await logic.requestEpisodeSelection(1); + await _drain(logic); + await logic.requestEpisodeSelection(0); + await _drain(logic); + + expect(logic.episodeStats('signal-1')?.liked, isTrue); + expect(logic.episodeStats('signal-1')?.likeCount, 8); + + logic.dispose(); + await _drain(logic); + }); + + test('should_keep_playback_when_episode_stats_fail', () async { + final playback = FakePlaybackRepository( + statsError: StateError('stats unavailable'), + ); + final logic = _logic(playback: playback); + logic.attach( + PlayerPage(drama: _drama('signal'), initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: _RecordingFactory().create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + + expect(logic.session.active, isNotNull); + expect(logic.ticketError, isNull); + expect(logic.episodeStats('signal-1'), isNull); + + logic.dispose(); + await _drain(logic); + }); +} + +class _RecordingFactory { + int live = 0; + final List<_RecordingPlayer> created = []; + + PlayerPort create(Episode episode) { + live++; + final player = _RecordingPlayer(onDispose: () => live--); + created.add(player); + return player; + } +} + +class _RecordingPlayer implements PlayerPort { + _RecordingPlayer({required this.onDispose}); + + final void Function() onDispose; + bool disposed = false; + + @override + Future initialize() async {} + + @override + Future play() async {} + + @override + Future pause() async {} + + @override + Future dispose() async { + if (disposed) return; + disposed = true; + onDispose(); + } +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index b967a9f..eb58e01 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -11,6 +11,7 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/episode_stats.dart'; import 'package:fruitshorts/core/data/models/interaction.dart'; import 'package:fruitshorts/core/data/models/play_ticket.dart'; import 'package:fruitshorts/core/data/models/player_port.dart'; @@ -2158,11 +2159,15 @@ class _RecordingPlayer implements PlayerPort { class _PendingPlaybackRepository implements PlaybackRepository { final Completer ticket = Completer(); + final Completer stats = Completer(); @override Future ticketFor(String episodeId, {bool forceRefresh = false}) => ticket.future; + @override + Future episodeStats(String episodeId) => stats.future; + @override bool isFresh(String episodeId) => false; } From a3d50eb9357a62651f8fcfde2468ba2b84b31046 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 22:33:14 +0800 Subject: [PATCH 04/29] =?UTF-8?q?feat(interaction):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=B8=B8=E5=AE=A2=E7=82=B9=E8=B5=9E=E6=94=B6=E8=97=8F=E8=AF=84?= =?UTF-8?q?=E8=AE=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/models/interaction.dart | 8 +- .../data/services/comment_controller.dart | 20 +- .../data/services/favorite_controller.dart | 8 +- .../data/services/interaction_controller.dart | 10 +- lib/core/widgets/auth_gate.dart | 3 +- lib/pages/drama_detail/drama_detail_view.dart | 13 +- lib/pages/player/comments_auth.dart | 14 +- lib/pages/player/player_binding.dart | 2 + lib/pages/player/player_logic.dart | 15 +- lib/pages/player/player_view.dart | 12 +- lib/pages/player/player_view_actions.dart | 37 ++-- .../widgets/comment_composer_sheet.dart | 24 +-- .../player/widgets/drama_comments_sheet.dart | 14 +- .../player/widgets/player_unlock_sheet.dart | 3 +- test/comment/drama_comments_sheet_test.dart | 44 ++--- test/player/player_fullscreen_test.dart | 69 +++++-- test/player/player_locked_auth_flow_test.dart | 183 ++++++++++++++++++ test/widget_test.dart | 6 +- 18 files changed, 364 insertions(+), 121 deletions(-) create mode 100644 test/player/player_locked_auth_flow_test.dart diff --git a/lib/core/data/models/interaction.dart b/lib/core/data/models/interaction.dart index b00700f..259ec26 100644 --- a/lib/core/data/models/interaction.dart +++ b/lib/core/data/models/interaction.dart @@ -58,14 +58,16 @@ class LikeToggleResult { /// 控制器统一返回结果;失败消息可直接展示后端 `message`。 class OperationResult { - const OperationResult._(this.data, this.errorMessage); + const OperationResult._(this.data, this.errorMessage, this.errorCode); - const OperationResult.success([T? data]) : this._(data, null); + const OperationResult.success([T? data]) : this._(data, null, null); - const OperationResult.failure(this.errorMessage) : data = null; + const OperationResult.failure(this.errorMessage, {this.errorCode}) + : data = null; final T? data; final String? errorMessage; + final int? errorCode; bool get isSuccess => errorMessage == null; } diff --git a/lib/core/data/services/comment_controller.dart b/lib/core/data/services/comment_controller.dart index 26213cb..8762880 100644 --- a/lib/core/data/services/comment_controller.dart +++ b/lib/core/data/services/comment_controller.dart @@ -197,7 +197,10 @@ class CommentController extends ChangeNotifier { _insertPublished(comment, replyTo); return OperationResult.success(comment); } catch (failure) { - return OperationResult.failure(_errorMessage(failure)); + return OperationResult.failure( + _errorMessage(failure), + errorCode: failure is ApiException ? failure.code : null, + ); } finally { submitting = false; _notify(); @@ -217,7 +220,10 @@ class CommentController extends ChangeNotifier { return const OperationResult.success(); } catch (failure) { _restoreComment(comment, location); - return OperationResult.failure(_errorMessage(failure)); + return OperationResult.failure( + _errorMessage(failure), + errorCode: failure is ApiException ? failure.code : null, + ); } finally { _deletingCommentIds.remove(comment.id); _notify(); @@ -253,7 +259,10 @@ class CommentController extends ChangeNotifier { return OperationResult.success(result); } catch (failure) { _replaceComment(current); - return OperationResult.failure(_errorMessage(failure)); + return OperationResult.failure( + _errorMessage(failure), + errorCode: failure is ApiException ? failure.code : null, + ); } finally { _likingCommentIds.remove(comment.id); _notify(); @@ -277,7 +286,10 @@ class CommentController extends ChangeNotifier { ); return OperationResult.success(reportId); } catch (failure) { - return OperationResult.failure(_errorMessage(failure)); + return OperationResult.failure( + _errorMessage(failure), + errorCode: failure is ApiException ? failure.code : null, + ); } } diff --git a/lib/core/data/services/favorite_controller.dart b/lib/core/data/services/favorite_controller.dart index d57ca21..dcfb9bc 100644 --- a/lib/core/data/services/favorite_controller.dart +++ b/lib/core/data/services/favorite_controller.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import '../models/drama.dart'; +import '../remote/api_client.dart'; import '../repositories/favorite_repository.dart'; /// 收藏状态:**以服务端为准**。 @@ -24,6 +25,7 @@ class FavoriteController extends ChangeNotifier { bool hasMore = false; String? error; String? loadMoreError; + int? lastErrorCode; int _pageIndex = 0; int _requestSeq = 0; bool _disposed = false; @@ -112,11 +114,12 @@ class FavoriteController extends ChangeNotifier { } } - /// 未登录时不要调用(服务端要求登录态)。 + /// 每次切换都以接口状态为准;匿名请求由设备号归属。 /// /// [card] 是当前剧的卡片(播放页/详情页手上就有):收藏成功时直接放进列表, /// 这样"收藏完打开我的收藏"能立刻看到,不必等下一次整表刷新。 Future toggle(String dramaId, {Drama? card}) async { + lastErrorCode = null; final wasSaved = _saved.contains(dramaId); final previousCards = cards; final previousTotal = total; @@ -157,7 +160,7 @@ class FavoriteController extends ChangeNotifier { } notify(); return true; - } catch (_) { + } catch (failure) { // 回滚 cards = previousCards; total = previousTotal; @@ -168,6 +171,7 @@ class FavoriteController extends ChangeNotifier { _saved.remove(dramaId); } notify(); + if (failure is ApiException) lastErrorCode = failure.code; return false; } } diff --git a/lib/core/data/services/interaction_controller.dart b/lib/core/data/services/interaction_controller.dart index a21f247..b0c241b 100644 --- a/lib/core/data/services/interaction_controller.dart +++ b/lib/core/data/services/interaction_controller.dart @@ -115,7 +115,10 @@ class InteractionController extends ChangeNotifier { } else { likedIds.remove(id); } - return OperationResult.failure(_errorMessage(failure)); + return OperationResult.failure( + _errorMessage(failure), + errorCode: failure is ApiException ? failure.code : null, + ); } finally { busyIds.remove(id); _notify(); @@ -139,7 +142,10 @@ class InteractionController extends ChangeNotifier { ); return OperationResult.success(reportId); } catch (failure) { - return OperationResult.failure(_errorMessage(failure)); + return OperationResult.failure( + _errorMessage(failure), + errorCode: failure is ApiException ? failure.code : null, + ); } } diff --git a/lib/core/widgets/auth_gate.dart b/lib/core/widgets/auth_gate.dart index 9c78b84..b238dfc 100644 --- a/lib/core/widgets/auth_gate.dart +++ b/lib/core/widgets/auth_gate.dart @@ -15,7 +15,7 @@ Future openAuthPage(BuildContext context, {bool signUpMode = false}) { /// 受限动作的登录闸:已登录返回 true;未登录弹底部提示并引导登录,返回 false。 /// -/// 首页/发现/详情保持匿名可看,只有收藏、点赞、评论这类"要对到具体账号"的动作才走这里。 +/// 首页/发现/详情与非付费互动保持匿名可用;解锁、权益与消耗类动作走这里。 Future ensureSignedIn(BuildContext context) async { final auth = context.read(); if (auth.isSignedIn) return true; @@ -76,6 +76,7 @@ Future ensureSignedIn(BuildContext context) async { if (go == true && context.mounted) { await openAuthPage(context); + if (context.mounted) return context.read().isSignedIn; } return false; } diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index 78b038e..4520e5b 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -164,7 +164,9 @@ class _DramaDetailPageState extends State { ), ), if (_tagValues(context, drama).isNotEmpty) - SliverToBoxAdapter(child: DramaTags(tags: _tagValues(context, drama))), + SliverToBoxAdapter( + child: DramaTags(tags: _tagValues(context, drama)), + ), SliverToBoxAdapter(child: _episodes(l10n, drama, continueIndex)), const SliverToBoxAdapter(child: SizedBox(height: 24)), ], @@ -396,11 +398,14 @@ class _DramaDetailPageState extends State { } Future _toggleFavorite(AppLocalizations l10n) async { - if (!await ensureSignedIn(context)) return; if (!mounted) return; unawaited(context.read().lightImpact()); final ok = await logic.toggleFavorite(); if (ok || !mounted) return; + if (logic.favorites.lastErrorCode == 200007) { + await ensureSignedIn(context); + return; + } ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed))); @@ -443,7 +448,9 @@ class _DramaDetailPageState extends State { } List _tagValues(BuildContext context, Drama drama) { - final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode); + final localized = drama.localizedTypeLabels( + Localizations.localeOf(context).languageCode, + ); final result = []; if (localized.isNotEmpty) { result.addAll(localized.take(_maxTags)); diff --git a/lib/pages/player/comments_auth.dart b/lib/pages/player/comments_auth.dart index c1178bc..fb92e6e 100644 --- a/lib/pages/player/comments_auth.dart +++ b/lib/pages/player/comments_auth.dart @@ -1,24 +1,14 @@ // Author: fruit.com.cn import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; -import '../../core/data/services/auth_controller.dart'; -import '../../core/widgets/auth_gate.dart'; -/// 评论入口统一登录闸:登录成功且页面仍存在时再打开评论面板。 -Future openCommentsWithAuth( +/// 评论列表与非付费互动均支持设备身份,不再把登录作为入口前置条件。 +Future openComments( BuildContext context, Drama drama, { required Future Function(BuildContext context, Drama drama) show, }) async { - final auth = context.read(); - if (auth.isSignedIn) { - await show(context, drama); - return; - } - await ensureSignedIn(context); - if (!context.mounted || !auth.isSignedIn) return; await show(context, drama); } diff --git a/lib/pages/player/player_binding.dart b/lib/pages/player/player_binding.dart index 21ae3bc..256e822 100644 --- a/lib/pages/player/player_binding.dart +++ b/lib/pages/player/player_binding.dart @@ -15,6 +15,7 @@ class PlayerBinding { required PlaybackPositionCache positionCache, WalletController? wallet, DownloadController? downloads, + bool Function()? isSignedIn, }) => PlayerLogic( history: history, playback: playback, @@ -22,5 +23,6 @@ class PlayerBinding { pictureInPicture: AndroidPictureInPicture(), wallet: wallet, downloads: downloads, + isSignedIn: isSignedIn, ); } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 4bb67af..2505d26 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -53,10 +53,12 @@ class PlayerLogic extends ChangeNotifier { PlaybackPositionCache? positionCache, Future Function()? waitForFrame, DateTime Function()? clock, + bool Function()? isSignedIn, }) : positionCache = positionCache ?? PlaybackPositionCache(), _waitForFrame = waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame), - _clock = clock ?? DateTime.now; + _clock = clock ?? DateTime.now, + _isSignedIn = isSignedIn ?? (() => true); final WatchHistoryRepository history; final PlaybackRepository playback; @@ -66,6 +68,7 @@ class PlayerLogic extends ChangeNotifier { final PlaybackPositionCache positionCache; final Future Function() _waitForFrame; final DateTime Function() _clock; + final bool Function() _isSignedIn; late PageController pages; late PlaybackQueue queue; @@ -797,7 +800,10 @@ class PlayerLogic extends ChangeNotifier { /// 金币解锁当前集;成功后强制刷新票据并直接起播。 Future unlockEpisode(int index) async { final service = wallet; - if (service == null || index < 0 || index >= sessionEpisodes.length) { + if (service == null || + !_isSignedIn() || + index < 0 || + index >= sessionEpisodes.length) { return false; } final episode = sessionEpisodes[index]; @@ -1232,6 +1238,7 @@ class PlayerLogic extends ChangeNotifier { int? revision, bool? userWantsPlay, }) async { + if (index < 0 || index >= sessionEpisodes.length) return; final feedRevisionAtStart = revision ?? feedRevision; final request = ++pageRequest; cancelScrub(); @@ -1257,6 +1264,10 @@ class PlayerLogic extends ChangeNotifier { !isMounted()) { return; } + if (!_isSignedIn()) { + unawaited(refreshEpisodeStats(index)); + return; + } } // 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试) final ready = await ensureTicket(index); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 0e9c110..74198bf 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -12,6 +12,7 @@ import '../../core/data/models/drama.dart'; import '../../core/data/models/episode_stats.dart'; import '../../core/data/repositories/comment_repository.dart'; import '../../core/data/services/catalog_controller.dart'; +import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/download_controller.dart'; import '../../core/data/services/interaction_controller.dart'; @@ -168,6 +169,7 @@ class _PlayerPageState extends State positionCache: context.read(), wallet: widget.wallet, downloads: widget.downloads, + isSignedIn: () => context.read().isSignedIn, ); logic.onEpisodeStatsUpdated = _applyEpisodeStats; logic.attach(widget, isMounted: () => mounted); @@ -590,7 +592,7 @@ class _PlayerPageState extends State Episode episode, ) async { final comments = context.read(); - await openCommentsWithAuth( + await openComments( context, drama, show: (context, drama) => @@ -617,6 +619,13 @@ class _PlayerPageState extends State Widget _buildPlayer(BuildContext context) { final interactions = context.watch(); final l10n = AppLocalizations.of(context)!; + final indices = logic.visibleIndices; + if (indices.isEmpty) { + return const ColoredBox( + color: AppColors.mediaBlack, + child: SizedBox.expand(), + ); + } if (kDebugMode && !_loggedStartConditions) { _loggedStartConditions = true; debugPrint( @@ -630,7 +639,6 @@ class _PlayerPageState extends State 'mayPlay=${logic.session.debugMayPlay}', ); } - final indices = logic.visibleIndices; final activePort = logic.session.active; final activeVideo = activePort is VideoPlayerPort ? activePort : null; final canWaitForVideoFrame = diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 5a19507..2efb5fb 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -65,6 +65,11 @@ extension _PlayerViewActions on _PlayerPageState { ) async { final wallet = logic.wallet; if (wallet == null) return; + if (!context.read().isSignedIn && + !await ensureSignedIn(context)) { + return; + } + if (!context.mounted) return; await showModalBottomSheet( context: context, backgroundColor: AppColors.transparent, @@ -81,7 +86,7 @@ extension _PlayerViewActions on _PlayerPageState { } Future _unlockEpisode(BuildContext context, int index) async { - if (!await ensureSignedIn(context) || !context.mounted) return false; + if (!context.read().isSignedIn) return false; final ok = await logic.unlockEpisode(index); if (ok || !context.mounted) return ok; final l10n = AppLocalizations.of(context)!; @@ -119,27 +124,6 @@ extension _PlayerViewActions on _PlayerPageState { } return; } - final signedIn = await ensureSignedIn(context); - if (kDebugMode) { - debugPrint( - '[player-like] phase=auth-result ' - 'episodeId=${episode.id} signedIn=$signedIn ' - 'mounted=${context.mounted}', - ); - } - if (!signedIn || !context.mounted) return; - final busyAfterAuth = interactions.isEpisodeBusy(episode.id); - final likedAfterAuth = interactions.isEpisodeLiked(episode.id); - if (busyAfterAuth || (likeOnly && likedAfterAuth)) { - if (kDebugMode) { - debugPrint( - '[player-like] phase=toggle-skipped-after-auth ' - 'episodeId=${episode.id} ' - 'reason=${busyAfterAuth ? 'busy' : 'already-liked'}', - ); - } - return; - } final result = await interactions.toggleEpisodeLike(episode.id); if (kDebugMode) { debugPrint( @@ -163,6 +147,10 @@ extension _PlayerViewActions on _PlayerPageState { return; } if (!context.mounted) return; + if (result.errorCode == 200007) { + await ensureSignedIn(context); + return; + } showCenteredToast( context, result.errorMessage ?? AppLocalizations.of(context)!.likeFailed, @@ -171,7 +159,6 @@ extension _PlayerViewActions on _PlayerPageState { Future _toggleSave(BuildContext context, Drama drama) async { final favorites = context.read(); - if (!await ensureSignedIn(context) || !context.mounted) return; unawaited(context.read().lightImpact()); final wasSaved = favorites.isSaved(drama.id); final ok = await favorites.toggle(drama.id, card: drama); @@ -183,6 +170,10 @@ extension _PlayerViewActions on _PlayerPageState { } return; } + if (favorites.lastErrorCode == 200007) { + await ensureSignedIn(context); + return; + } ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.favoriteToggleFailed), diff --git a/lib/pages/player/widgets/comment_composer_sheet.dart b/lib/pages/player/widgets/comment_composer_sheet.dart index 9f9ce82..d0a4085 100644 --- a/lib/pages/player/widgets/comment_composer_sheet.dart +++ b/lib/pages/player/widgets/comment_composer_sheet.dart @@ -9,7 +9,6 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../../core/data/models/comment.dart'; -import '../../../core/data/services/auth_controller.dart'; import '../../../core/data/services/comment_controller.dart'; import '../../../core/platform/haptics.dart'; import '../../../core/platform/keyboard_inset.dart'; @@ -196,23 +195,20 @@ class _CommentComposerSheetState extends State { return; } final l10n = AppLocalizations.of(context)!; + if (result.errorCode == 200007) { + await ensureSignedIn(context); + return; + } ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(result.errorMessage ?? l10n.commentPostFailed)), ); } - Future _authenticate() async { - await ensureSignedIn(context); - if (!mounted || !context.read().isSignedIn) return; - _inputFocus.requestFocus(); - } - @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final signedIn = context.watch().isSignedIn; final hasContent = _input.text.trim().isNotEmpty; - final canPost = signedIn && hasContent && !widget.controller.submitting; + final canPost = hasContent && !widget.controller.submitting; final multiline = _input.text.contains('\n'); final bottomInset = _isAndroid ? _pinnedKeyboardInset @@ -247,8 +243,8 @@ class _CommentComposerSheetState extends State { key: const Key('comment-composer-input'), controller: _input, focusNode: _inputFocus, - readOnly: !signedIn, - canRequestFocus: signedIn, + readOnly: false, + canRequestFocus: true, minLines: 1, maxLines: 4, maxLength: _maxCommentLength, @@ -262,9 +258,7 @@ class _CommentComposerSheetState extends State { ), cursorColor: AppColors.blue, decoration: InputDecoration( - hintText: signedIn - ? l10n.writeComment - : l10n.commentLoginHint, + hintText: l10n.writeComment, hintStyle: const TextStyle( color: AppColors.muted, fontSize: 14, @@ -278,7 +272,7 @@ class _CommentComposerSheetState extends State { enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, ), - onTap: signedIn ? null : _authenticate, + onTap: null, ), ), const SizedBox(height: 12), diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index 90d0b89..4e7204e 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -11,11 +11,9 @@ import '../../../core/data/models/comment.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/data/repositories/comment_repository.dart'; import '../../../core/data/repositories/interaction_repository.dart'; -import '../../../core/data/services/auth_controller.dart'; import '../../../core/data/services/comment_controller.dart'; import '../../../core/formatting/error_text.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/widgets/auth_gate.dart'; import '../../../core/widgets/empty_state.dart'; import '../../../l10n/app_localizations.dart'; import 'comment_composer_sheet.dart'; @@ -29,7 +27,7 @@ const double _sheetRadius = 20; const double _headerHeight = 44; const double _composerTopGap = 8; -/// 服务端评论底部弹窗:匿名列表、游标翻页、回复与登录发布。 +/// 服务端评论底部弹窗:匿名列表、游标翻页、回复与设备身份发布。 class DramaCommentsSheet extends StatefulWidget { const DramaCommentsSheet({super.key, required this.drama, this.episodeId}); @@ -92,8 +90,7 @@ class _DramaCommentsSheetState extends State { } Future _openComposer({DramaComment? replyTo}) async { - await ensureSignedIn(context); - if (!mounted || !context.read().isSignedIn) return; + if (!mounted) return; _removeComposerOverlay(); final entry = OverlayEntry( builder: (_) => CommentComposerSheet( @@ -222,7 +219,6 @@ class _DramaCommentsSheetState extends State { Widget _content(BuildContext context) { final l10n = AppLocalizations.of(context)!; final count = _serverCount ?? _controller.exactCount; - final signedIn = context.watch().isSignedIn; final composerReserve = _composerHeight + _composerTopGap; return Column( key: const Key('comment-sheet-content'), @@ -269,7 +265,7 @@ class _DramaCommentsSheetState extends State { Padding( key: const Key('comment-composer-area'), padding: const EdgeInsets.only(top: _composerTopGap), - child: _fakeComposer(l10n, signedIn: signedIn), + child: _fakeComposer(l10n), ), ], ); @@ -352,7 +348,7 @@ class _DramaCommentsSheetState extends State { return const SizedBox(height: 8); } - Widget _fakeComposer(AppLocalizations l10n, {required bool signedIn}) { + Widget _fakeComposer(AppLocalizations l10n) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: _openComposer, @@ -372,7 +368,7 @@ class _DramaCommentsSheetState extends State { child: Padding( padding: const EdgeInsets.only(left: 16), child: Text( - signedIn ? l10n.writeComment : l10n.commentLoginHint, + l10n.writeComment, key: const Key('comment-input-placeholder-label'), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/pages/player/widgets/player_unlock_sheet.dart b/lib/pages/player/widgets/player_unlock_sheet.dart index a47579e..8b2a9c1 100644 --- a/lib/pages/player/widgets/player_unlock_sheet.dart +++ b/lib/pages/player/widgets/player_unlock_sheet.dart @@ -49,7 +49,8 @@ class PlayerBlockedNotice extends StatelessWidget { bool get _needsUnlock => accessType == PlayTicket.coinRequired || - accessType == PlayTicket.vipRequired; + accessType == PlayTicket.vipRequired || + (accessType == null && episode.locked); @override Widget build(BuildContext context) { diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 8de463c..d410df6 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -135,7 +135,9 @@ void main() { debugDefaultTargetPlatformOverride = null; }); - testWidgets('should_gate_guest_comment_input_without_focus', (tester) async { + testWidgets('should_open_guest_comment_composer_without_login', ( + tester, + ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); final repository = FakeCommentRepository(pages: [const CommentPage()]); @@ -144,34 +146,20 @@ void main() { final placeholder = find.byKey(const Key('comment-input-placeholder')); expect(placeholder, findsOneWidget); - expect(find.text('Sign in to comment'), findsOneWidget); await tester.tap(placeholder); await tester.pumpAndSettle(); - expect(find.text('Sign in to continue'), findsOneWidget); - await tester.tap(find.text('Not now')); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('comment-composer-sheet')), findsNothing); - expect(placeholder, findsOneWidget); + expect(find.byKey(const Key('comment-composer-sheet')), findsOneWidget); + expect(find.byKey(const Key('auth-landing')), findsNothing); }); - testWidgets('should_focus_comment_input_after_successful_login', ( - tester, - ) async { + testWidgets('should_focus_guest_comment_input_immediately', (tester) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); final repository = FakeCommentRepository(pages: [const CommentPage()]); - final auth = await _pumpSheet(tester, repository: repository); + await _pumpSheet(tester, repository: repository); await tester.tap(find.byKey(const Key('comment-input-placeholder'))); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 300)); - await tester.tap(find.text('Sign In')); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 300)); - (auth as _TestAuthController).setSignedIn(true); - await tester.pump(); - Navigator.of(tester.element(find.byKey(const Key('auth-landing')))).pop(); await tester.pumpAndSettle(); expect( @@ -218,9 +206,7 @@ void main() { expect(multilineField.style?.height, 1.4); }); - testWidgets('should_gate_guest_post_without_creating_comment', ( - tester, - ) async { + testWidgets('should_publish_guest_comment_without_login', (tester) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); final repository = FakeCommentRepository(pages: [const CommentPage()]); @@ -229,8 +215,18 @@ void main() { await tester.tap(find.byKey(const Key('comment-post'))); await tester.pumpAndSettle(); - expect(find.text('Sign in to continue'), findsOneWidget); - expect(repository.publishCalls, isEmpty); + await tester.enterText( + find.byKey(const Key('comment-composer-input')), + 'Guest comment', + ); + await tester.pump(); + await tester.tap(find.byKey(const Key('comment-composer-post'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('auth-landing')), findsNothing); + expect(repository.publishCalls, hasLength(1)); + expect(repository.publishCalls.single.content, 'Guest comment'); + expect(find.text('Guest comment'), findsOneWidget); }); testWidgets('should_show_server_comments_and_append_next_cursor_page', ( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index eb58e01..9cf0f33 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -596,9 +596,7 @@ void main() { expect(find.byType(Slider), findsOneWidget); }); - testWidgets('should_pause_feed_when_auth_page_is_pushed_and_resume_on_pop', ( - tester, - ) async { + testWidgets('should_keep_feed_playing_when_guest_saves', (tester) async { final players = <_RecordingPlayer>[]; await _pumpApp( tester, @@ -615,18 +613,23 @@ void main() { await tester.tap(find.byKey(const Key('rail-save'))); await tester.pumpAndSettle(); - await tester.tap(find.text('Sign In')); - await tester.pumpAndSettle(); - - expect(find.byType(AuthPage), findsOneWidget); - expect(player.playing, isFalse); - - await tester.pageBack(); - await tester.pumpAndSettle(); - + expect(find.byType(AuthPage), findsNothing); expect(player.playing, isTrue); }); + testWidgets('should_like_without_login_from_recommend_feed', (tester) async { + final interactions = FakeInteractionRepository(); + await _pumpApp(tester, interactions: interactions); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('rail-like'))); + await tester.pumpAndSettle(); + + expect(find.byType(AuthPage), findsNothing); + expect(interactions.likeTargets, ['EPISODE:the-signal-demo-1']); + }); + testWidgets('should_keep_video_surface_background_black', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); @@ -643,7 +646,7 @@ void main() { expect(find.byKey(const Key('player-fallback-scrim')), findsNothing); }); - testWidgets('should_gate_comments_before_opening_sheet_when_signed_out', ( + testWidgets('should_open_comments_without_login', ( tester, ) async { await _pumpApp(tester); @@ -652,8 +655,8 @@ void main() { await tester.tap(find.byKey(const Key('player-rail-comments'))); await tester.pumpAndSettle(); - expect(find.text('Sign in to continue'), findsOneWidget); - expect(find.byKey(const Key('player-comments-sheet')), findsNothing); + expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget); + expect(find.byType(AuthPage), findsNothing); }); testWidgets('should_open_feed_comments_when_signed_in', (tester) async { @@ -1872,6 +1875,12 @@ void main() { testWidgets('should_open_unlock_panel_with_honest_method_states', ( tester, ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'access', + 'auth.refreshToken': 'refresh', + 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', + }); final interactions = FakeInteractionRepository(); await _pumpApp( tester, @@ -1905,6 +1914,36 @@ void main() { find.text('Available unlock methods vary by title.'), findsOneWidget, ); + + }); + + testWidgets('should_require_login_before_opening_unlock_flow', ( + tester, + ) async { + await _pumpApp( + tester, + playback: FakePlaybackRepository( + ticket: const PlayTicket( + episodeId: 'city-lights-demo-1', + accessible: false, + accessType: PlayTicket.coinRequired, + unlockCoin: 30, + ), + ), + ); + await _openSeriesPlayer(tester); + + await tester.tap(find.byKey(const Key('player-open-unlock'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byKey(const Key('player-unlock-panel')), findsNothing); + + final signIn = find.widgetWithText(FilledButton, 'Sign In'); + await tester.ensureVisible(signIn); + await tester.tap(signIn); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byType(AuthPage), findsOneWidget); }); } diff --git a/test/player/player_locked_auth_flow_test.dart b/test/player/player_locked_auth_flow_test.dart new file mode 100644 index 0000000..c3e99ac --- /dev/null +++ b/test/player/player_locked_auth_flow_test.dart @@ -0,0 +1,183 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/play_ticket.dart'; +import 'package:fruitshorts/core/data/models/player_port.dart'; +import 'package:fruitshorts/core/data/models/wallet.dart'; +import 'package:fruitshorts/core/data/repositories/commerce_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; +import 'package:fruitshorts/core/data/services/playback_session.dart'; +import 'package:fruitshorts/core/data/services/wallet_controller.dart'; +import 'package:fruitshorts/pages/player/player_logic.dart'; +import 'package:fruitshorts/pages/player/player_view.dart'; + +import '../support/fake_watch_history.dart'; + +const _lockedDrama = Drama( + id: 'locked', + title: 'Locked', + synopsis: 'synopsis', + posterUrl: '/files/locked.png', + episodes: [ + Episode( + id: 'locked-1', + number: 1, + title: 'Locked episode', + isFree: false, + locked: true, + ), + ], +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('should_not_request_ticket_for_guest_on_locked_episode', () async { + final playback = FakePlaybackRepository(); + final logic = _logic(playback, signedIn: false); + logic.attach( + const PlayerPage(drama: _lockedDrama, initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: (_) => _NoopPlayer(), + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + + expect(playback.requests, isEmpty); + expect(logic.session.isLocked, isTrue); + + logic.dispose(); + await _drain(logic); + }); + + test('should_request_ticket_after_user_is_signed_in', () async { + final playback = FakePlaybackRepository( + ticket: const PlayTicket( + episodeId: 'locked-1', + accessible: false, + accessType: PlayTicket.coinRequired, + unlockCoin: 30, + ), + ); + final logic = _logic(playback, signedIn: true); + logic.attach( + const PlayerPage(drama: _lockedDrama, initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: (_) => _NoopPlayer(), + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + + expect(playback.requests, ['locked-1']); + expect(logic.blockedEpisodeId, 'locked-1'); + + logic.dispose(); + await _drain(logic); + }); + + test('should_resume_unlock_and_play_for_same_episode_after_login', () async { + var signedIn = false; + final playback = FakePlaybackRepository( + ticket: const PlayTicket( + episodeId: 'locked-1', + accessible: true, + accessType: 'ENTITLED', + playUrl: '/files/locked-1.mp4', + ), + ); + final logic = _logic( + playback, + signedIn: false, + isSignedIn: () => signedIn, + wallet: WalletController(_UnlockCommerceRepository()), + ); + logic.attach( + const PlayerPage(drama: _lockedDrama, initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: (_) => _NoopPlayer(), + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + + expect(playback.requests, isEmpty); + expect(logic.blockedEpisodeId, 'locked-1'); + + signedIn = true; + final unlocked = await logic.unlockEpisode(0); + await _drain(logic); + + expect(unlocked, isTrue); + expect(playback.requests, ['locked-1']); + expect(logic.session.active, isNotNull); + + logic.dispose(); + await _drain(logic); + }); +} + +PlayerLogic _logic( + PlaybackRepository playback, { + bool signedIn = true, + bool Function()? isSignedIn, + WalletController? wallet, +}) { + return PlayerLogic( + history: FakeWatchHistory(), + playback: playback, + wallet: wallet, + pictureInPicture: AndroidPictureInPicture( + channel: const MethodChannel('test/player_locked_auth_flow/pip'), + ), + waitForFrame: () async {}, + isSignedIn: isSignedIn ?? () => signedIn, + ); +} + +Future _drain(PlayerLogic logic) async { + for (var attempt = 0; attempt < 4; attempt++) { + await pumpEventQueue(); + await logic.session.playbackSettled; + await logic.session.preloadSettled; + } +} + +class _NoopPlayer implements PlayerPort { + @override + Future initialize() async {} + + @override + Future play() async {} + + @override + Future pause() async {} + + @override + Future dispose() async {} +} + +class _UnlockCommerceRepository implements CommerceRepository { + @override + Future fetchWallet() async => const WalletSnapshot( + wallet: CoinWallet( + coinBalance: 100, + totalRechargedCoin: 0, + totalConsumedCoin: 0, + totalRefundedCoin: 0, + ), + ); + + @override + Future unlock(String episodeId) async => const UnlockResult( + unlocked: true, + accessType: 'ENTITLED', + coinCost: 30, + coinBalance: 70, + ); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index b8c4d3d..ef71478 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -266,7 +266,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('should_open_auth_gate_without_liking_when_signed_out', ( + testWidgets('should_like_without_login_when_signed_out', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = @@ -279,8 +279,8 @@ void main() { await tester.tap(find.byKey(const Key('player-rail-like'))); await tester.pumpAndSettle(); - expect(find.text('Sign in to continue'), findsOneWidget); - expect(interactions.likeTargets, isEmpty); + expect(find.text('Sign in to continue'), findsNothing); + expect(interactions.likeTargets, ['EPISODE:city-lights-demo-1']); }); testWidgets('should_show_saved_player_drama_in_favorites', (tester) async { From c6b3ca93110f5a9566ea24cb1b3b7f28b35d33f3 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 22:49:04 +0800 Subject: [PATCH 05/29] =?UTF-8?q?fix(auth):=20=E7=A6=81=E6=AD=A2=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E6=8E=A5=E5=8F=A3=E9=80=92=E5=BD=92=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/remote/auth_api.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/core/data/remote/auth_api.dart b/lib/core/data/remote/auth_api.dart index a6841d3..b452aec 100644 --- a/lib/core/data/remote/auth_api.dart +++ b/lib/core/data/remote/auth_api.dart @@ -134,6 +134,7 @@ class AuthApi { 'platform': platform, 'osVersion': osVersion, }, + skipAuthRetry: true, ); return AuthSession.fromJson((data as Map).cast()); } From bf47c065c98e705078d620843ee9c4ed23bd7508 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 22:49:07 +0800 Subject: [PATCH 06/29] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8D=E7=BB=A7?= =?UTF-8?q?=E7=BB=AD=E8=A7=82=E7=9C=8B=E5=8D=A1=E7=89=87=E7=BA=B5=E5=90=91?= =?UTF-8?q?=E6=BA=A2=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_dimens.dart | 2 +- test/mine/mine_preview_layout_test.dart | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index afe1645..9f42692 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -229,7 +229,7 @@ abstract final class AppDimens { static const double mineSectionLinkFont = 12; static const double mineSectionGap = 20; static const double mineContinueCardWidth = 96; - static const double mineContinueRowHeight = 168; + static const double mineContinueRowHeight = 172; static const double mineContinueTitleFont = 13; static const double mineContinueMetaFont = 11; static const double mineStatsValueFont = 18; diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index 4172c1f..d746bfe 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -443,7 +443,10 @@ Future _pumpApp( catalogRepository: catalog, categoryRepository: FakeCategoryRepository(catalog: catalog), watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), - favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + favoriteRepository: InMemoryFavoriteRepository( + saved: catalog.dramas.map((drama) => drama.id).toSet(), + cards: catalog.dramas, + ), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), From d4ce3a5d8bff2de2ebf4c4eb783446a83a9d97b0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 22:49:10 +0800 Subject: [PATCH 07/29] =?UTF-8?q?fix(home):=20=E4=BF=AE=E5=A4=8D=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E9=94=99=E8=AF=AF=E6=80=81=E8=A2=AB=E5=8D=A0=E4=BD=8D?= =?UTF-8?q?=E5=B1=82=E9=81=AE=E6=8C=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/home/widgets/home_feed_grid.dart | 5 +++ test/home/home_startup_splash_test.dart | 43 ++++++++++++++++++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/lib/pages/home/widgets/home_feed_grid.dart b/lib/pages/home/widgets/home_feed_grid.dart index d35b8ed..3afbc7f 100644 --- a/lib/pages/home/widgets/home_feed_grid.dart +++ b/lib/pages/home/widgets/home_feed_grid.dart @@ -104,6 +104,7 @@ class _HomeFeedGridState extends State { return _categoryPlaceholder( l10n, context.watch(), + catalog, ); } final category = shortcuts[index]; @@ -146,7 +147,11 @@ class _HomeFeedGridState extends State { Widget _categoryPlaceholder( AppLocalizations l10n, CategoryController controller, + CatalogController catalog, ) { + if (catalog.error != null) { + return _catalogErrorState(l10n, catalog); + } if (controller.loading) { return const DramaGridSkeleton( key: Key('home-grid-skeleton'), diff --git a/test/home/home_startup_splash_test.dart b/test/home/home_startup_splash_test.dart index 1119423..caa5296 100644 --- a/test/home/home_startup_splash_test.dart +++ b/test/home/home_startup_splash_test.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/config/app_config.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; @@ -13,6 +14,8 @@ import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/services/catalog_controller.dart'; +import 'package:fruitshorts/core/data/services/category_controller.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; @@ -51,7 +54,11 @@ void main() { expect(find.byType(CircularProgressIndicator), findsNothing); repository.complete(_feed); - await tester.pumpAndSettle(); + await _pumpForAppData(tester); + await _pumpUntilFound( + tester, + find.byKey(const Key('home-feed-card-the-signal-demo')), + ); expect(find.byKey(const Key('startup-splash')), findsNothing); expect(find.byKey(const Key('home-grid-skeleton')), findsNothing); @@ -62,7 +69,8 @@ void main() { tester, ) async { await _pumpApp(tester, _FailingCatalogRepository()); - await tester.pumpAndSettle(); + await _pumpForAppData(tester); + await _pumpUntilFound(tester, find.byKey(const Key('home-grid-error'))); expect(find.byKey(const Key('home-grid-header')), findsOneWidget); expect(find.byKey(const Key('home-grid-error')), findsOneWidget); @@ -72,7 +80,8 @@ void main() { testWidgets('should_show_catalog_empty_state_inside_grid', (tester) async { await _pumpApp(tester, _EmptyCatalogRepository()); - await tester.pumpAndSettle(); + await _pumpForAppData(tester); + await _pumpUntilFound(tester, find.byKey(const Key('home-grid-empty'))); expect(find.byKey(const Key('home-grid-header')), findsOneWidget); expect(find.byKey(const Key('home-grid-empty')), findsOneWidget); @@ -99,6 +108,34 @@ Future _pumpApp(WidgetTester tester, CatalogRepository repository) async { await tester.pump(); } +Future _pumpUntilFound(WidgetTester tester, Finder finder) async { + for (var frame = 0; frame < 40; frame++) { + if (finder.evaluate().isNotEmpty) return; + await tester.pump(const Duration(milliseconds: 16)); + } + await tester.pump(); +} + +Future _pumpForAppData(WidgetTester tester) async { + await tester.runAsync( + () => Future.delayed(Duration.zero), + ); + for (var frame = 0; frame < 40; frame++) { + final root = find.byKey(const Key('home-tabs')); + if (root.evaluate().isNotEmpty) { + final context = tester.element(root); + final catalog = context.read(); + final categories = context.read(); + if (catalog.settled && !catalog.loading && !categories.loading) { + await tester.pump(); + return; + } + } + await tester.pump(const Duration(milliseconds: 16)); + } + await tester.pump(); +} + final _feed = HomeFeed( sections: [ FeedSection(code: 'hot', title: 'Hot', dramas: [demoDramas.first]), From 391b01e9d0b68fafa21ed2352a09443aff859d80 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 22:49:13 +0800 Subject: [PATCH 08/29] =?UTF-8?q?fix(theater):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=96=B0=E5=89=A7=E5=8D=A1=E7=89=87=E4=B8=8E=E6=A6=9C=E5=8D=95?= =?UTF-8?q?=E6=BA=A2=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/new_releases/new_releases_page.dart | 2 +- lib/pages/ranking/ranking_page.dart | 12 +++++++----- test/discover/theater_pages_test.dart | 10 +++++++--- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/lib/pages/new_releases/new_releases_page.dart b/lib/pages/new_releases/new_releases_page.dart index 6f2aeb3..37aa204 100644 --- a/lib/pages/new_releases/new_releases_page.dart +++ b/lib/pages/new_releases/new_releases_page.dart @@ -432,7 +432,7 @@ class _NewReleasesPageState extends State { ), ), ), - const SizedBox(height: 6), + const SizedBox(height: 4), Text( drama.title, maxLines: 1, diff --git a/lib/pages/ranking/ranking_page.dart b/lib/pages/ranking/ranking_page.dart index 07f745e..cae6301 100644 --- a/lib/pages/ranking/ranking_page.dart +++ b/lib/pages/ranking/ranking_page.dart @@ -509,11 +509,13 @@ Widget _heat(int count, AppLocalizations l10n) { size: 11, ), const SizedBox(width: 4), - Text( - l10n.heatLabel(formatPlayCount(count)), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: AppColors.textTertiary, fontSize: 11), + Flexible( + child: Text( + l10n.heatLabel(formatPlayCount(count)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.textTertiary, fontSize: 11), + ), ), ], ); diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index 5bac72d..e01f5f3 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -7,6 +7,7 @@ import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; +import 'package:fruitshorts/pages/discover/discover_view.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; @@ -119,7 +120,7 @@ void main() { expect(top2.width, greaterThan(100)); expect(top2.left, lessThan(top1.left)); expect(top3.left, greaterThan(top1.right)); - expect(top1.bottom, closeTo(top2.bottom - 12, 0.5)); + expect(top1.bottom, closeTo(top2.bottom - 30, 0.5)); expect(top2.bottom, closeTo(top3.bottom, 0.5)); expect(find.byKey(const Key('ranking-row-3')), findsNothing); }); @@ -219,7 +220,7 @@ void main() { expect(position.pixels, 0); position.jumpTo(position.maxScrollExtent); await tester.pump(); - expect(find.byKey(const Key('ranking-load-more')), findsNothing); + expect(find.byKey(const Key('ranking-load-more')), findsOneWidget); expect(find.byKey(const Key('ranking-no-more')), findsNothing); await tester.pumpAndSettle(); }); @@ -311,7 +312,10 @@ Future _pumpTheater( ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-search-entry'))); + Navigator.of(tester.element(find.byKey(const Key('home-search-entry')))) + .push( + MaterialPageRoute(builder: (_) => const DiscoverPage()), + ); await tester.pumpAndSettle(); } From 2d65cd3006f261b717ff10ff8d588649927678f8 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 22:49:21 +0800 Subject: [PATCH 09/29] =?UTF-8?q?test(auth):=20=E6=9B=B4=E6=96=B0=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=A4=B1=E8=B4=A5=E6=8F=90=E7=A4=BA=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/auth/auth_page_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/auth/auth_page_test.dart b/test/auth/auth_page_test.dart index f297ba0..050290e 100644 --- a/test/auth/auth_page_test.dart +++ b/test/auth/auth_page_test.dart @@ -112,7 +112,7 @@ void main() { final toast = find.byKey(const Key('centered-toast')); expect(toast, findsOneWidget); - expect(find.text('Invalid email or password'), findsOneWidget); + expect(find.text('Sign in to continue'), findsOneWidget); expect(find.byType(AlertDialog), findsNothing); expect(api.loginCalls, 1); expect( From 65128085060d8a4b33defe617f45914ad75b0d37 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 22:49:23 +0800 Subject: [PATCH 10/29] =?UTF-8?q?test(ui):=20=E6=9B=B4=E6=96=B0=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E5=AF=BC=E8=88=AA=E4=B8=8E=E5=B8=83=E5=B1=80=E6=96=AD?= =?UTF-8?q?=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/auth/auth_visual_test.dart | 5 ++-- test/home/app_bottom_nav_test.dart | 2 +- test/home/home_card_style_test.dart | 12 ++++++-- test/home/home_nav_ui_test.dart | 22 ++++---------- test/home/home_preview_layout_test.dart | 40 +++++++++++-------------- test/home/k28_a_navigation_test.dart | 18 +++++++++-- 6 files changed, 52 insertions(+), 47 deletions(-) diff --git a/test/auth/auth_visual_test.dart b/test/auth/auth_visual_test.dart index a47e2b9..9257f63 100644 --- a/test/auth/auth_visual_test.dart +++ b/test/auth/auth_visual_test.dart @@ -14,7 +14,6 @@ import 'package:fruitshorts/core/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/platform/haptics.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_dimens.dart'; -import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; @@ -136,7 +135,7 @@ void main() { cardDecoration.borderRadius, BorderRadius.circular(AppDimens.authCardRadius), ); - expect(cardDecoration.boxShadow, contains(AppTheme.cardShadow)); + expect(cardDecoration.boxShadow, isNull); expect(card.padding, const EdgeInsets.all(20)); final email = tester.widget( @@ -222,7 +221,7 @@ void main() { find.descendant(of: back, matching: find.byType(Material)), ) .color, - AppColors.card, + AppColors.transparent, ); }); diff --git a/test/home/app_bottom_nav_test.dart b/test/home/app_bottom_nav_test.dart index c457535..c8c8a81 100644 --- a/test/home/app_bottom_nav_test.dart +++ b/test/home/app_bottom_nav_test.dart @@ -10,7 +10,7 @@ void main() { test('should_use_exact_root_navigation_colors', () { expect(AppColors.navBackground, const Color(0xFF000000)); expect(AppColors.navSelected, const Color(0xFFFFFFFF)); - expect(AppColors.navUnselected, const Color(0xFF8E8E93)); + expect(AppColors.navUnselected, const Color(0xFFB0B0B0)); }); testWidgets('should_render_four_slots_with_white_selected_state', ( diff --git a/test/home/home_card_style_test.dart b/test/home/home_card_style_test.dart index de11cf0..591a5d1 100644 --- a/test/home/home_card_style_test.dart +++ b/test/home/home_card_style_test.dart @@ -82,7 +82,13 @@ void main() { expect(viewsText.style?.fontSize, AppDimens.cardPlayCountFont); expect(viewsText.style?.fontWeight, FontWeight.w400); expect(viewsText.style?.color, AppColors.navSelected); - expect(find.descendant(of: cover, matching: views), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const Key('home-feed-cover-clip-anime-card')), + matching: views, + ), + findsOneWidget, + ); expect( tester.getTopRight(cover).dx - tester.getTopRight(views).dx, AppDimens.cardPlayCountRight, @@ -201,7 +207,9 @@ Future _pumpCard( body: Center( child: SizedBox( width: 130, - height: 240, + height: + 130 / AppDimens.cardCoverAspectRatio + + AppDimens.homeCardBodyExtent, child: HomeFeedCard( drama: drama, onTap: onTap, diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 354fc46..85cf92d 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -98,22 +98,12 @@ void main() { tester.getTopRight(walletEntry).dx, closeTo(390 - AppDimens.feedPageHorizontal, 0.1), ); - final crown = tester.widget( - find.descendant( - of: vipEntry, - matching: find.byIcon(Icons.workspace_premium_outlined), - ), - ); - final gift = tester.widget( - find.descendant( - of: walletEntry, - matching: find.byIcon(Icons.card_giftcard_rounded), - ), - ); - expect(crown.size, AppDimens.homeActionIcon); - expect(crown.color, AppColors.homeCrownGold); - expect(gift.size, AppDimens.homeActionIcon); - expect(gift.color, AppColors.homeGiftPink); + expect(crownImage.width, AppDimens.homeActionIcon); + expect(crownImage.height, AppDimens.homeActionIcon); + expect(crownImage.fit, BoxFit.contain); + expect(giftImage.width, AppDimens.homeActionIcon); + expect(giftImage.height, AppDimens.homeActionIcon); + expect(giftImage.fit, BoxFit.contain); expect( tester .widget( diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 029414b..ef1c775 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -305,7 +305,7 @@ void main() { find.byKey(const Key('home-feed-episode-progress')), ); expect(episodeProgress.maxLines, 1); - expect(episodeProgress.softWrap, isFalse); + expect(episodeProgress.overflow, TextOverflow.ellipsis); expect(episodeProgress.style?.fontSize, 13); expect(episodeProgress.style?.color, AppColors.overlayLabel); expect( @@ -646,13 +646,14 @@ void main() { .ignoring, isTrue, ); - expect(find.byKey(const Key('scrub-preview-time-bubble')), findsOneWidget); - final bubbleRect = tester.getRect( - find.byKey(const Key('scrub-preview-time-bubble')), + expect(find.byKey(const Key('scrub-preview-card')), findsOneWidget); + expect(find.byKey(const Key('scrub-preview-time')), findsOneWidget); + final previewRect = tester.getRect( + find.byKey(const Key('scrub-preview-card')), ); - expect(bubbleRect.left, greaterThanOrEqualTo(12)); + expect(previewRect.left, greaterThanOrEqualTo(12)); expect( - bubbleRect.right, + previewRect.right, lessThanOrEqualTo( tester.getRect(find.byKey(const Key('home-feed-gesture'))).right - 12, ), @@ -662,33 +663,26 @@ void main() { .getRect(find.byKey(const Key('home-feed-progress-track'))) .center .dy - - bubbleRect.bottom, + previewRect.bottom, closeTo(16, 0.1), ); - final bubbleContainer = tester.widget( - find.descendant( - of: find.byKey(const Key('scrub-preview-time-bubble')), - matching: find.byType(Container), - ), - ); - expect( - (bubbleContainer.decoration! as BoxDecoration).color, - AppColors.timeBubbleScrim, - ); - expect( - bubbleContainer.padding, - const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + final previewContainer = tester.widget( + find.byKey(const Key('scrub-preview-card')), ); + final previewDecoration = previewContainer.decoration! as BoxDecoration; + expect(previewDecoration.color, AppColors.card); + expect(previewDecoration.borderRadius, BorderRadius.circular(8)); + expect((previewDecoration.border! as Border).top.color, AppColors.onPrimary); await gesture.moveBy(const Offset(-600, 0)); await tester.pump(); expect( - tester.getRect(find.byKey(const Key('scrub-preview-time-bubble'))).left, + tester.getRect(find.byKey(const Key('scrub-preview-card'))).left, closeTo(12, 0.1), ); await gesture.moveBy(const Offset(1200, 0)); await tester.pump(); expect( - tester.getRect(find.byKey(const Key('scrub-preview-time-bubble'))).right, + tester.getRect(find.byKey(const Key('scrub-preview-card'))).right, closeTo( tester.getRect(find.byKey(const Key('home-feed-gesture'))).right - 12, 0.1, @@ -980,7 +974,7 @@ void main() { await tester.pump(); expect(find.byKey(const Key('scrub-preview-card')), findsOneWidget); - expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget); + expect(find.byKey(const Key('scrub-preview-background')), findsOneWidget); expect(find.textContaining('/ 01:40'), findsOneWidget); await gesture.up(); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 6d0a1e9..15e9725 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -8,6 +8,7 @@ import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/widgets/app_bottom_nav.dart'; +import 'package:fruitshorts/pages/home/widgets/home_feed_grid.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; @@ -45,7 +46,7 @@ void main() { expect(find.byKey(const Key('home-grid')), findsOneWidget); expect(find.byKey(const Key('home-grid-cards')), findsOneWidget); - expect(find.byType(PageView), findsNothing); + expect(find.byKey(const Key('home-primary-page-view')), findsOneWidget); expect(find.byKey(const Key('home-page-gradient')), findsNothing); expect(_hasLightStatusBar(tester), isTrue); }); @@ -158,7 +159,20 @@ void main() { testWidgets('should_open_player_from_home_grid_card', (tester) async { await _pumpApp(tester); - await tester.tap(find.byKey(const Key('home-feed-card-the-signal-demo'))); + final grid = tester.widget(find.byType(HomeFeedGrid)); + expect(grid.feedController.dramas, isNotEmpty); + final card = find.byKey( + const Key('home-feed-card-city-lights-demo'), + ); + for (var attempt = 0; attempt < 10; attempt++) { + if (card.evaluate().isNotEmpty) break; + await tester.drag( + find.byKey(const Key('home-grid-scroll')), + const Offset(0, -240), + ); + await tester.pump(); + } + await tester.tap(card); await tester.pumpAndSettle(); final player = tester.widget(find.byType(PlayerPage).last); From 6be024f86faad149b8aaf7ec57e181b90f1bde1a Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 22:49:27 +0800 Subject: [PATCH 11/29] =?UTF-8?q?test(flutter):=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E9=85=8D=E7=BD=AE=E4=B8=8E=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/config/server_endpoint_test.dart | 6 +++- test/core/app_pull_to_refresh_test.dart | 2 +- test/discover/filter_page_test.dart | 6 +++- test/discover/home_all_dramas_grid_test.dart | 8 +++-- test/home/home_grid_layout_test.dart | 38 +++++++++++++------- 5 files changed, 42 insertions(+), 18 deletions(-) diff --git a/test/config/server_endpoint_test.dart b/test/config/server_endpoint_test.dart index b0ad05b..379fd13 100644 --- a/test/config/server_endpoint_test.dart +++ b/test/config/server_endpoint_test.dart @@ -63,7 +63,11 @@ void main() { }); test('should_use_the_configured_local_address_for_device_default', () { - expect(AppConfig.devHostBaseUrl, 'http://192.168.31.243:8081'); + final uri = Uri.parse(AppConfig.devHostBaseUrl); + expect(uri.scheme, 'http'); + expect(uri.host, isNotEmpty); + expect(uri.port, 8081); + expect(uri.path, isEmpty); }); test('should_skip_probe_when_compiled_override_is_set', () async { diff --git a/test/core/app_pull_to_refresh_test.dart b/test/core/app_pull_to_refresh_test.dart index 71054d1..08a7e2f 100644 --- a/test/core/app_pull_to_refresh_test.dart +++ b/test/core/app_pull_to_refresh_test.dart @@ -43,7 +43,7 @@ void main() { final scrollable = tester.state( find.descendant(of: list, matching: find.byType(Scrollable)).first, ); - expect(scrollable.position.pixels, 0); + expect(scrollable.position.pixels, lessThan(0)); final indicator = find.byKey(const Key('pull-refresh-indicator')); expect(indicator, findsOneWidget); expect( diff --git a/test/discover/filter_page_test.dart b/test/discover/filter_page_test.dart index 061df58..0d44b22 100644 --- a/test/discover/filter_page_test.dart +++ b/test/discover/filter_page_test.dart @@ -8,6 +8,7 @@ import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/pages/discover/discover_view.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; @@ -151,7 +152,10 @@ Future _pumpFilterPage(WidgetTester tester) async { ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-search-entry'))); + Navigator.of(tester.element(find.byKey(const Key('home-search-entry')))) + .push( + MaterialPageRoute(builder: (_) => const DiscoverPage()), + ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('theater-toolbar-filter'))); await tester.pumpAndSettle(); diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index a68dad5..17f6ab3 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -13,6 +13,7 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; +import 'package:fruitshorts/pages/discover/discover_view.dart'; import 'package:fruitshorts/pages/discover/widgets/discover_category_tab.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; @@ -423,9 +424,10 @@ Future _pumpTheater( ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-search-entry'))); - await tester.pumpAndSettle(); - FocusManager.instance.primaryFocus?.unfocus(); + Navigator.of(tester.element(find.byKey(const Key('home-search-entry')))) + .push( + MaterialPageRoute(builder: (_) => const DiscoverPage()), + ); await tester.pumpAndSettle(); } diff --git a/test/home/home_grid_layout_test.dart b/test/home/home_grid_layout_test.dart index c959c6d..842dde1 100644 --- a/test/home/home_grid_layout_test.dart +++ b/test/home/home_grid_layout_test.dart @@ -13,6 +13,7 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/pages/home/widgets/home_feed_card.dart'; +import 'package:fruitshorts/pages/home/widgets/home_feed_grid.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; @@ -102,7 +103,7 @@ void main() { await tester.tap(find.byKey(const Key('home-section-category-社群'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-grid-empty')), findsOneWidget); + expect(firstDramaId(), 'page1-0'); expect(find.byKey(const Key('filter-page')), findsNothing); }); @@ -185,17 +186,30 @@ void main() { expect(repository.cursors, contains('page1')); expect(repository.requestedSizes.every((size) => size <= 20), isTrue); - await tester.scrollUntilVisible( - find.byKey(const Key('home-feed-card-page2-1')), - 260, - scrollable: find - .descendant( - of: find.byKey(const Key('home-grid-refresh')), - matching: find.byType(Scrollable), - ) - .first, + expect(find.byKey(const Key('home-grid-refresh')), findsOneWidget); + final gridScrollable = find + .descendant( + of: find.byKey(const Key('home-grid-scroll')), + matching: find.byType(Scrollable), + ); + expect(gridScrollable, findsOneWidget); + final secondPageCard = find.byKey( + const Key('home-feed-card-page2-0'), ); - expect(find.byKey(const Key('home-feed-card-page2-1')), findsOneWidget); + final feed = tester + .widget(find.byType(HomeFeedGrid)) + .feedController; + expect( + feed.dramas.map((drama) => drama.id), + contains('page2-0'), + ); + final position = tester.state(gridScrollable).position; + await tester.pump(); + position.jumpTo(position.maxScrollExtent); + await tester.pump(); + position.jumpTo(position.maxScrollExtent); + await tester.pump(); + expect(secondPageCard, findsOneWidget); }); testWidgets('should_open_player_when_grid_card_is_tapped', (tester) async { @@ -242,7 +256,7 @@ Future _pumpApp( class _PagedCatalogRepository implements CatalogRepository { _PagedCatalogRepository() - : pageOne = List.generate(10, (index) => _drama('page1-$index')), + : pageOne = List.generate(30, (index) => _drama('page1-$index')), pageTwo = List.generate(2, (index) => _drama('page2-$index')); final List pageOne; From 4b97f89c0b32309a910bc6213c56e9e7b9a22abe Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 27 Oct 2026 09:19:18 +0800 Subject: [PATCH 12/29] =?UTF-8?q?fix(recommend):=20=E5=BB=B6=E5=90=8E?= =?UTF-8?q?=E6=8E=A8=E8=8D=90=E6=95=B0=E6=8D=AE=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/recommend/recommend_page.dart | 40 +++- .../recommend_refresh_scheduling_test.dart | 183 ++++++++++++++++++ 2 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 test/recommend/recommend_refresh_scheduling_test.dart diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index d6f0967..137be25 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -14,6 +14,7 @@ import '../../core/data/services/playlist_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; +import '../home/home_feed_channel.dart'; import '../player/open_player.dart'; import '../player/player_view.dart'; import 'recommend_types.dart'; @@ -51,6 +52,8 @@ class _RecommendPageState extends State RecommendInnerTab _tab = RecommendInnerTab.feed; final PageController _pageController = PageController(); int _scrubCancelRequest = 0; + bool _recommendDataRefreshScheduled = false; + HomeFeedChannel? _observedFeedChannel; @override bool get wantKeepAlive => true; @@ -58,22 +61,48 @@ class _RecommendPageState extends State @override void initState() { super.initState(); + _observedFeedChannel = widget.feedController.channel; + widget.feedController.addListener(_handleFeedControllerChanged); if (widget.active) _scheduleRecommendData(); } @override void didUpdateWidget(RecommendPage oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.active && !oldWidget.active) _scheduleRecommendData(); + if (oldWidget.feedController != widget.feedController) { + oldWidget.feedController.removeListener(_handleFeedControllerChanged); + _observedFeedChannel = widget.feedController.channel; + widget.feedController.addListener(_handleFeedControllerChanged); + } + if (_requiresRecommendDataRefresh(oldWidget)) { + _scheduleRecommendData(); + } + } + + bool _requiresRecommendDataRefresh(RecommendPage oldWidget) { + if (!widget.active) return false; + return !oldWidget.active || + oldWidget.feedController != widget.feedController || + oldWidget.catalogApi != widget.catalogApi; + } + + void _handleFeedControllerChanged() { + final channel = widget.feedController.channel; + if (channel == _observedFeedChannel) return; + _observedFeedChannel = channel; + if (widget.active) _scheduleRecommendData(); } void _scheduleRecommendData() { - final feed = widget.feedController; - if (!feed.loaded && !feed.loadingFirstPage) { - unawaited(feed.refreshCurrent()); - } + if (_recommendDataRefreshScheduled) return; + _recommendDataRefreshScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { + _recommendDataRefreshScheduled = false; if (!mounted || !widget.active) return; + final feed = widget.feedController; + if (!feed.loaded && !feed.loadingFirstPage) { + unawaited(feed.refreshCurrent()); + } final playlists = _readPlaylists(); if (playlists != null && !playlists.loaded && !playlists.loading) { unawaited(playlists.load()); @@ -83,6 +112,7 @@ class _RecommendPageState extends State @override void dispose() { + widget.feedController.removeListener(_handleFeedControllerChanged); _pageController.dispose(); super.dispose(); } diff --git a/test/recommend/recommend_refresh_scheduling_test.dart b/test/recommend/recommend_refresh_scheduling_test.dart new file mode 100644 index 0000000..523adcc --- /dev/null +++ b/test/recommend/recommend_refresh_scheduling_test.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/services/category_controller.dart'; +import 'package:fruitshorts/core/data/services/home_feed_controller.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/home/home_feed_channel.dart'; +import 'package:fruitshorts/pages/recommend/recommend_page.dart'; +import 'package:provider/provider.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; + +void main() { + testWidgets('should_refresh_once_when_parent_rebuilds_repeatedly', ( + tester, + ) async { + final fixture = await _pumpRecommend(tester, active: true); + + expect(fixture.controller.refreshCalls, 1); + for (var index = 0; index < 20; index++) { + fixture.harnessKey.currentState!.rebuildSameInputs(); + await tester.pump(); + } + + expect(tester.takeException(), isNull); + expect(fixture.controller.refreshCalls, 1); + }); + + testWidgets('should_refresh_once_when_active_turns_on', (tester) async { + final fixture = await _pumpRecommend(tester, active: false); + expect(fixture.controller.refreshCalls, 0); + + fixture.harnessKey.currentState!.update(active: true); + await tester.pump(); + + expect(fixture.controller.refreshCalls, 1); + fixture.harnessKey.currentState!.rebuildSameInputs(); + await tester.pump(); + expect(fixture.controller.refreshCalls, 1); + }); + + testWidgets('should_refresh_again_when_data_source_changes', (tester) async { + final fixture = await _pumpRecommend(tester, active: true); + expect(fixture.controller.refreshCalls, 1); + + fixture.harnessKey.currentState!.update(active: false); + await tester.pump(); + expect(fixture.controller.refreshCalls, 1); + + fixture.harnessKey.currentState!.update(active: true); + await tester.pump(); + expect(fixture.controller.refreshCalls, 2); + }); + + testWidgets('should_not_refresh_after_dispose_when_channel_changes', ( + tester, + ) async { + final fixture = await _pumpRecommend(tester, active: true); + expect(fixture.controller.refreshCalls, 1); + + fixture.harnessKey.currentState!.update(active: true); + final selection = fixture.controller.selectChannel(HomeFeedChannel.anime); + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + await selection; + await tester.pump(); + + expect(fixture.controller.refreshCalls, 1); + }); +} + +Future<_RecommendFixture> _pumpRecommend( + WidgetTester tester, { + required bool active, +}) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final catalog = FakeCatalogRepository(); + final controller = _RecordingHomeFeedController(catalog); + final categories = CategoryController( + FakeCategoryRepository(catalog: catalog), + ); + final harnessKey = GlobalKey<_RecommendHarnessState>(); + final fixture = _RecommendFixture( + controller: controller, + categoryController: categories, + harnessKey: harnessKey, + ); + addTearDown(controller.dispose); + addTearDown(categories.dispose); + + await tester.pumpWidget(fixture.app(active: active)); + await tester.pump(); + return fixture; +} + +class _RecommendFixture { + const _RecommendFixture({ + required this.controller, + required this.categoryController, + required this.harnessKey, + }); + + final _RecordingHomeFeedController controller; + final CategoryController categoryController; + final GlobalKey<_RecommendHarnessState> harnessKey; + + Widget app({required bool active}) { + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ChangeNotifierProvider.value( + value: categoryController, + child: _RecommendHarness( + key: harnessKey, + controller: controller, + active: active, + ), + ), + ); + } +} + +class _RecommendHarness extends StatefulWidget { + const _RecommendHarness({ + super.key, + required this.controller, + required this.active, + }); + + final HomeFeedController controller; + final bool active; + + @override + State<_RecommendHarness> createState() => _RecommendHarnessState(); +} + +class _RecommendHarnessState extends State<_RecommendHarness> { + late HomeFeedController _controller; + late bool _active; + + @override + void initState() { + super.initState(); + _controller = widget.controller; + _active = widget.active; + } + + void rebuildSameInputs() { + setState(() {}); + } + + void update({required bool active}) { + setState(() { + _active = active; + }); + } + + @override + Widget build(BuildContext context) { + return RecommendPage( + feedController: _controller, + active: _active, + videoOnly: true, + onSearch: () {}, + ); + } +} + +class _RecordingHomeFeedController extends HomeFeedController { + _RecordingHomeFeedController(super.repository); + + int refreshCalls = 0; + + @override + Future refreshCurrent() async { + refreshCalls++; + notifyListeners(); + return true; + } +} From d3dbc9f8dc76364012a7281022b532aee38cd526 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 27 Oct 2026 09:31:48 +0800 Subject: [PATCH 13/29] =?UTF-8?q?fix(favorite):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=94=B6=E8=97=8F=E7=8A=B6=E6=80=81=E4=B8=8E=E4=BA=92=E5=8A=A8?= =?UTF-8?q?=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/services/favorite_controller.dart | 89 ++++++++++- .../drama_detail/drama_detail_logic.dart | 24 +-- lib/pages/player/player_logic.dart | 26 +--- lib/pages/player/player_view.dart | 28 +--- lib/pages/player/player_view_actions.dart | 2 +- .../player/widgets/home_feed_overlay.dart | 3 + .../widgets/home_feed_recommend_panel.dart | 144 ++++++------------ .../player/widgets/interaction_rail.dart | 34 +++-- .../drama_detail/drama_detail_logic_test.dart | 63 +++++--- test/home/home_preview_layout_test.dart | 60 ++++++-- test/library/favorite_controller_test.dart | 28 +++- test/player/double_tap_like_test.dart | 7 +- test/player/player_fullscreen_test.dart | 52 ++++++- 13 files changed, 347 insertions(+), 213 deletions(-) diff --git a/lib/core/data/services/favorite_controller.dart b/lib/core/data/services/favorite_controller.dart index dcfb9bc..b4c5ba6 100644 --- a/lib/core/data/services/favorite_controller.dart +++ b/lib/core/data/services/favorite_controller.dart @@ -18,6 +18,7 @@ class FavoriteController extends ChangeNotifier { final FavoriteRepository _repository; final int pageSize; final Set _saved = {}; + final Map _favoriteCounts = {}; List cards = const []; int total = 0; bool loading = false; @@ -34,17 +35,53 @@ class FavoriteController extends ChangeNotifier { bool isSaved(String dramaId) => _saved.contains(dramaId); - /// 用统计快照校准收藏状态;已有列表卡片结构不因一次状态同步而改动。 - void applyServerState(String dramaId, {required bool saved}) { - final changed = _saved.contains(dramaId) != saved; + /// 当前剧收藏数;包含本地 toggle 增量。 + int? favoriteCount(String dramaId) => _favoriteCounts[dramaId]; + + /// 首次展示时缓存卡片基数;之后统一由本控制器维护,避免页面各存一份。 + int? favoriteCountFor(Drama drama) { + final current = _favoriteCounts[drama.id]; + if (current != null) return current; + final fallback = drama.favoriteCount; + if (fallback == null) return null; + final normalized = fallback < 0 ? 0 : fallback; + _favoriteCounts[drama.id] = normalized; + return normalized; + } + + /// 用 stats/detail 的服务端快照校准唯一状态和计数。 + void applyServerState( + String dramaId, { + required bool saved, + int? favoriteCount, + }) { + var changed = _saved.contains(dramaId) != saved; if (saved) { _saved.add(dramaId); } else { _saved.remove(dramaId); } + if (favoriteCount != null) { + final normalized = favoriteCount < 0 ? 0 : favoriteCount; + changed = changed || _favoriteCounts[dramaId] != normalized; + _favoriteCounts[dramaId] = normalized; + } if (changed) notify(); } + /// 详情等只有计数、没有当前收藏状态的接口使用。 + void applyServerCount( + String dramaId, + int? favoriteCount, { + bool notify = true, + }) { + if (favoriteCount == null) return; + final normalized = favoriteCount < 0 ? 0 : favoriteCount; + if (_favoriteCounts[dramaId] == normalized) return; + _favoriteCounts[dramaId] = normalized; + if (notify) this.notify(); + } + void notify() { if (!_disposed) notifyListeners(); } @@ -65,6 +102,7 @@ class FavoriteController extends ChangeNotifier { _saved ..clear() ..addAll(list.map((drama) => drama.id)); + _rememberCardCounts(list); total = page.total; _pageIndex = 1; hasMore = page.list.isNotEmpty && cards.length < total; @@ -99,6 +137,7 @@ class FavoriteController extends ChangeNotifier { ).where((drama) => known.add(drama.id)).toList(growable: false); cards = [...cards, ...appended]; _saved.addAll(appended.map((drama) => drama.id)); + _rememberCardCounts(appended); total = page.total; _pageIndex += 1; hasMore = page.list.isNotEmpty && cards.length < total; @@ -121,12 +160,16 @@ class FavoriteController extends ChangeNotifier { Future toggle(String dramaId, {Drama? card}) async { lastErrorCode = null; final wasSaved = _saved.contains(dramaId); + final previousCount = card == null + ? favoriteCount(dramaId) + : favoriteCountFor(card); final previousCards = cards; final previousTotal = total; final previousHasMore = hasMore; // 乐观更新 if (wasSaved) { _saved.remove(dramaId); + _stepCount(dramaId, -1); if (cards.any((drama) => drama.id == dramaId)) { cards = cards.where((drama) => drama.id != dramaId).toList(); total = total > 0 ? total - 1 : 0; @@ -134,6 +177,7 @@ class FavoriteController extends ChangeNotifier { } } else { _saved.add(dramaId); + _stepCount(dramaId, 1); } notify(); try { @@ -142,6 +186,7 @@ class FavoriteController extends ChangeNotifier { if (saved) { _saved.add(dramaId); if (wasSaved) { + _restoreCount(dramaId, previousCount); cards = previousCards; total = previousTotal; hasMore = previousHasMore; @@ -152,6 +197,9 @@ class FavoriteController extends ChangeNotifier { } } else { _saved.remove(dramaId); + if (!wasSaved) { + _restoreCount(dramaId, previousCount); + } if (cards.any((drama) => drama.id == dramaId)) { cards = cards.where((drama) => drama.id != dramaId).toList(); total = total > 0 ? total - 1 : 0; @@ -170,6 +218,7 @@ class FavoriteController extends ChangeNotifier { } else { _saved.remove(dramaId); } + _restoreCount(dramaId, previousCount); notify(); if (failure is ApiException) lastErrorCode = failure.code; return false; @@ -185,9 +234,13 @@ class FavoriteController extends ChangeNotifier { final previousHasMore = hasMore; final removed = cards.where((drama) => ids.contains(drama.id)).toList(); if (removed.isEmpty) return true; + final previousCounts = { + for (final drama in removed) drama.id: _favoriteCounts[drama.id], + }; cards = cards.where((drama) => !ids.contains(drama.id)).toList(); for (final id in ids) { _saved.remove(id); + _stepCount(id, -1); } total = total > removed.length ? total - removed.length : 0; hasMore = cards.length < total; @@ -202,6 +255,9 @@ class FavoriteController extends ChangeNotifier { for (final drama in removed) { _saved.add(drama.id); } + for (final entry in previousCounts.entries) { + _restoreCount(entry.key, entry.value); + } notify(); return false; } @@ -212,7 +268,9 @@ class FavoriteController extends ChangeNotifier { final index = cards.indexWhere((drama) => drama.id == dramaId); if (index < 0) return true; final removed = cards[index]; + final previousCount = _favoriteCounts[dramaId]; _saved.remove(dramaId); + _stepCount(dramaId, -1); final next = [...cards]..removeAt(index); cards = next; total = total > 0 ? total - 1 : 0; @@ -229,6 +287,7 @@ class FavoriteController extends ChangeNotifier { ); cards = restored; _saved.add(dramaId); + _restoreCount(dramaId, previousCount); total += 1; hasMore = cards.length < total; notify(); @@ -259,6 +318,30 @@ class FavoriteController extends ChangeNotifier { ]; } + void _rememberCardCounts(List dramas) { + for (final drama in dramas) { + final count = drama.favoriteCount; + if (count != null && !_favoriteCounts.containsKey(drama.id)) { + _favoriteCounts[drama.id] = count < 0 ? 0 : count; + } + } + } + + void _stepCount(String dramaId, int delta) { + final current = _favoriteCounts[dramaId]; + if (current == null) return; + final next = current + delta; + _favoriteCounts[dramaId] = next < 0 ? 0 : next; + } + + void _restoreCount(String dramaId, int? count) { + if (count == null) { + _favoriteCounts.remove(dramaId); + } else { + _favoriteCounts[dramaId] = count; + } + } + @override void dispose() { _disposed = true; diff --git a/lib/pages/drama_detail/drama_detail_logic.dart b/lib/pages/drama_detail/drama_detail_logic.dart index 82d7536..0866e77 100644 --- a/lib/pages/drama_detail/drama_detail_logic.dart +++ b/lib/pages/drama_detail/drama_detail_logic.dart @@ -38,7 +38,6 @@ class DramaDetailLogic extends ChangeNotifier { int? continueIndex; WatchProgress? progress; String? progressError; - int _favoriteCountDelta = 0; bool _disposed = false; void notify() { @@ -47,6 +46,7 @@ class DramaDetailLogic extends ChangeNotifier { /// 列表卡片只带一集可播预览,进详情时补一次完整剧集列表。 Future load(Future Function() fetch) async { + favorites.applyServerCount(drama.id, drama.favoriteCount, notify: false); if (drama.detailLoaded) { await loadContinue(); return; @@ -57,6 +57,7 @@ class DramaDetailLogic extends ChangeNotifier { notify(); try { drama = await fetch(); + favorites.applyServerCount(drama.id, drama.favoriteCount); await loadContinue(); } on ApiException catch (exception) { failed = true; @@ -139,25 +140,12 @@ class DramaDetailLogic extends ChangeNotifier { await loadContinue(); } - /// 详情页展示用的收藏数;没有服务端计数时继续保持缺省展示。 - int? get adjustedFavoriteCount { - final base = drama.favoriteCount; - if (base == null) return null; - final adjusted = base + _favoriteCountDelta; - return adjusted < 0 ? 0 : adjusted; - } + /// 详情页展示用的收藏数,统一从全局 FavoriteController 读取。 + int? get adjustedFavoriteCount => favorites.favoriteCountFor(drama); - /// 收藏切换:乐观状态由控制器负责回滚,这里只在成功后同步计数。 + /// 收藏切换:状态与计数均由全局控制器维护。 Future toggleFavorite() async { - final wasSaved = favorites.isSaved(drama.id); - final ok = await favorites.toggle(drama.id, card: drama); - if (!ok) return false; - final nowSaved = favorites.isSaved(drama.id); - if (nowSaved != wasSaved) { - _favoriteCountDelta += nowSaved ? 1 : -1; - notify(); - } - return true; + return favorites.toggle(drama.id, card: drama); } @override diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 2505d26..aa3e95e 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -119,7 +119,6 @@ class PlayerLogic extends ChangeNotifier { final Map> _episodeStatsInFlight = {}; final Map _episodeStatsRevision = {}; final Map _dramaStatsRevision = {}; - final Map _dramaFavoriteCounts = {}; /// 解锁中的集 id 与失败文案;空表示没有进行中的解锁。 String? unlockingEpisodeId; @@ -185,8 +184,6 @@ class PlayerLogic extends ChangeNotifier { EpisodeStats? episodeStats(String episodeId) => _episodeStats[episodeId]; - int? dramaFavoriteCount(String dramaId) => _dramaFavoriteCounts[dramaId]; - void notify() { if (!_disposed) notifyListeners(); } @@ -621,9 +618,6 @@ class PlayerLogic extends ChangeNotifier { ); _episodeStats[episodeId] = stats; _episodeStatsFetchedAt[episodeId] = _clock(); - if (stats.dramaId.isNotEmpty) { - _dramaFavoriteCounts[stats.dramaId] = stats.favoriteCount; - } final episodeIndex = sessionEpisodes.indexWhere( (episode) => episode.id == episodeId, ); @@ -703,29 +697,13 @@ class PlayerLogic extends ChangeNotifier { notify(); } - /// 收藏成功时同时修正剧级计数和所有已缓存的同剧互动状态。 - void applyDramaFavoriteState( - String dramaId, { - required bool favorited, - int? favoriteCount, - }) { + /// 收藏成功后让旧 stats 请求失效,计数本身只写 FavoriteController。 + void markDramaFavoriteChanged(String dramaId) { _dramaStatsRevision.update( dramaId, (value) => value + 1, ifAbsent: () => 1, ); - if (favoriteCount != null) { - _dramaFavoriteCounts[dramaId] = favoriteCount; - } - for (final entry in _episodeStats.entries.toList()) { - final stats = entry.value; - if (stats.dramaId != dramaId) continue; - _episodeStats[entry.key] = stats.copyWith( - favorited: favorited, - favoriteCount: favoriteCount, - ); - } - notify(); } String _fileUrl(String path) => diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 74198bf..357008f 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -564,6 +564,7 @@ class _PlayerPageState extends State context.read().applyServerState( stats.dramaId, saved: stats.favorited, + favoriteCount: stats.favoriteCount, ); } setState(() { @@ -571,21 +572,6 @@ class _PlayerPageState extends State }); } - /// 收藏状态确认后同步右侧剧级计数。 - void _applyFavoriteDelta(Drama drama, {required bool nowSaved}) { - final current = logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount; - final next = current == null - ? null - : nowSaved - ? current + 1 - : (current > 0 ? current - 1 : 0); - logic.applyDramaFavoriteState( - drama.id, - favorited: nowSaved, - favoriteCount: next, - ); - } - Future _openComments( BuildContext context, Drama drama, @@ -1026,6 +1012,7 @@ class _PlayerPageState extends State InteractionController interactions, ) { final l10n = AppLocalizations.of(context)!; + final favorites = context.watch(); final entry = logic.queue.entryAt(index); final drama = entry.drama; final episode = _resolvedEpisode(index); @@ -1194,13 +1181,12 @@ class _PlayerPageState extends State liked: interactions.isEpisodeLiked(episode.id), likeCount: interactions.episodeLikeCount(episode.id) ?? episode.likeCount, - favoriteCount: - logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount, + favoriteCount: favorites.favoriteCountFor(drama), commentCount: logic.episodeStats(episode.id)?.commentCount ?? _commentCounts[episode.id] ?? episode.commentCount, - saved: context.watch().isSaved(drama.id), + saved: favorites.isSaved(drama.id), onLike: () async { await _toggleLike(context, interactions, episode); }, @@ -1277,6 +1263,7 @@ class _PlayerPageState extends State ) { if (logic.pip || logic.pipPreparing) return const SizedBox.expand(); final l10n = AppLocalizations.of(context)!; + final favorites = context.watch(); final entry = logic.queue.entryAt(index); final drama = entry.drama; final episode = _resolvedEpisode(index); @@ -1306,9 +1293,8 @@ class _PlayerPageState extends State logic.episodeStats(episode.id)?.commentCount ?? _commentCounts[episode.id] ?? episode.commentCount, - favoriteCount: - logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount, - saved: context.watch().isSaved(drama.id), + favoriteCount: favorites.favoriteCountFor(drama), + saved: favorites.isSaved(drama.id), playing: logic.session.userWantsPlay, playbackSpeed: _playbackSpeed, totalEpisodes: logic.visibleIndices.length, diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 2efb5fb..d78f3d0 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -166,7 +166,7 @@ extension _PlayerViewActions on _PlayerPageState { if (ok) { final nowSaved = favorites.isSaved(drama.id); if (nowSaved != wasSaved) { - _applyFavoriteDelta(drama, nowSaved: nowSaved); + logic.markDramaFavoriteChanged(drama.id); } return; } diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index fdd4aac..b4c96ec 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -269,8 +269,11 @@ class _HomeFeedOverlayState extends State { liked: widget.liked, favoriteCount: widget.favoriteCount, likeCount: widget.likeCount, + commentCount: widget.commentCount, onSave: widget.onSave, onLike: widget.onLike, + onComments: widget.onComments, + onShare: widget.onShare, onEpisodes: widget.onEpisodes ?? widget.onOpenFullPlayer, ); } diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart index 22a7af0..07f08da 100644 --- a/lib/pages/player/widgets/home_feed_recommend_panel.dart +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -5,19 +5,16 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import '../../../core/data/models/drama.dart'; -import '../../../core/formatting/count_format.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; import '../../home/widgets/home_drama_classification.dart'; +import 'interaction_rail.dart'; const double _seriesFont = 19; const double _tagFont = 13; const double _synopsisFont = 13; const double _watchAllHeight = 44; -const double _actionGap = 28; -const double _actionIcon = 32; -const double _actionWidth = 64; const List _textShadows = [ Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)), ]; @@ -307,112 +304,71 @@ class HomeFeedActionRail extends StatelessWidget { required this.liked, required this.onSave, required this.onLike, + required this.onComments, + required this.onShare, required this.onEpisodes, this.favoriteCount, this.likeCount, + this.commentCount, }); final bool saved; final bool liked; final int? favoriteCount; final int? likeCount; + final int? commentCount; final VoidCallback onSave; final VoidCallback onLike; + final VoidCallback onComments; + final VoidCallback onShare; final VoidCallback onEpisodes; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return SizedBox( - width: _actionWidth, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _action( - key: const Key('rail-save'), - icon: saved - ? Icons.bookmark_rounded - : Icons.bookmark_border_rounded, - label: favoriteCount == null - ? l10n.favorite - : formatInteractionCount(favoriteCount!), - labelKey: const Key('rail-save-count'), - onTap: onSave, - ), - const SizedBox(height: _actionGap), - _action( - key: const Key('rail-like'), - icon: liked - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - iconColor: liked - ? AppColors.doubleTapHeartRed - : AppColors.textPrimary, - label: likeCount == null - ? l10n.like - : formatInteractionCount(likeCount!), - labelKey: const Key('rail-like-count'), - onTap: onLike, - ), - const SizedBox(height: _actionGap), - _action( - key: const Key('rail-episodes'), - icon: Icons.layers_rounded, - label: l10n.episodes, - labelKey: const Key('rail-episodes-label'), - labelFontSize: 11, - onTap: onEpisodes, - ), - ], - ), - ); - } - - Widget _action({ - required Key key, - required IconData icon, - required String label, - required Key labelKey, - required VoidCallback onTap, - Color iconColor = AppColors.textPrimary, - double labelFontSize = 12, - }) { - return GestureDetector( - key: key, - behavior: HitTestBehavior.opaque, - onTap: onTap, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: _actionIcon, - color: iconColor, - shadows: const [ - Shadow( - color: AppColors.shadowSoft, - blurRadius: 4, - offset: Offset(0, 1), - ), - ], - ), - const SizedBox(height: 2), - Text( - label, - key: labelKey, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: TextStyle( - color: AppColors.textPrimary, - fontSize: labelFontSize, - fontWeight: FontWeight.w500, - height: 1, - shadows: _textShadows, - ), - ), - ], - ), + return InteractionRail( + items: [ + InteractionRailItem( + key: const Key('rail-save'), + asset: 'assets/icon/rail/rail_save.png', + label: l10n.favorite, + count: favoriteCount, + countKey: const Key('rail-save-count'), + selected: saved, + onTap: onSave, + ), + InteractionRailItem( + key: const Key('rail-comments'), + asset: 'assets/icon/rail/rail_comment.png', + label: l10n.comments, + count: commentCount, + countKey: const Key('rail-comments-count'), + onTap: onComments, + ), + InteractionRailItem( + key: const Key('rail-like'), + asset: 'assets/icon/rail/rail_like.png', + label: l10n.like, + count: likeCount, + countKey: const Key('rail-like-count'), + selected: liked, + selectedColor: AppColors.doubleTapHeartRed, + onTap: onLike, + ), + InteractionRailItem( + key: const Key('rail-share'), + asset: 'assets/icon/rail/rail_share.png', + label: l10n.share, + onTap: onShare, + ), + InteractionRailItem( + key: const Key('rail-episodes'), + icon: Icons.layers_rounded, + label: l10n.episodes, + countKey: const Key('rail-episodes-label'), + onTap: onEpisodes, + ), + ], ); } } diff --git a/lib/pages/player/widgets/interaction_rail.dart b/lib/pages/player/widgets/interaction_rail.dart index 48f0c98..40af975 100644 --- a/lib/pages/player/widgets/interaction_rail.dart +++ b/lib/pages/player/widgets/interaction_rail.dart @@ -13,17 +13,19 @@ const double _itemGap = 24; class InteractionRailItem { const InteractionRailItem({ required this.key, - required this.asset, required this.label, required this.onTap, + this.asset, + this.icon, this.count, this.countKey, this.selected = false, this.selectedColor = AppColors.blue, - }); + }) : assert((asset == null) != (icon == null)); final Key key; - final String asset; + final String? asset; + final IconData? icon; final String label; final VoidCallback onTap; final int? count; @@ -68,14 +70,24 @@ class _RailButton extends StatelessWidget { child: IconButton( onPressed: item.onTap, padding: EdgeInsets.zero, - icon: Image.asset( - item.asset, - width: _iconSize, - height: _iconSize, - fit: BoxFit.contain, - color: item.selected ? item.selectedColor : AppColors.onPrimary, - colorBlendMode: BlendMode.srcIn, - ), + icon: item.asset != null + ? Image.asset( + item.asset!, + width: _iconSize, + height: _iconSize, + fit: BoxFit.contain, + color: item.selected + ? item.selectedColor + : AppColors.onPrimary, + colorBlendMode: BlendMode.srcIn, + ) + : Icon( + item.icon, + size: _iconSize, + color: item.selected + ? item.selectedColor + : AppColors.onPrimary, + ), ), ), const SizedBox(height: 2), diff --git a/test/drama_detail/drama_detail_logic_test.dart b/test/drama_detail/drama_detail_logic_test.dart index 8249304..a4fa558 100644 --- a/test/drama_detail/drama_detail_logic_test.dart +++ b/test/drama_detail/drama_detail_logic_test.dart @@ -16,20 +16,20 @@ DramaDetailLogic _logic( FakeWatchHistory history, { FavoriteController? favorites, PlaybackPositionCache? positionCache, -}) => - DramaDetailLogic( - drama: drama, - history: history, - playback: FakePlaybackRepository(), - favorites: favorites ?? FavoriteController(InMemoryFavoriteRepository()), - positionCache: positionCache ?? PlaybackPositionCache(), - ); +}) => DramaDetailLogic( + drama: drama, + history: history, + playback: FakePlaybackRepository(), + favorites: favorites ?? FavoriteController(InMemoryFavoriteRepository()), + positionCache: positionCache ?? PlaybackPositionCache(), +); -Drama _card(String id) => Drama( +Drama _card(String id, {int? favoriteCount}) => Drama( id: id, title: id, synopsis: 'synopsis', posterUrl: '/files/$id.jpg', + favoriteCount: favoriteCount, totalEpisodes: 3, episodes: [ Episode( @@ -42,11 +42,12 @@ Drama _card(String id) => Drama( ], ); -Drama _detail(String id) => Drama( +Drama _detail(String id, {int? favoriteCount}) => Drama( id: id, title: id, synopsis: 'synopsis', posterUrl: '/files/$id.jpg', + favoriteCount: favoriteCount, totalEpisodes: 3, detailLoaded: true, episodes: [ @@ -62,21 +63,24 @@ Drama _detail(String id) => Drama( ); void main() { - test('should_upgrade_to_full_episode_list_when_detail_loads_for_list_card', () async { - var calls = 0; - final logic = _logic(_card('signal'), FakeWatchHistory()); + test( + 'should_upgrade_to_full_episode_list_when_detail_loads_for_list_card', + () async { + var calls = 0; + final logic = _logic(_card('signal'), FakeWatchHistory()); - await logic.load(() async { - calls++; - return _detail('signal'); - }); + await logic.load(() async { + calls++; + return _detail('signal'); + }); - expect(calls, 1); - expect(logic.loading, isFalse); - expect(logic.failed, isFalse); - expect(logic.drama.episodes, hasLength(3)); - logic.dispose(); - }); + expect(calls, 1); + expect(logic.loading, isFalse); + expect(logic.failed, isFalse); + expect(logic.drama.episodes, hasLength(3)); + logic.dispose(); + }, + ); test('should_keep_card_and_report_reason_when_load_fails', () async { final logic = _logic(_card('signal'), FakeWatchHistory()); @@ -107,6 +111,19 @@ void main() { logic.dispose(); }); + test('should_use_detail_favorite_count_as_authoritative', () async { + final favorites = FavoriteController(InMemoryFavoriteRepository()); + final card = _card('signal', favoriteCount: 2); + expect(favorites.favoriteCountFor(card), 2); + final logic = _logic(card, FakeWatchHistory(), favorites: favorites); + + await logic.load(() async => _detail('signal', favoriteCount: 9)); + + expect(favorites.favoriteCount('signal'), 9); + expect(logic.adjustedFavoriteCount, 9); + logic.dispose(); + }); + test('should_prefer_recent_local_position_when_loading_continue', () async { final cache = PlaybackPositionCache(); cache.save( diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index ef1c775..3835d74 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -323,19 +323,59 @@ void main() { tester.widget(find.byKey(const Key('rail-save-count'))).data, '3.2万', ); - expect( + final likeImage = tester.widget( find.descendant( of: find.byKey(const Key('rail-like')), - matching: find.byIcon(Icons.favorite_rounded), + matching: find.byType(Image), ), - findsOneWidget, ); - expect( + final saveImage = tester.widget( find.descendant( of: find.byKey(const Key('rail-save')), - matching: find.byIcon(Icons.bookmark_border_rounded), + matching: find.byType(Image), ), - findsOneWidget, + ); + expect( + (likeImage.image as AssetImage).assetName, + 'assets/icon/rail/rail_like.png', + ); + expect(likeImage.color, AppColors.doubleTapHeartRed); + expect( + (saveImage.image as AssetImage).assetName, + 'assets/icon/rail/rail_save.png', + ); + expect(saveImage.color, AppColors.onPrimary); + expect(find.byKey(const Key('rail-comments')), findsOneWidget); + expect(find.byKey(const Key('rail-share')), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('rail-comments-count'))).data, + '12', + ); + expect( + (tester + .widget( + find.descendant( + of: find.byKey(const Key('rail-comments')), + matching: find.byType(Image), + ), + ) + .image + as AssetImage) + .assetName, + 'assets/icon/rail/rail_comment.png', + ); + expect( + (tester + .widget( + find.descendant( + of: find.byKey(const Key('rail-share')), + matching: find.byType(Image), + ), + ) + .image + as AssetImage) + .assetName, + 'assets/icon/rail/rail_share.png', ); expect(find.byKey(const Key('rail-episodes')), findsOneWidget); expect( @@ -345,9 +385,6 @@ void main() { ), findsOneWidget, ); - expect(find.byKey(const Key('rail-comments')), findsNothing); - expect(find.byKey(const Key('rail-share')), findsNothing); - expect(find.text('Comments'), findsNothing); expect( tester.getRect(find.byKey(const Key('home-feed-action-rail'))).right, closeTo( @@ -672,7 +709,10 @@ void main() { final previewDecoration = previewContainer.decoration! as BoxDecoration; expect(previewDecoration.color, AppColors.card); expect(previewDecoration.borderRadius, BorderRadius.circular(8)); - expect((previewDecoration.border! as Border).top.color, AppColors.onPrimary); + expect( + (previewDecoration.border! as Border).top.color, + AppColors.onPrimary, + ); await gesture.moveBy(const Offset(-600, 0)); await tester.pump(); expect( diff --git a/test/library/favorite_controller_test.dart b/test/library/favorite_controller_test.dart index 0b56819..1dc8154 100644 --- a/test/library/favorite_controller_test.dart +++ b/test/library/favorite_controller_test.dart @@ -6,11 +6,12 @@ import 'package:fruitshorts/core/data/models/page_result.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; -Drama _card(String id) => Drama( +Drama _card(String id, {int? favoriteCount}) => Drama( id: id, title: 'Drama $id', synopsis: 'synopsis', posterUrl: '/files/$id.png', + favoriteCount: favoriteCount, ); void main() { @@ -37,6 +38,31 @@ void main() { expect(controller.cards.map((drama) => drama.id), ['signal']); }); + test( + 'should_adjust_the_single_favorite_count_when_toggle_succeeds', + () async { + final controller = FavoriteController(InMemoryFavoriteRepository()); + final card = _card('signal', favoriteCount: 12); + + expect(controller.favoriteCountFor(card), 12); + expect(await controller.toggle('signal', card: card), isTrue); + expect(controller.favoriteCount('signal'), 13); + expect(await controller.toggle('signal', card: card), isTrue); + expect(controller.favoriteCount('signal'), 12); + }, + ); + + test('should_apply_server_count_and_roll_back_toggle_failure', () async { + final repository = InMemoryFavoriteRepository()..failToggle = true; + final controller = FavoriteController(repository); + + controller.applyServerState('signal', saved: false, favoriteCount: 20); + expect(controller.favoriteCount('signal'), 20); + expect(await controller.toggle('signal'), isFalse); + expect(controller.isSaved('signal'), isFalse); + expect(controller.favoriteCount('signal'), 20); + }); + test('should_remove_card_and_state_when_toggled_off', () async { final repository = InMemoryFavoriteRepository(saved: {'signal'}); final controller = FavoriteController(repository); diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index ce253a4..40b341e 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/services/interaction_controller.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart'; @@ -41,13 +42,13 @@ void main() { ); expect(repository.likeTargets, hasLength(1)); expect(haptics.lightImpacts, 1); - expect( + final likeImage = tester.widget( find.descendant( of: find.byKey(const Key('rail-like')), - matching: find.byIcon(Icons.favorite_rounded), + matching: find.byType(Image), ), - findsOneWidget, ); + expect(likeImage.color, AppColors.doubleTapHeartRed); } }); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 9cf0f33..c71fbb6 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -630,6 +630,53 @@ void main() { expect(interactions.likeTargets, ['EPISODE:the-signal-demo-1']); }); + testWidgets('should_keep_favorite_count_when_player_saves_and_returns', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + + final initialCount = _railLabel(tester, const Key('rail-save')); + await tester.tap(find.byKey(const Key('home-feed-series-name'))); + await tester.pumpAndSettle(); + expect(_railLabel(tester, const Key('player-rail-save')), initialCount); + await tester.tap(find.byKey(const Key('player-rail-save'))); + await tester.pumpAndSettle(); + + final savedCount = _railLabel(tester, const Key('player-rail-save')); + expect(savedCount, isNot(initialCount)); + await tester.tap(find.byKey(const Key('player-back'))); + await tester.pumpAndSettle(); + + expect(find.byType(AuthPage), findsNothing); + expect(_railLabel(tester, const Key('rail-save')), savedCount); + }); + + testWidgets('should_keep_favorite_state_when_recommend_opens_player', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('rail-save'))); + await tester.pumpAndSettle(); + final savedCount = _railLabel(tester, const Key('rail-save')); + + await tester.tap(find.byKey(const Key('home-feed-series-name'))); + await tester.pumpAndSettle(); + + expect(_railLabel(tester, const Key('player-rail-save')), savedCount); + final saveImage = tester.widget( + find.descendant( + of: find.byKey(const Key('player-rail-save')), + matching: find.byType(Image), + ), + ); + expect(saveImage.color, AppColors.blue); + }); + testWidgets('should_keep_video_surface_background_black', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); @@ -646,9 +693,7 @@ void main() { expect(find.byKey(const Key('player-fallback-scrim')), findsNothing); }); - testWidgets('should_open_comments_without_login', ( - tester, - ) async { + testWidgets('should_open_comments_without_login', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); @@ -1914,7 +1959,6 @@ void main() { find.text('Available unlock methods vary by title.'), findsOneWidget, ); - }); testWidgets('should_require_login_before_opening_unlock_flow', ( From 4c3acb047c409fe005b822a1f474567b1040e8e4 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 27 Oct 2026 09:37:00 +0800 Subject: [PATCH 14/29] =?UTF-8?q?fix(player):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E6=8E=A8=E8=8D=90=E9=A1=B5=E4=BA=92=E5=8A=A8=E6=A0=8F=E9=97=B4?= =?UTF-8?q?=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../player/widgets/home_feed_overlay.dart | 1 - .../widgets/home_feed_recommend_panel.dart | 21 ++++++------------ .../player/widgets/interaction_rail.dart | 11 +++++++--- test/home/home_preview_layout_test.dart | 22 +++++++------------ test/player/player_fullscreen_test.dart | 11 ++++++++++ 5 files changed, 34 insertions(+), 32 deletions(-) diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index b4c96ec..dc10f8b 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -273,7 +273,6 @@ class _HomeFeedOverlayState extends State { onSave: widget.onSave, onLike: widget.onLike, onComments: widget.onComments, - onShare: widget.onShare, onEpisodes: widget.onEpisodes ?? widget.onOpenFullPlayer, ); } diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart index 07f08da..56c049f 100644 --- a/lib/pages/player/widgets/home_feed_recommend_panel.dart +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -305,7 +305,6 @@ class HomeFeedActionRail extends StatelessWidget { required this.onSave, required this.onLike, required this.onComments, - required this.onShare, required this.onEpisodes, this.favoriteCount, this.likeCount, @@ -320,13 +319,13 @@ class HomeFeedActionRail extends StatelessWidget { final VoidCallback onSave; final VoidCallback onLike; final VoidCallback onComments; - final VoidCallback onShare; final VoidCallback onEpisodes; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return InteractionRail( + itemGap: 14, items: [ InteractionRailItem( key: const Key('rail-save'), @@ -337,14 +336,6 @@ class HomeFeedActionRail extends StatelessWidget { selected: saved, onTap: onSave, ), - InteractionRailItem( - key: const Key('rail-comments'), - asset: 'assets/icon/rail/rail_comment.png', - label: l10n.comments, - count: commentCount, - countKey: const Key('rail-comments-count'), - onTap: onComments, - ), InteractionRailItem( key: const Key('rail-like'), asset: 'assets/icon/rail/rail_like.png', @@ -356,10 +347,12 @@ class HomeFeedActionRail extends StatelessWidget { onTap: onLike, ), InteractionRailItem( - key: const Key('rail-share'), - asset: 'assets/icon/rail/rail_share.png', - label: l10n.share, - onTap: onShare, + key: const Key('rail-comments'), + asset: 'assets/icon/rail/rail_comment.png', + label: l10n.comments, + count: commentCount, + countKey: const Key('rail-comments-count'), + onTap: onComments, ), InteractionRailItem( key: const Key('rail-episodes'), diff --git a/lib/pages/player/widgets/interaction_rail.dart b/lib/pages/player/widgets/interaction_rail.dart index 40af975..38f1369 100644 --- a/lib/pages/player/widgets/interaction_rail.dart +++ b/lib/pages/player/widgets/interaction_rail.dart @@ -7,7 +7,7 @@ import '../../../core/theme/app_colors.dart'; const double _buttonSize = 44; const double _iconSize = 32; -const double _itemGap = 24; +const double _defaultItemGap = 24; /// 互动栏单项:图标、计数与点击。 class InteractionRailItem { @@ -36,9 +36,14 @@ class InteractionRailItem { /// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。 class InteractionRail extends StatelessWidget { - const InteractionRail({super.key, required this.items}); + const InteractionRail({ + super.key, + required this.items, + this.itemGap = _defaultItemGap, + }); final List items; + final double itemGap; @override Widget build(BuildContext context) { @@ -46,7 +51,7 @@ class InteractionRail extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ for (var index = 0; index < items.length; index++) ...[ - if (index > 0) const SizedBox(height: _itemGap), + if (index > 0) SizedBox(height: itemGap), _RailButton(item: items[index]), ], ], diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 3835d74..4d5c62f 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -346,7 +346,7 @@ void main() { ); expect(saveImage.color, AppColors.onPrimary); expect(find.byKey(const Key('rail-comments')), findsOneWidget); - expect(find.byKey(const Key('rail-share')), findsOneWidget); + expect(find.byKey(const Key('rail-share')), findsNothing); expect( tester.widget(find.byKey(const Key('rail-comments-count'))).data, '12', @@ -364,19 +364,6 @@ void main() { .assetName, 'assets/icon/rail/rail_comment.png', ); - expect( - (tester - .widget( - find.descendant( - of: find.byKey(const Key('rail-share')), - matching: find.byType(Image), - ), - ) - .image - as AssetImage) - .assetName, - 'assets/icon/rail/rail_share.png', - ); expect(find.byKey(const Key('rail-episodes')), findsOneWidget); expect( find.descendant( @@ -385,6 +372,13 @@ void main() { ), findsOneWidget, ); + final saveRect = tester.getRect(find.byKey(const Key('rail-save'))); + final likeRect = tester.getRect(find.byKey(const Key('rail-like'))); + final commentsRect = tester.getRect(find.byKey(const Key('rail-comments'))); + final episodesRect = tester.getRect(find.byKey(const Key('rail-episodes'))); + expect(likeRect.top - saveRect.bottom, closeTo(14, 0.1)); + expect(commentsRect.top - likeRect.bottom, closeTo(14, 0.1)); + expect(episodesRect.top - commentsRect.bottom, closeTo(14, 0.1)); expect( tester.getRect(find.byKey(const Key('home-feed-action-rail'))).right, closeTo( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index c71fbb6..dbf9662 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -1351,6 +1351,17 @@ void main() { expect(shareImage.width, 32); expect(shareImage.height, 32); expect(shareImage.colorBlendMode, BlendMode.srcIn); + final saveRect = tester.getRect(find.byKey(const Key('player-rail-save'))); + final commentsRect = tester.getRect( + find.byKey(const Key('player-rail-comments')), + ); + final likeRect = tester.getRect(find.byKey(const Key('player-rail-like'))); + final shareRect = tester.getRect( + find.byKey(const Key('player-rail-share')), + ); + expect(commentsRect.top - saveRect.bottom, closeTo(24, 0.1)); + expect(likeRect.top - commentsRect.bottom, closeTo(24, 0.1)); + expect(shareRect.top - likeRect.bottom, closeTo(24, 0.1)); final saveButton = tester.widget( find.descendant( From 0d785bf1f8c1edf396e800604ae5af04067f95ba Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 27 Oct 2026 09:54:51 +0800 Subject: [PATCH 15/29] =?UTF-8?q?feat(library):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E8=BF=BD=E5=89=A7=E4=B8=8E=E8=A7=82=E7=9C=8B?= =?UTF-8?q?=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app.dart | 17 +-- lib/core/data/remote/watch_api.dart | 2 +- .../repositories/favorite_repository.dart | 8 +- .../watch_history_repository.dart | 12 +- .../data/services/favorite_controller.dart | 8 +- .../services/watch_records_controller.dart | 13 ++- lib/pages/favorites/favorites_page.dart | 16 +-- .../watch_records/watch_records_page.dart | 23 +++- test/library/favorites_page_test.dart | 26 ++--- test/theme/dark_theme_smoke_test.dart | 19 +++- test/watch/watch_api_test.dart | 57 ++++++++++ test/watch/watch_records_page_test.dart | 107 ++++++++++++++++-- 12 files changed, 232 insertions(+), 76 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 53133db..c47a354 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -141,17 +141,11 @@ class _LumaDramaAppState extends State { late final WatchHistoryRepository _watchHistory = widget.watchHistoryRepository ?? - HttpWatchHistoryRepository( - WatchApi(_apiClient), - isSignedIn: () => _auth.isSignedIn, - ); + HttpWatchHistoryRepository(WatchApi(_apiClient)); late final FavoriteRepository _favorites = widget.favoriteRepository ?? - HttpFavoriteRepository( - LibraryApi(_apiClient), - isSignedIn: () => _auth.isSignedIn, - ); + HttpFavoriteRepository(LibraryApi(_apiClient)); late final PlaybackRepository _playback = widget.playbackRepository ?? @@ -219,7 +213,7 @@ class _LumaDramaAppState extends State { unawaited(_localeController.load()); // 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联 _auth.addListener(_onAuthChanged); - if (_auth.isSignedIn) unawaited(_favoriteController.load()); + unawaited(_favoriteController.load()); if (_auth.isSignedIn) unawaited(_walletController.load()); unawaited(_downloadController.restore()); } @@ -235,6 +229,7 @@ class _LumaDramaAppState extends State { unawaited(_walletController.load()); } else { _favoriteController.clear(); + unawaited(_favoriteController.load()); _downloadController.reset(); _walletController.reset(); } @@ -293,9 +288,7 @@ class _LumaDramaAppState extends State { ), ChangeNotifierProvider(create: (_) => _createCategories()), if (playlists != null) - ChangeNotifierProvider( - create: (_) => PlaylistController(playlists), - ), + ChangeNotifierProvider(create: (_) => PlaylistController(playlists)), ChangeNotifierProvider.value(value: _favoriteController), ChangeNotifierProvider.value(value: _downloadController), ChangeNotifierProvider.value(value: _walletController), diff --git a/lib/core/data/remote/watch_api.dart b/lib/core/data/remote/watch_api.dart index f918e8d..74d39fa 100644 --- a/lib/core/data/remote/watch_api.dart +++ b/lib/core/data/remote/watch_api.dart @@ -4,7 +4,7 @@ import '../models/page_result.dart'; import '../models/watch_progress.dart'; import 'api_client.dart'; -/// 观看进度接口:`/app/watch/*`(契约 §1.5)。全部需要登录态。 +/// 观看进度接口:`/app/watch/*`(契约 §1.5)。支持账号或设备身份。 class WatchApi { const WatchApi(this._client); diff --git a/lib/core/data/repositories/favorite_repository.dart b/lib/core/data/repositories/favorite_repository.dart index dd7d917..3c7e861 100644 --- a/lib/core/data/repositories/favorite_repository.dart +++ b/lib/core/data/repositories/favorite_repository.dart @@ -19,21 +19,15 @@ abstract interface class FavoriteRepository { } class HttpFavoriteRepository implements FavoriteRepository { - const HttpFavoriteRepository(this._api, {this.isSignedIn}); + const HttpFavoriteRepository(this._api); final LibraryApi _api; - /// 未登录时不请求:收藏属于账号,匿名浏览时列表按空处理(点收藏按钮会先引导登录)。 - final bool Function()? isSignedIn; - - bool get _signedIn => isSignedIn?.call() ?? true; - @override Future toggle(String dramaId) => _api.toggleFavorite(dramaId); @override Future> page({int pageIndex = 1, int pageSize = 20}) async { - if (!_signedIn) return const PageResult(); return _api.favorites(pageIndex: pageIndex, pageSize: pageSize); } diff --git a/lib/core/data/repositories/watch_history_repository.dart b/lib/core/data/repositories/watch_history_repository.dart index cb6b76f..7c7937d 100644 --- a/lib/core/data/repositories/watch_history_repository.dart +++ b/lib/core/data/repositories/watch_history_repository.dart @@ -35,18 +35,12 @@ abstract interface class WatchHistoryRepository { } class HttpWatchHistoryRepository implements WatchHistoryRepository { - const HttpWatchHistoryRepository(this._api, {this.isSignedIn}); + const HttpWatchHistoryRepository(this._api); final WatchApi _api; - /// 未登录时这些接口必然 401:直接短路成"没有记录",让浏览/试看保持匿名可用。 - final bool Function()? isSignedIn; - - bool get _signedIn => isSignedIn?.call() ?? true; - @override Future progressFor(String dramaId) async { - if (!_signedIn) return null; return _api.progressOfDrama(dramaId); } @@ -57,7 +51,6 @@ class HttpWatchHistoryRepository implements WatchHistoryRepository { Duration? duration, bool finished = false, }) async { - if (!_signedIn) return null; return _api.saveProgress( episodeId: episodeId, position: position, @@ -71,19 +64,16 @@ class HttpWatchHistoryRepository implements WatchHistoryRepository { int pageIndex = 1, int pageSize = 20, }) async { - if (!_signedIn) return const PageResult(); return _api.recent(pageIndex: pageIndex, pageSize: pageSize); } @override Future remove(String dramaId) async { - if (!_signedIn) return; await _api.removeProgress(dramaId); } @override Future clear() async { - if (!_signedIn) return; await _api.clearProgress(); } } diff --git a/lib/core/data/services/favorite_controller.dart b/lib/core/data/services/favorite_controller.dart index b4c5ba6..29a473a 100644 --- a/lib/core/data/services/favorite_controller.dart +++ b/lib/core/data/services/favorite_controller.dart @@ -93,6 +93,7 @@ class FavoriteController extends ChangeNotifier { loadingMore = false; error = null; loadMoreError = null; + lastErrorCode = null; notify(); try { final page = await _repository.page(pageIndex: 1, pageSize: pageSize); @@ -106,9 +107,10 @@ class FavoriteController extends ChangeNotifier { total = page.total; _pageIndex = 1; hasMore = page.list.isNotEmpty && cards.length < total; - } catch (_) { + } catch (failure) { if (!_disposed && request == _requestSeq) { error = 'favoritesError'; + if (failure is ApiException) lastErrorCode = failure.code; } } finally { if (!_disposed && request == _requestSeq) { @@ -124,6 +126,7 @@ class FavoriteController extends ChangeNotifier { final request = ++_requestSeq; loadingMore = true; loadMoreError = null; + lastErrorCode = null; notify(); try { final page = await _repository.page( @@ -141,9 +144,10 @@ class FavoriteController extends ChangeNotifier { total = page.total; _pageIndex += 1; hasMore = page.list.isNotEmpty && cards.length < total; - } catch (_) { + } catch (failure) { if (!_disposed && request == _requestSeq) { loadMoreError = 'favoritesError'; + if (failure is ApiException) lastErrorCode = failure.code; } } finally { if (!_disposed && request == _requestSeq) { diff --git a/lib/core/data/services/watch_records_controller.dart b/lib/core/data/services/watch_records_controller.dart index ec70015..4ac064a 100644 --- a/lib/core/data/services/watch_records_controller.dart +++ b/lib/core/data/services/watch_records_controller.dart @@ -38,6 +38,7 @@ class WatchRecordsController extends ChangeNotifier { String? error; String? loadMoreError; String? actionError; + int? lastErrorCode; int _pageIndex = 0; int _requestSeq = 0; bool _disposed = false; @@ -50,6 +51,7 @@ class WatchRecordsController extends ChangeNotifier { error = null; loadMoreError = null; actionError = null; + lastErrorCode = null; _notify(); try { final page = await _history.recent(pageIndex: 1, pageSize: pageSize); @@ -71,6 +73,7 @@ class WatchRecordsController extends ChangeNotifier { ); if (!_disposed && request == _requestSeq) { error = failure is ApiException ? failure.message : 'progressError'; + if (failure is ApiException) lastErrorCode = failure.code; } } finally { if (!_disposed && request == _requestSeq) { @@ -86,6 +89,7 @@ class WatchRecordsController extends ChangeNotifier { final request = ++_requestSeq; loadingMore = true; loadMoreError = null; + lastErrorCode = null; _notify(); try { final page = await _history.recent( @@ -108,6 +112,7 @@ class WatchRecordsController extends ChangeNotifier { loadMoreError = failure is ApiException ? failure.message : 'progressError'; + if (failure is ApiException) lastErrorCode = failure.code; } } finally { if (!_disposed && request == _requestSeq) { @@ -148,17 +153,19 @@ class WatchRecordsController extends ChangeNotifier { total = total > 0 ? total - 1 : 0; hasMore = records.length < total; actionError = null; + lastErrorCode = null; _notify(); try { await _history.remove(dramaId); return true; - } catch (_) { + } catch (failure) { final next = [...records]; next.insert(index > next.length ? next.length : index, removed); records = next; total += 1; hasMore = records.length < total; actionError = 'recordDeleteFailed'; + if (failure is ApiException) lastErrorCode = failure.code; return false; } finally { _notify(); @@ -179,16 +186,18 @@ class WatchRecordsController extends ChangeNotifier { loadingMore = false; clearing = true; actionError = null; + lastErrorCode = null; _notify(); try { await _history.clear(); return true; - } catch (_) { + } catch (failure) { records = previous; total = previousTotal; _pageIndex = previousPageIndex; hasMore = records.length < total; actionError = 'recordClearFailed'; + if (failure is ApiException) lastErrorCode = failure.code; return false; } finally { clearing = false; diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index 5e0d871..c4559e1 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -6,7 +6,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; -import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/formatting/count_format.dart'; import '../../core/formatting/error_text.dart'; @@ -77,11 +76,6 @@ class _FavoritesContentState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - final signedIn = context.watch().isSignedIn; - if (!signedIn) { - _requested = false; - return; - } if (_requested) return; _requested = true; final favorites = context.read(); @@ -93,7 +87,6 @@ class _FavoritesContentState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final signedIn = context.watch().isSignedIn; return SafeArea( bottom: false, child: Column( @@ -104,9 +97,9 @@ class _FavoritesContentState extends State { showBack: widget.showBack, ), Expanded( - child: signedIn - ? _FavoritesBody(favorites: context.watch()) - : const AuthGatePanel(), + child: _FavoritesBody( + favorites: context.watch(), + ), ), ], ), @@ -122,6 +115,9 @@ class _FavoritesBody extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + if (favorites.lastErrorCode == 200007) { + return const AuthGatePanel(); + } if (favorites.loading && favorites.cards.isEmpty) { return const Center( child: CircularProgressIndicator(key: Key('favorites-loading')), diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index b244c63..62db3ad 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -35,7 +35,6 @@ class WatchRecordsPage extends StatelessWidget { @override Widget build(BuildContext context) { - final signedIn = context.watch().isSignedIn; final l10n = AppLocalizations.of(context)!; return Theme( data: AppTheme.dark(), @@ -49,11 +48,7 @@ class WatchRecordsPage extends StatelessWidget { title: l10n.watchHistory, backKey: const Key('watch-records-back'), ), - Expanded( - child: signedIn - ? const _WatchRecordsBody() - : const AuthGatePanel(), - ), + Expanded(child: const _WatchRecordsBody()), ], ), ), @@ -71,10 +66,15 @@ class _WatchRecordsBody extends StatefulWidget { class _WatchRecordsBodyState extends State<_WatchRecordsBody> { late final WatchRecordsController controller; + late final AuthController _auth; + late bool _signedIn; @override void initState() { super.initState(); + _auth = context.read(); + _signedIn = _auth.isSignedIn; + _auth.addListener(_handleAuthChanged); controller = WatchRecordsBinding().create( history: context.read(), catalog: context.read(), @@ -82,8 +82,16 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { unawaited(controller.load()); } + void _handleAuthChanged() { + final signedIn = _auth.isSignedIn; + if (_signedIn == signedIn || !mounted) return; + _signedIn = signedIn; + unawaited(controller.load()); + } + @override void dispose() { + _auth.removeListener(_handleAuthChanged); controller.dispose(); super.dispose(); } @@ -98,6 +106,9 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { Widget _content(BuildContext context) { final l10n = AppLocalizations.of(context)!; + if (controller.lastErrorCode == 200007) { + return const AuthGatePanel(); + } if (controller.loading && controller.records.isEmpty) { return const Center( child: CircularProgressIndicator(key: Key('watch-records-loading')), diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index 4599f42..d378a89 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -187,22 +187,20 @@ void main() { expect(find.byKey(const Key('favorites-retry')), findsOneWidget); }); - testWidgets( - 'should_show_auth_gate_without_loading_favorites_when_signed_out', - (tester) async { - final auth = await _auth(signedIn: false); - final repository = InMemoryFavoriteRepository( - saved: {'signal'}, - cards: [_card('signal')], - ); - final favorites = FavoriteController(repository); + testWidgets('should_show_device_favorites_when_signed_out', (tester) async { + final auth = await _auth(signedIn: false); + final repository = InMemoryFavoriteRepository( + saved: {'signal'}, + cards: [_card('signal')], + ); + final favorites = FavoriteController(repository); - await _pumpPage(tester, auth: auth, favorites: favorites); + await _pumpPage(tester, auth: auth, favorites: favorites); - expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget); - expect(repository.pageCalls, 0); - }, - ); + expect(find.byKey(const Key('auth-gate-panel')), findsNothing); + expect(find.text('Drama signal'), findsOneWidget); + expect(repository.pageCalls, 1); + }); testWidgets('should_open_player_when_favorite_card_is_tapped', ( tester, diff --git a/test/theme/dark_theme_smoke_test.dart b/test/theme/dark_theme_smoke_test.dart index 7ece6f6..a5963ab 100644 --- a/test/theme/dark_theme_smoke_test.dart +++ b/test/theme/dark_theme_smoke_test.dart @@ -4,8 +4,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/data/services/auth_controller.dart'; import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; @@ -15,6 +18,8 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import '../support/fake_http_adapter.dart'; +import '../support/fake_catalog.dart'; +import '../support/fake_haptics.dart'; void main() { setUp(() { @@ -92,8 +97,15 @@ void main() { ); await tester.pumpWidget( - ChangeNotifierProvider.value( - value: auth, + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + Provider.value( + value: InMemoryWatchHistoryRepository(), + ), + Provider.value(value: FakeCatalogRepository()), + Provider.value(value: FakeHaptics()), + ], child: MaterialApp( theme: AppTheme.dark(), darkTheme: AppTheme.dark(), @@ -111,7 +123,8 @@ void main() { find.byKey(const Key('watch-records-page')), ); expect(scaffold.backgroundColor, AppColors.ink); - expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget); + expect(find.byKey(const Key('auth-gate-panel')), findsNothing); + expect(find.byKey(const Key('watch-records-empty')), findsOneWidget); expect(find.byType(Scaffold), findsOneWidget); }); } diff --git a/test/watch/watch_api_test.dart b/test/watch/watch_api_test.dart index 3214b41..22611d5 100644 --- a/test/watch/watch_api_test.dart +++ b/test/watch/watch_api_test.dart @@ -5,6 +5,7 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/remote/watch_api.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import '../support/fake_http_adapter.dart'; @@ -94,4 +95,60 @@ void main() { ), ); }); + + test('should_allow_guest_watch_read_save_and_clear', () async { + final adapter = FakeHttpAdapter((options) async { + if (options.method == 'GET' && options.uri.path.endsWith('/progress')) { + return FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'dramaId': '12', + 'episodeId': '88', + 'positionMs': '8000', + 'durationMs': '60000', + }, + }), + ); + } + if (options.method == 'GET') { + return FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': {'total': '0', 'list': []}, + }), + ); + } + return FakeHttpResponse( + jsonEncode({'code': 0, 'message': 'ok', 'data': null}), + ); + }); + final repository = HttpWatchHistoryRepository( + WatchApi(ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter)), + ); + + expect((await repository.progressFor('12'))?.episodeId, '88'); + expect((await repository.recent()).list, isEmpty); + await repository.save( + episodeId: '88', + position: const Duration(seconds: 8), + ); + await repository.remove('12'); + await repository.clear(); + + expect( + adapter.requests.map( + (request) => '${request.method} ${request.uri.path}', + ), + [ + 'GET /app/watch/progress', + 'GET /app/watch/recent', + 'POST /app/watch/progress', + 'DELETE /app/watch/progress/12', + 'DELETE /app/watch/progress', + ], + ); + }); } diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index 9b8fc26..db2f3c4 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -174,9 +174,7 @@ void main() { }, ); - testWidgets('should_show_auth_gate_without_loading_records_when_signed_out', ( - tester, - ) async { + testWidgets('should_show_device_history_when_signed_out', (tester) async { final auth = await _auth(signedIn: false); final history = CountingWatchHistory([_progress('the-signal-demo')]); @@ -187,8 +185,59 @@ void main() { catalog: FakeCatalogRepository(), ); - expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget); - expect(history.recentCalls, 0); + expect(find.byKey(const Key('auth-gate-panel')), findsNothing); + expect(find.text('The Signal'), findsOneWidget); + expect(history.recentCalls, 1); + }); + + testWidgets('should_reload_device_history_after_login', (tester) async { + final auth = _ToggleAuthController(); + final history = CountingWatchHistory([_progress('the-signal-demo')]); + await _pumpPage( + tester, + auth: auth, + history: history, + catalog: FakeCatalogRepository(), + ); + expect(history.recentCalls, 1); + + auth.setSignedIn(true); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(history.recentCalls, 2); + expect(find.text('The Signal'), findsOneWidget); + }); + + testWidgets('should_remove_and_clear_device_history_when_signed_out', ( + tester, + ) async { + final auth = await _auth(signedIn: false); + final history = CountingWatchHistory([ + _progress('the-signal-demo'), + _progress('city-lights-demo'), + ]); + await _pumpPage( + tester, + auth: auth, + history: history, + catalog: FakeCatalogRepository(), + ); + + await tester.tap( + find.byKey(const Key('watch-record-remove-the-signal-demo')), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('watch-record-the-signal-demo')), findsNothing); + expect(history.removeCalls, 1); + + await tester.tap(find.byKey(const Key('watch-records-clear'))); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Clear')); + await tester.pumpAndSettle(); + + expect(history.clearCalls, 1); + expect(find.byKey(const Key('watch-records-empty')), findsOneWidget); }); testWidgets( @@ -313,9 +362,17 @@ Future _auth({required bool signedIn}) async { } final client = ApiClient( baseUrl: 'http://127.0.0.1:8081', - adapter: FakeHttpAdapter( - (_) async => FakeHttpResponse('{"code":0,"message":"ok","data":null}'), - ), + adapter: FakeHttpAdapter((options) async { + if (options.uri.path == '/app/auth/login') { + return FakeHttpResponse( + '{"code":0,"message":"ok","data":{' + '"accessToken":"test-access","refreshToken":"test-refresh",' + '"expiresIn":7200,"user":{"uid":"1","nickname":"Viewer",' + '"avatarUrl":""}}}', + ); + } + return FakeHttpResponse('{"code":0,"message":"ok","data":null}'); + }), ); final auth = AuthController(AuthApi(client), AuthSessionStore()); await auth.load(); @@ -353,6 +410,7 @@ class CountingWatchHistory extends InMemoryWatchHistoryRepository { final List visibleRecords; int recentCalls = 0; + int removeCalls = 0; int clearCalls = 0; @override @@ -371,6 +429,12 @@ class CountingWatchHistory extends InMemoryWatchHistoryRepository { ); } + @override + Future remove(String dramaId) async { + removeCalls++; + await super.remove(dramaId); + } + @override Future clear() async { clearCalls++; @@ -440,3 +504,30 @@ class _AlwaysFailingWatchCatalog implements CatalogRepository { @override Future fetchHomeFeed() async => const HomeFeed(); } + +class _ToggleAuthController extends AuthController { + _ToggleAuthController() + : super( + AuthApi( + ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: FakeHttpAdapter( + (_) async => + FakeHttpResponse('{"code":0,"message":"ok","data":null}'), + ), + ), + ), + AuthSessionStore(), + ); + + bool _signedIn = false; + + @override + bool get isSignedIn => _signedIn; + + void setSignedIn(bool value) { + if (_signedIn == value) return; + _signedIn = value; + notifyListeners(); + } +} From 06a2f6d7c02d046f2a591991718c98945a0a3237 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 27 Oct 2026 10:13:27 +0800 Subject: [PATCH 16/29] =?UTF-8?q?fix(following):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=9C=AA=E7=99=BB=E5=BD=95=E8=BF=BD=E5=89=A7=E4=B8=8E=E5=8E=86?= =?UTF-8?q?=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/watch_records_controller.dart | 17 ++ lib/pages/following/following_page.dart | 91 +++---- .../watch_records/watch_records_page.dart | 5 +- test/following/following_page_test.dart | 232 +++++++++++++++++- 4 files changed, 292 insertions(+), 53 deletions(-) diff --git a/lib/core/data/services/watch_records_controller.dart b/lib/core/data/services/watch_records_controller.dart index 4ac064a..5167541 100644 --- a/lib/core/data/services/watch_records_controller.dart +++ b/lib/core/data/services/watch_records_controller.dart @@ -314,6 +314,23 @@ class WatchRecordsController extends ChangeNotifier { if (!_disposed) notifyListeners(); } + /// 切换账号/设备身份时清除内存数据,不删除服务端记录。 + void reset() { + _requestSeq += 1; + records = const []; + total = 0; + loading = false; + loadingMore = false; + hasMore = false; + clearing = false; + error = null; + loadMoreError = null; + actionError = null; + lastErrorCode = null; + _pageIndex = 0; + _notify(); + } + @override void dispose() { _disposed = true; diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart index 7ba9f2d..26d7993 100644 --- a/lib/pages/following/following_page.dart +++ b/lib/pages/following/following_page.dart @@ -15,7 +15,6 @@ import '../../core/data/services/watch_records_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; -import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; @@ -67,7 +66,7 @@ class _FollowingPageState extends State { history: context.read(), catalog: context.read(), ); - if (_wasSignedIn) unawaited(_history.load()); + unawaited(_history.load()); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(widget.favoriteController.load()); }); @@ -75,8 +74,16 @@ class _FollowingPageState extends State { void _onAuthChanged() { final signedIn = _auth.isSignedIn; - if (signedIn && !_wasSignedIn) unawaited(_history.load()); + if (signedIn == _wasSignedIn) return; _wasSignedIn = signedIn; + _leaveEditing(); + _history.reset(); + widget.favoriteController.clear(); + unawaited(_reloadForIdentity()); + } + + Future _reloadForIdentity() async { + await Future.wait([_history.load(), widget.favoriteController.load()]); } @override @@ -104,7 +111,6 @@ class _FollowingPageState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final signedIn = context.watch().isSignedIn; return ColoredBox( key: const Key('following-page'), color: AppColors.appBackground, @@ -114,47 +120,44 @@ class _FollowingPageState extends State { children: [ _header(l10n), Expanded( - child: !signedIn - ? const AuthGatePanel() - : PageView( - key: const Key('following-tab-pages'), - controller: _pageController, - onPageChanged: _onPageChanged, - children: [ - FollowingKeepAliveTab( - child: FollowingFavoritesGrid( - controller: widget.favoriteController, - selection: FollowingFavoriteSelection( - editing: _editing, - selected: _selected, - onToggle: _toggleDrama, - ), - scrollController: _favoriteScroll, - onGoHome: widget.onGoHome, - ), - ), - FollowingKeepAliveTab( - child: _HistoryList( - controller: _history, - editing: _editing, - scrollController: _historyScroll, - onListEmptied: _exitEditingIfEmpty, - ), - ), - ], + child: PageView( + key: const Key('following-tab-pages'), + controller: _pageController, + onPageChanged: _onPageChanged, + children: [ + FollowingKeepAliveTab( + child: FollowingFavoritesGrid( + controller: widget.favoriteController, + selection: FollowingFavoriteSelection( + editing: _editing, + selected: _selected, + onToggle: _toggleDrama, + ), + scrollController: _favoriteScroll, + onGoHome: widget.onGoHome, ), - ), - if (signedIn) - FollowingEditSlot( - tab: _tab, - editing: _editing, - favorites: widget.favoriteController, - selected: _selected, - actions: FollowingEditActions( - onToggleAll: _toggleAll, - onRemove: () => unawaited(_cancelSelected()), - ), + ), + FollowingKeepAliveTab( + child: _HistoryList( + controller: _history, + editing: _editing, + scrollController: _historyScroll, + onListEmptied: _exitEditingIfEmpty, + ), + ), + ], ), + ), + FollowingEditSlot( + tab: _tab, + editing: _editing, + favorites: widget.favoriteController, + selected: _selected, + actions: FollowingEditActions( + onToggleAll: _toggleAll, + onRemove: () => unawaited(_cancelSelected()), + ), + ), ], ), ), @@ -297,7 +300,7 @@ class _FollowingPageState extends State { final tab = FollowingTab.values[index]; _leaveEditing(); _tab.value = tab; - if (tab == FollowingTab.history && _auth.isSignedIn) { + if (tab == FollowingTab.history) { unawaited(_history.load()); } } diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index 62db3ad..1d8c471 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -15,7 +15,6 @@ import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; -import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/paged_list_footer.dart'; import '../../core/widgets/subpage_header.dart'; @@ -86,6 +85,7 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { final signedIn = _auth.isSignedIn; if (_signedIn == signedIn || !mounted) return; _signedIn = signedIn; + controller.reset(); unawaited(controller.load()); } @@ -106,9 +106,6 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { Widget _content(BuildContext context) { final l10n = AppLocalizations.of(context)!; - if (controller.lastErrorCode == 200007) { - return const AuthGatePanel(); - } if (controller.loading && controller.records.isEmpty) { return const Center( child: CircularProgressIndicator(key: Key('watch-records-loading')), diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart index be4b8e3..5e57ce3 100644 --- a/test/following/following_page_test.dart +++ b/test/following/following_page_test.dart @@ -4,12 +4,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/page_result.dart'; import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_dimens.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; @@ -165,7 +169,8 @@ void main() { testWidgets('should_unfollow_every_selected_drama_when_edit_confirms', ( tester, ) async { - _signIn(); + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); final catalog = FakeCatalogRepository(); final dramas = catalog.dramas.take(2).toList(); final favorites = InMemoryFavoriteRepository( @@ -203,16 +208,119 @@ void main() { expect(favorites.removeManyCalls, 1); }); - testWidgets('should_show_auth_gate_when_signed_out', (tester) async { + testWidgets('should_show_device_favorites_and_history_when_signed_out', ( + tester, + ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); - await tester.pumpWidget(_app(catalog: FakeCatalogRepository())); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + _app( + catalog: catalog, + favorites: InMemoryFavoriteRepository( + cards: catalog.dramas.take(1).toList(), + saved: {catalog.dramas.first.id}, + ), + history: InMemoryWatchHistoryRepository( + seed: [ + WatchProgress( + dramaId: 'the-signal-demo', + episodeId: 'the-signal-demo-3', + dramaTitle: 'Device history', + episodeNumber: 3, + position: const Duration(seconds: 25), + duration: const Duration(seconds: 100), + ), + ], + ), + ), + ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget); - expect(find.byKey(const Key('following-favorites-grid')), findsNothing); + expect(find.byKey(const Key('auth-gate-panel')), findsNothing); + expect(find.byKey(const Key('following-favorites-grid')), findsOneWidget); + expect( + find.byKey(const Key('following-card-the-signal-demo')), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('following-tab-history'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('following-history-card-the-signal-demo')), + findsOneWidget, + ); + expect(find.text('Device history'), findsOneWidget); + }); + + testWidgets('should_reload_identity_data_after_login_and_logout', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final catalog = FakeCatalogRepository(); + final favorites = _IdentityFavoriteRepository( + device: [catalog.dramas[0]], + account: [catalog.dramas[1]], + ); + final history = _IdentityWatchHistoryRepository( + device: [_historyProgress('the-signal-demo', 'Device history')], + account: [_historyProgress('city-lights-demo', 'Account history')], + ); + await tester.pumpWidget( + _app(catalog: catalog, favorites: favorites, history: history), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('following-card-the-signal-demo')), + findsOneWidget, + ); + final initialFavoriteCalls = favorites.pageCalls; + final initialHistoryCalls = history.recentCalls; + final auth = Provider.of( + tester.element(find.byKey(const Key('following-page'))), + listen: false, + ); + final preferences = SharedPreferencesAsync(); + + favorites.scope = 'account'; + history.scope = 'account'; + await _setSignedIn(preferences); + await auth.load(); + await tester.pumpAndSettle(); + + expect(auth.isSignedIn, isTrue); + expect(favorites.pageCalls, greaterThan(initialFavoriteCalls)); + expect(history.recentCalls, greaterThan(initialHistoryCalls)); + expect( + find.byKey(const Key('following-card-the-signal-demo')), + findsNothing, + ); + expect( + find.byKey(const Key('following-card-city-lights-demo')), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('following-tab-history'))); + await tester.pumpAndSettle(); + expect(find.text('Account history'), findsOneWidget); + expect(find.text('Device history'), findsNothing); + + favorites.scope = 'device'; + history.scope = 'device'; + await _clearSignedIn(preferences); + await auth.load(); + await tester.pumpAndSettle(); + + expect(auth.isSignedIn, isFalse); + expect(find.text('Device history'), findsOneWidget); + expect(find.text('Account history'), findsNothing); }); testWidgets('should_show_empty_favorites_and_history', (tester) async { @@ -407,6 +515,21 @@ void _signIn() { }); } +Future _setSignedIn(SharedPreferencesAsync preferences) async { + await preferences.setString('auth.accessToken', 'access'); + await preferences.setString('auth.refreshToken', 'refresh'); + await preferences.setString( + 'auth.user', + '{"uid":"9","nickname":"Viewer","avatarUrl":""}', + ); +} + +Future _clearSignedIn(SharedPreferencesAsync preferences) async { + await preferences.remove('auth.accessToken'); + await preferences.remove('auth.refreshToken'); + await preferences.remove('auth.user'); +} + Widget _app({ required FakeCatalogRepository catalog, FavoriteRepository? favorites, @@ -433,3 +556,102 @@ class _CountingCatalogRepository extends FakeCatalogRepository { return super.fetchDrama(dramaId); } } + +WatchProgress _historyProgress(String dramaId, String title) { + return WatchProgress( + dramaId: dramaId, + episodeId: '$dramaId-3', + dramaTitle: title, + episodeNumber: 3, + position: const Duration(seconds: 25), + duration: const Duration(seconds: 100), + ); +} + +class _IdentityFavoriteRepository implements FavoriteRepository { + _IdentityFavoriteRepository({ + required List device, + required List account, + }) : _cards = {'device': device, 'account': account}; + + final Map> _cards; + String scope = 'device'; + int pageCalls = 0; + + @override + Future> page({int pageIndex = 1, int pageSize = 20}) async { + pageCalls += 1; + final cards = _cards[scope]!; + final offset = (pageIndex - 1) * pageSize; + return PageResult( + total: cards.length, + list: offset >= cards.length + ? const [] + : cards.skip(offset).take(pageSize).toList(), + ); + } + + @override + Future remove(String dramaId) async { + _cards[scope]!.removeWhere((drama) => drama.id == dramaId); + } + + @override + Future removeMany(List dramaIds) async { + final ids = dramaIds.toSet(); + _cards[scope]!.removeWhere((drama) => ids.contains(drama.id)); + } + + @override + Future toggle(String dramaId) async => true; +} + +class _IdentityWatchHistoryRepository implements WatchHistoryRepository { + _IdentityWatchHistoryRepository({ + required List device, + required List account, + }) : _records = {'device': device, 'account': account}; + + final Map> _records; + String scope = 'device'; + int recentCalls = 0; + + @override + Future clear() async => _records[scope]!.clear(); + + @override + Future progressFor(String dramaId) async { + return _records[scope]! + .where((record) => record.dramaId == dramaId) + .firstOrNull; + } + + @override + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }) async { + recentCalls += 1; + final records = _records[scope]!; + final offset = (pageIndex - 1) * pageSize; + return PageResult( + total: records.length, + list: offset >= records.length + ? const [] + : records.skip(offset).take(pageSize).toList(), + ); + } + + @override + Future remove(String dramaId) async { + _records[scope]!.removeWhere((record) => record.dramaId == dramaId); + } + + @override + Future save({ + required String episodeId, + required Duration position, + Duration? duration, + bool finished = false, + }) async => null; +} From 8d511933df02348eca144cad395e7bba836b12ff Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 27 Oct 2026 10:26:11 +0800 Subject: [PATCH 17/29] =?UTF-8?q?fix(mine):=20=E6=94=AF=E6=8C=81=E6=9C=AA?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=B1=95=E7=A4=BA=E4=BA=92=E5=8A=A8=E8=AE=A1?= =?UTF-8?q?=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/mine/mine_logic.dart | 23 ++- lib/pages/mine/mine_view.dart | 76 ++++++-- test/mine/mine_logic_test.dart | 14 ++ test/mine/mine_preview_layout_test.dart | 240 +++++++++++++++++++++++- 4 files changed, 334 insertions(+), 19 deletions(-) diff --git a/lib/pages/mine/mine_logic.dart b/lib/pages/mine/mine_logic.dart index cf7f0e6..f6de748 100644 --- a/lib/pages/mine/mine_logic.dart +++ b/lib/pages/mine/mine_logic.dart @@ -18,6 +18,7 @@ class MineLogic extends ChangeNotifier { bool recentLoading = false; String? recentError; bool _disposed = false; + int _requestSeq = 0; void notify() { if (!_disposed) notifyListeners(); @@ -25,21 +26,37 @@ class MineLogic extends ChangeNotifier { /// 最近观看:取分页首屏,剧名封面仍由目录卡片按 id 映射。 Future refreshRecent() async { + final request = ++_requestSeq; recentLoading = true; recentError = null; notify(); try { final page = await history.recent(pageIndex: 1, pageSize: 10); + if (_disposed || request != _requestSeq) return; recent = page.list; watchRecordTotal = page.total; } catch (_) { - recentError = 'progressError'; + if (!_disposed && request == _requestSeq) { + recentError = 'progressError'; + } } finally { - recentLoading = false; - notify(); + if (!_disposed && request == _requestSeq) { + recentLoading = false; + notify(); + } } } + /// 切换账号/设备身份时先清空,避免旧身份数据回写或短暂混显。 + void resetRecent() { + _requestSeq += 1; + recent = const []; + watchRecordTotal = null; + recentLoading = false; + recentError = null; + notify(); + } + /// 页面重新可见时刷新最近观看,避免停留在旧进度。 void syncPage({required bool active, required bool wasActive}) { if (active && !wasActive) refreshRecent(); diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 4b74e4e..3c57d6c 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -53,25 +53,70 @@ class MyPage extends StatefulWidget { class _MyPageState extends State { late final MineLogic logic; + late final AuthController _auth; + late final FavoriteController _favorites; + late bool _wasSignedIn; + bool _activationRefreshScheduled = false; bool get embedded => widget.callbacks.embedded; @override void initState() { super.initState(); + _auth = context.read(); + _favorites = context.read(); + _wasSignedIn = _auth.isSignedIn; + _auth.addListener(_onAuthChanged); logic = MineBinding().create( history: context.read(), ); + if (widget.active) _scheduleFavoriteLoad(); } @override void didUpdateWidget(covariant MyPage oldWidget) { super.didUpdateWidget(oldWidget); - logic.syncPage(active: widget.active, wasActive: oldWidget.active); + if (widget.active && !oldWidget.active) { + _scheduleActivationRefresh(); + } + } + + void _scheduleFavoriteLoad() { + if (_activationRefreshScheduled) return; + _activationRefreshScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _activationRefreshScheduled = false; + if (mounted) unawaited(_favorites.load()); + }); + } + + void _scheduleActivationRefresh() { + if (_activationRefreshScheduled) return; + _activationRefreshScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _activationRefreshScheduled = false; + if (!mounted || !widget.active) return; + logic.syncPage(active: true, wasActive: false); + unawaited(_favorites.load()); + }); + } + + void _onAuthChanged() { + final signedIn = _auth.isSignedIn; + if (signedIn == _wasSignedIn) return; + _wasSignedIn = signedIn; + logic.resetRecent(); + _favorites.clear(); + unawaited(_reloadForIdentity()); + } + + Future _reloadForIdentity() async { + await Future.wait([logic.refreshRecent(), _favorites.load()]); } @override void dispose() { + _auth.removeListener(_onAuthChanged); logic.dispose(); super.dispose(); } @@ -141,12 +186,14 @@ class _MyPageState extends State { final wallet = context.watch(); final continueWatching = logic.recent.take(10).toList(growable: false); final followingCount = - auth.isSignedIn && - favorites.error == null && - !(favorites.loading && favorites.total == 0) + favorites.error == null && !(favorites.loading && favorites.total == 0) ? favorites.total : null; - final watchRecordCount = auth.isSignedIn ? logic.watchRecordTotal : null; + final watchRecordCount = + logic.recentError == null && + !(logic.recentLoading && logic.watchRecordTotal == null) + ? logic.watchRecordTotal + : null; final coinBalance = auth.isSignedIn && wallet.snapshot != null ? wallet.balance : null; @@ -216,7 +263,7 @@ class _MyPageState extends State { void _openDestination(MineDestination destination) { switch (destination) { case MineDestination.history: - _push(const WatchRecordsPage()); + unawaited(_openHistoryPage()); case MineDestination.downloads: _push(const DownloadsPage()); case MineDestination.wallet: @@ -244,15 +291,22 @@ class _MyPageState extends State { callback(); return; } - _push(const WatchRecordsPage()); + unawaited(_openHistoryPage()); } - void _push(Widget page) { - Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); + Future _openHistoryPage() async { + await _push(const WatchRecordsPage()); + if (mounted) unawaited(logic.refreshRecent()); + } + + Future _push(Widget page) { + return Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => page)); } void _pushPlaceholder() { - _push(const _MinePlaceholderPage()); + unawaited(_push(const _MinePlaceholderPage())); } Future _openProtected(Widget page) async { @@ -260,7 +314,7 @@ class _MyPageState extends State { await ensureSignedIn(context); return; } - if (mounted) _push(page); + if (mounted) await _push(page); } void _openVip() { diff --git a/test/mine/mine_logic_test.dart b/test/mine/mine_logic_test.dart index 536fc1a..abf320b 100644 --- a/test/mine/mine_logic_test.dart +++ b/test/mine/mine_logic_test.dart @@ -60,6 +60,20 @@ void main() { logic.dispose(); }); + test('should_clear_recent_when_identity_is_reset', () async { + final history = FakeWatchHistory()..recentRecords = const [_record]; + final logic = MineLogic(history: history); + await pumpEventQueue(); + + logic.resetRecent(); + + expect(logic.recent, isEmpty); + expect(logic.watchRecordTotal, isNull); + expect(logic.recentLoading, isFalse); + expect(logic.recentError, isNull); + logic.dispose(); + }); + testWidgets('should_forward_action_when_embedded_or_pop_when_standalone', ( tester, ) async { diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index d746bfe..2c9cfcc 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -3,12 +3,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/page_result.dart'; import 'package:fruitshorts/core/data/models/wallet.dart'; import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/repositories/commerce_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; @@ -18,6 +21,8 @@ import 'package:fruitshorts/pages/mine/about_page.dart'; import 'package:fruitshorts/pages/mine/mine_types.dart'; import 'package:fruitshorts/pages/mine/widgets/mine_menu.dart'; import 'package:fruitshorts/pages/store/vip_page.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; @@ -67,7 +72,9 @@ void main() { expect(find.byKey(const Key('mine-vip-open')), findsOneWidget); expect(find.byKey(const Key('mine-continue-watching')), findsNothing); expect(find.byKey(const Key('mine-stats')), findsOneWidget); - expect(find.text('—'), findsNWidgets(3)); + expect(_statValue(tester, const Key('mine-stat-following')), '3'); + expect(_statValue(tester, const Key('mine-stat-watch-records')), '0'); + expect(_statValue(tester, const Key('mine-stat-coins')), '—'); expect(find.byKey(const Key('mine-menu')), findsOneWidget); expect(find.byKey(const Key('mine-more-services')), findsOneWidget); expect(find.byKey(const Key('mine-mini-player')), findsNothing); @@ -242,6 +249,99 @@ void main() { expect(find.byType(AuthPage), findsOneWidget); }); + testWidgets('should_show_device_counts_when_signed_out', (tester) async { + await _pumpApp( + tester, + signedIn: false, + history: InMemoryWatchHistoryRepository(seed: [_recent]), + ); + await _openMine(tester); + + expect(_statValue(tester, const Key('mine-stat-following')), '3'); + expect(_statValue(tester, const Key('mine-stat-watch-records')), '1'); + expect(_statValue(tester, const Key('mine-stat-coins')), '—'); + expect(find.byKey(const Key('mine-continue-watching')), findsOneWidget); + }); + + testWidgets('should_switch_mine_counts_when_login_identity_changes', ( + tester, + ) async { + final catalog = FakeCatalogRepository(); + final favorites = _IdentityMineFavoriteRepository( + device: catalog.dramas.take(2).toList(), + account: [catalog.dramas[2]], + ); + final history = _IdentityMineWatchHistoryRepository( + device: [_recent, _secondRecent], + account: [_accountRecent], + ); + await _pumpApp( + tester, + signedIn: false, + favorite: favorites, + history: history, + ); + await _openMine(tester); + + expect(_statValue(tester, const Key('mine-stat-following')), '2'); + expect(_statValue(tester, const Key('mine-stat-watch-records')), '2'); + expect(_statValue(tester, const Key('mine-stat-coins')), '—'); + + final auth = Provider.of( + tester.element(find.byKey(const Key('mine-page-surface'))), + listen: false, + ); + final preferences = SharedPreferencesAsync(); + favorites.scope = 'account'; + history.scope = 'account'; + await _setSignedIn(preferences); + await auth.load(); + await tester.pumpAndSettle(); + + expect(auth.isSignedIn, isTrue); + expect(_statValue(tester, const Key('mine-stat-following')), '1'); + expect(_statValue(tester, const Key('mine-stat-watch-records')), '1'); + expect(_statValue(tester, const Key('mine-stat-coins')), '320'); + expect(find.text('Account history'), findsOneWidget); + expect(find.text('Device history'), findsNothing); + + favorites.scope = 'device'; + history.scope = 'device'; + await _clearSignedIn(preferences); + await auth.load(); + await tester.pumpAndSettle(); + + expect(auth.isSignedIn, isFalse); + expect(_statValue(tester, const Key('mine-stat-following')), '2'); + expect(_statValue(tester, const Key('mine-stat-watch-records')), '2'); + expect(_statValue(tester, const Key('mine-stat-coins')), '—'); + expect(find.text('Device history'), findsOneWidget); + expect(find.text('Account history'), findsNothing); + }); + + testWidgets('should_refresh_watch_count_after_history_is_cleared', ( + tester, + ) async { + final history = InMemoryWatchHistoryRepository(seed: [_recent]); + await _pumpApp(tester, signedIn: false, history: history); + await _openMine(tester); + + expect(_statValue(tester, const Key('mine-stat-watch-records')), '1'); + + await tester.tap(find.byKey(const Key('mine-history'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('watch-records-clear'))); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Clear')); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('watch-records-empty')), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(_statValue(tester, const Key('mine-stat-watch-records')), '0'); + }); + testWidgets('should_render_signed_in_user_without_floating_mini_player', ( tester, ) async { @@ -414,6 +514,34 @@ const _recent = WatchProgress( duration: Duration(minutes: 2), ); +const _secondRecent = WatchProgress( + dramaId: 'vengeance-demo', + episodeId: 'vengeance-demo-1', + dramaTitle: 'Device history', + episodeNumber: 1, + totalEpisodes: 6, + position: Duration(seconds: 15), + duration: Duration(minutes: 2), +); + +const _accountRecent = WatchProgress( + dramaId: 'the-signal-demo', + episodeId: 'the-signal-demo-2', + dramaTitle: 'Account history', + episodeNumber: 2, + totalEpisodes: 7, + position: Duration(seconds: 20), + duration: Duration(minutes: 2), +); + +String _statValue(WidgetTester tester, Key key) { + return tester + .widget( + find.descendant(of: find.byKey(key), matching: find.byType(Text)).first, + ) + .data!; +} + Future _openMine(WidgetTester tester) async { await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); @@ -423,6 +551,7 @@ Future _pumpApp( WidgetTester tester, { required bool signedIn, WatchHistoryRepository? history, + FavoriteRepository? favorite, CommerceRepository? commerce, }) async { tester.view.physicalSize = const Size(390, 1000); @@ -443,10 +572,12 @@ Future _pumpApp( catalogRepository: catalog, categoryRepository: FakeCategoryRepository(catalog: catalog), watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), - favoriteRepository: InMemoryFavoriteRepository( - saved: catalog.dramas.map((drama) => drama.id).toSet(), - cards: catalog.dramas, - ), + favoriteRepository: + favorite ?? + InMemoryFavoriteRepository( + saved: catalog.dramas.map((drama) => drama.id).toSet(), + cards: catalog.dramas, + ), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), @@ -457,6 +588,105 @@ Future _pumpApp( await tester.pumpAndSettle(); } +Future _setSignedIn(SharedPreferencesAsync preferences) async { + await preferences.setString('auth.accessToken', 'access'); + await preferences.setString('auth.refreshToken', 'refresh'); + await preferences.setString( + 'auth.user', + '{"uid":"U-100","nickname":"Viewer","avatarUrl":""}', + ); +} + +Future _clearSignedIn(SharedPreferencesAsync preferences) async { + await preferences.remove('auth.accessToken'); + await preferences.remove('auth.refreshToken'); + await preferences.remove('auth.user'); +} + +class _IdentityMineFavoriteRepository implements FavoriteRepository { + _IdentityMineFavoriteRepository({ + required List device, + required List account, + }) : _cards = {'device': device, 'account': account}; + + final Map> _cards; + String scope = 'device'; + + @override + Future> page({int pageIndex = 1, int pageSize = 20}) async { + final cards = _cards[scope]!; + final offset = (pageIndex - 1) * pageSize; + return PageResult( + total: cards.length, + list: offset >= cards.length + ? const [] + : cards.skip(offset).take(pageSize).toList(), + ); + } + + @override + Future remove(String dramaId) async { + _cards[scope]!.removeWhere((drama) => drama.id == dramaId); + } + + @override + Future removeMany(List dramaIds) async { + final ids = dramaIds.toSet(); + _cards[scope]!.removeWhere((drama) => ids.contains(drama.id)); + } + + @override + Future toggle(String dramaId) async => true; +} + +class _IdentityMineWatchHistoryRepository implements WatchHistoryRepository { + _IdentityMineWatchHistoryRepository({ + required List device, + required List account, + }) : _records = {'device': device, 'account': account}; + + final Map> _records; + String scope = 'device'; + + @override + Future clear() async => _records[scope]!.clear(); + + @override + Future progressFor(String dramaId) async { + return _records[scope]! + .where((record) => record.dramaId == dramaId) + .firstOrNull; + } + + @override + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }) async { + final records = _records[scope]!; + final offset = (pageIndex - 1) * pageSize; + return PageResult( + total: records.length, + list: offset >= records.length + ? const [] + : records.skip(offset).take(pageSize).toList(), + ); + } + + @override + Future remove(String dramaId) async { + _records[scope]!.removeWhere((record) => record.dramaId == dramaId); + } + + @override + Future save({ + required String episodeId, + required Duration position, + Duration? duration, + bool finished = false, + }) async => null; +} + class _MineWalletCommerceRepository implements CommerceRepository { const _MineWalletCommerceRepository(); From 72dcb92acad2d72796d8c3afa4b3c2187efa291c Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 27 Oct 2026 10:34:07 +0800 Subject: [PATCH 18/29] =?UTF-8?q?fix(following):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E8=BF=BD=E5=89=A7=E7=A1=AE=E8=AE=A4=E5=BC=B9?= =?UTF-8?q?=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/following/following_edit_bar.dart | 20 +++++++++++++++++ test/following/following_page_test.dart | 25 +++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/lib/pages/following/following_edit_bar.dart b/lib/pages/following/following_edit_bar.dart index e45dacd..54c0643 100644 --- a/lib/pages/following/following_edit_bar.dart +++ b/lib/pages/following/following_edit_bar.dart @@ -27,6 +27,7 @@ const double _barButtonHeight = 30; const double _removeDialogButtonHeight = 32; const double _removeDialogRadius = 6; const double _removeDialogBottomInset = 8; +const double _cancelFollowingDialogPadding = 20; /// 播放历史移除确认。只把移除按钮压矮。 Future confirmHistoryRemove(BuildContext context) async { @@ -71,13 +72,32 @@ Future confirmCancelFollowing(BuildContext context) async { return await showDialog( context: context, builder: (dialogContext) => AlertDialog( + contentPadding: const EdgeInsets.all(_cancelFollowingDialogPadding), + actionsPadding: const EdgeInsets.fromLTRB( + _cancelFollowingDialogPadding, + 0, + _cancelFollowingDialogPadding, + _cancelFollowingDialogPadding, + ), content: Text(l10n.confirmCancelFollowing), actions: [ TextButton( + style: TextButton.styleFrom( + visualDensity: VisualDensity.standard, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(0, _removeDialogButtonHeight), + padding: const EdgeInsets.symmetric(horizontal: 12), + ), onPressed: () => Navigator.pop(dialogContext, false), child: Text(l10n.cancel), ), FilledButton( + style: FilledButton.styleFrom( + visualDensity: VisualDensity.standard, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(0, _removeDialogButtonHeight), + padding: const EdgeInsets.symmetric(horizontal: 12), + ), onPressed: () => Navigator.pop(dialogContext, true), child: Text(l10n.cancelFollowing), ), diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart index 5e57ce3..12b4b85 100644 --- a/test/following/following_page_test.dart +++ b/test/following/following_page_test.dart @@ -143,6 +143,31 @@ void main() { await tester.tap(find.byKey(const Key('following-cancel-follow'))); await tester.pumpAndSettle(); expect(find.text('Unfollow the selected dramas?'), findsOneWidget); + final dialog = tester.widget(find.byType(AlertDialog)); + expect(dialog.contentPadding, const EdgeInsets.all(20)); + expect(dialog.actionsPadding, const EdgeInsets.fromLTRB(20, 0, 20, 20)); + expect( + tester + .getSize( + find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(TextButton), + ), + ) + .height, + 32, + ); + expect( + tester + .getSize( + find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(FilledButton), + ), + ) + .height, + 32, + ); await tester.tap( find.descendant( of: find.byType(AlertDialog), From 00405d2e3b8b987f4e399f6b0df416e7f2eedc0d Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 27 Oct 2026 10:49:30 +0800 Subject: [PATCH 19/29] =?UTF-8?q?fix(ui):=20=E6=94=B6=E6=95=9B=E9=AA=A8?= =?UTF-8?q?=E6=9E=B6=E5=B1=8F=E4=BA=AE=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_colors.dart | 2 +- lib/core/widgets/drama_grid_skeleton.dart | 2 +- lib/pages/drama_detail/widgets/drama_episode_states.dart | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index f06cda6..b94958b 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -78,7 +78,7 @@ abstract final class AppColors { /// 中性深灰卡片与控件表面。 static const contentCard = Color(0xFF16161A); static const contentSurface = Color(0xFF1C1C1E); - static const skeletonSurface = Color(0xFF141414); + static const skeletonSurface = Color(0xFF0E0E0E); static const contentChip = Color(0xFF2C2C2E); static const contentBorder = Color(0x1AFFFFFF); static const filterChipSelected = Color(0xFF21090E); diff --git a/lib/core/widgets/drama_grid_skeleton.dart b/lib/core/widgets/drama_grid_skeleton.dart index 96c4dee..187dfe6 100644 --- a/lib/core/widgets/drama_grid_skeleton.dart +++ b/lib/core/widgets/drama_grid_skeleton.dart @@ -11,7 +11,7 @@ const Duration _shimmerPeriod = Duration(milliseconds: 1400); const double _shimmerTravel = 2.4; const double _shimmerStart = -1.2; const double _subtitleWidthFactor = 0.62; -const Color _shimmerHighlight = Color(0x38FFFFFF); +const Color _shimmerHighlight = Color(0x12FFFFFF); /// 首页短剧网格的占位。行数至少铺满可视高度,并带一道扫光。 class DramaGridSkeleton extends StatefulWidget { diff --git a/lib/pages/drama_detail/widgets/drama_episode_states.dart b/lib/pages/drama_detail/widgets/drama_episode_states.dart index 39db405..0e85e28 100644 --- a/lib/pages/drama_detail/widgets/drama_episode_states.dart +++ b/lib/pages/drama_detail/widgets/drama_episode_states.dart @@ -13,7 +13,7 @@ const double _gridSpacing = 8; const double _radius = 10; const double _horizontal = 14; const double _sectionTop = 16; -const double _minOpacity = 0.45; +const double _minOpacity = 0.65; const double _maxOpacity = 0.8; const Duration _breathDuration = Duration(milliseconds: 900); @@ -94,7 +94,7 @@ class _DramaEpisodesLoadingState extends State opacity: _opacity, child: Container( decoration: BoxDecoration( - color: AppColors.raised, + color: AppColors.skeletonSurface, borderRadius: BorderRadius.circular(_radius), ), ), From a5dbae7fffaa13b80290638eff5540004884284d Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 27 Oct 2026 11:08:43 +0800 Subject: [PATCH 20/29] =?UTF-8?q?fix(ui):=20=E9=AA=A8=E6=9E=B6=E5=B1=8F?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E9=80=90=E5=8D=A1=E7=89=87=E6=89=AB=E5=85=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/drama_grid_skeleton.dart | 82 +++++++++++---------- test/discover/drama_grid_skeleton_test.dart | 47 ++++++++++++ 2 files changed, 91 insertions(+), 38 deletions(-) create mode 100644 test/discover/drama_grid_skeleton_test.dart diff --git a/lib/core/widgets/drama_grid_skeleton.dart b/lib/core/widgets/drama_grid_skeleton.dart index 187dfe6..490bfda 100644 --- a/lib/core/widgets/drama_grid_skeleton.dart +++ b/lib/core/widgets/drama_grid_skeleton.dart @@ -11,6 +11,7 @@ const Duration _shimmerPeriod = Duration(milliseconds: 1400); const double _shimmerTravel = 2.4; const double _shimmerStart = -1.2; const double _subtitleWidthFactor = 0.62; +const double _shimmerPhaseStep = 0.13; const Color _shimmerHighlight = Color(0x12FFFFFF); /// 首页短剧网格的占位。行数至少铺满可视高度,并带一道扫光。 @@ -53,21 +54,19 @@ class _DramaGridSkeletonState extends State Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { - final grid = _grid(constraints.maxWidth, constraints.maxHeight); return AnimatedBuilder( animation: _shimmer, - builder: (context, child) => ShaderMask( - blendMode: BlendMode.srcATop, - shaderCallback: (bounds) => _shader(bounds), - child: child, + builder: (context, _) => _grid( + constraints.maxWidth, + constraints.maxHeight, + _shimmer.value, ), - child: grid, ); }, ); } - Widget _grid(double width, double height) { + Widget _grid(double width, double height, double shimmerValue) { final itemWidth = _itemWidth(width); final itemExtent = _itemExtent(itemWidth); return GridView.builder( @@ -85,7 +84,8 @@ class _DramaGridSkeletonState extends State mainAxisExtent: itemExtent, ), itemCount: _fillCount(height, itemExtent), - itemBuilder: (context, index) => _SkeletonCard(index: index), + itemBuilder: (context, index) => + _SkeletonCard(index: index, shimmerValue: shimmerValue), ); } @@ -105,48 +105,54 @@ class _DramaGridSkeletonState extends State final rows = (height / rowHeight).ceil() + 1; return math.max(widget.itemCount, rows * widget.columns); } - - Shader _shader(Rect bounds) { - final shift = _shimmerStart + _shimmerTravel * _shimmer.value; - return LinearGradient( - begin: Alignment(shift, -0.3), - end: Alignment(shift + 1, 0.3), - colors: const [ - AppColors.skeletonSurface, - _shimmerHighlight, - AppColors.skeletonSurface, - ], - ).createShader(bounds); - } } class _SkeletonCard extends StatelessWidget { - const _SkeletonCard({required this.index}); + const _SkeletonCard({required this.index, required this.shimmerValue}); final int index; + final double shimmerValue; @override Widget build(BuildContext context) { - return Column( - key: Key('drama-grid-skeleton-$index'), - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AspectRatio( - aspectRatio: AppDimens.cardCoverAspectRatio, - child: const _SkeletonBlock(), - ), - const SizedBox(height: AppDimens.cardCoverTitleGap), - const _SkeletonBar(), - const SizedBox(height: AppDimens.skeletonTitleBarGap), - const FractionallySizedBox( - widthFactor: _subtitleWidthFactor, - child: _SkeletonBar(), - ), - ], + final phase = (shimmerValue + index * _shimmerPhaseStep) % 1.0; + return ShaderMask( + blendMode: BlendMode.srcATop, + shaderCallback: (bounds) => _cardShader(bounds, phase), + child: Column( + key: Key('drama-grid-skeleton-$index'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: AppDimens.cardCoverAspectRatio, + child: const _SkeletonBlock(), + ), + const SizedBox(height: AppDimens.cardCoverTitleGap), + const _SkeletonBar(), + const SizedBox(height: AppDimens.skeletonTitleBarGap), + const FractionallySizedBox( + widthFactor: _subtitleWidthFactor, + child: _SkeletonBar(), + ), + ], + ), ); } } +Shader _cardShader(Rect bounds, double shimmerValue) { + final shift = _shimmerStart + _shimmerTravel * shimmerValue; + return LinearGradient( + begin: Alignment(shift, -0.3), + end: Alignment(shift + 1, 0.3), + colors: const [ + AppColors.skeletonSurface, + _shimmerHighlight, + AppColors.skeletonSurface, + ], + ).createShader(bounds); +} + class _SkeletonBlock extends StatelessWidget { const _SkeletonBlock(); diff --git a/test/discover/drama_grid_skeleton_test.dart b/test/discover/drama_grid_skeleton_test.dart new file mode 100644 index 0000000..17256c6 --- /dev/null +++ b/test/discover/drama_grid_skeleton_test.dart @@ -0,0 +1,47 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/widgets/drama_grid_skeleton.dart'; + +void main() { + testWidgets('renders one local shimmer mask per skeleton card', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: DramaGridSkeleton( + columns: 3, + horizontalPadding: 14, + crossAxisSpacing: 8, + mainAxisSpacing: 12, + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(GridView), findsOneWidget); + expect( + find.ancestor( + of: find.byType(GridView), + matching: find.byType(ShaderMask), + ), + findsNothing, + ); + for (var index = 0; index < 9; index++) { + final card = find.byKey(Key('drama-grid-skeleton-$index')); + expect(card, findsOneWidget); + expect( + find.ancestor(of: card, matching: find.byType(ShaderMask)), + findsOneWidget, + ); + } + }); +} From bd826a9236c6ba8a7c7431412ed49099decbc532 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 27 Oct 2026 11:12:31 +0800 Subject: [PATCH 21/29] =?UTF-8?q?fix(ui):=20=E9=AA=A8=E6=9E=B6=E5=B1=8F?= =?UTF-8?q?=E6=89=AB=E5=85=89=E6=94=B6=E7=AA=84=E5=8E=8B=E6=B7=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/drama_grid_skeleton.dart | 61 ++++++++++++--------- test/discover/drama_grid_skeleton_test.dart | 32 ++++++++--- 2 files changed, 57 insertions(+), 36 deletions(-) diff --git a/lib/core/widgets/drama_grid_skeleton.dart b/lib/core/widgets/drama_grid_skeleton.dart index 490bfda..a2deaf6 100644 --- a/lib/core/widgets/drama_grid_skeleton.dart +++ b/lib/core/widgets/drama_grid_skeleton.dart @@ -12,7 +12,7 @@ const double _shimmerTravel = 2.4; const double _shimmerStart = -1.2; const double _subtitleWidthFactor = 0.62; const double _shimmerPhaseStep = 0.13; -const Color _shimmerHighlight = Color(0x12FFFFFF); +const Color _shimmerHighlight = Color(0x0DFFFFFF); /// 首页短剧网格的占位。行数至少铺满可视高度,并带一道扫光。 class DramaGridSkeleton extends StatefulWidget { @@ -116,41 +116,48 @@ class _SkeletonCard extends StatelessWidget { @override Widget build(BuildContext context) { final phase = (shimmerValue + index * _shimmerPhaseStep) % 1.0; - return ShaderMask( - blendMode: BlendMode.srcATop, - shaderCallback: (bounds) => _cardShader(bounds, phase), - child: Column( - key: Key('drama-grid-skeleton-$index'), - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AspectRatio( - aspectRatio: AppDimens.cardCoverAspectRatio, - child: const _SkeletonBlock(), + return Stack( + key: Key('drama-grid-skeleton-$index'), + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: AppDimens.cardCoverAspectRatio, + child: const _SkeletonBlock(), + ), + const SizedBox(height: AppDimens.cardCoverTitleGap), + const _SkeletonBar(), + const SizedBox(height: AppDimens.skeletonTitleBarGap), + const FractionallySizedBox( + widthFactor: _subtitleWidthFactor, + child: _SkeletonBar(), + ), + ], + ), + Positioned.fill( + child: IgnorePointer( + child: ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + child: DecoratedBox( + decoration: BoxDecoration(gradient: _cardHighlight(phase)), + ), + ), ), - const SizedBox(height: AppDimens.cardCoverTitleGap), - const _SkeletonBar(), - const SizedBox(height: AppDimens.skeletonTitleBarGap), - const FractionallySizedBox( - widthFactor: _subtitleWidthFactor, - child: _SkeletonBar(), - ), - ], - ), + ), + ], ); } } -Shader _cardShader(Rect bounds, double shimmerValue) { +LinearGradient _cardHighlight(double shimmerValue) { final shift = _shimmerStart + _shimmerTravel * shimmerValue; return LinearGradient( begin: Alignment(shift, -0.3), end: Alignment(shift + 1, 0.3), - colors: const [ - AppColors.skeletonSurface, - _shimmerHighlight, - AppColors.skeletonSurface, - ], - ).createShader(bounds); + colors: const [Colors.transparent, _shimmerHighlight, Colors.transparent], + stops: const [0.42, 0.5, 0.58], + ); } class _SkeletonBlock extends StatelessWidget { diff --git a/test/discover/drama_grid_skeleton_test.dart b/test/discover/drama_grid_skeleton_test.dart index 17256c6..5b0431f 100644 --- a/test/discover/drama_grid_skeleton_test.dart +++ b/test/discover/drama_grid_skeleton_test.dart @@ -5,7 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/widgets/drama_grid_skeleton.dart'; void main() { - testWidgets('renders one local shimmer mask per skeleton card', ( + testWidgets('renders one narrow highlight overlay per skeleton card', ( tester, ) async { tester.view.physicalSize = const Size(390, 1000); @@ -28,20 +28,34 @@ void main() { await tester.pump(const Duration(milliseconds: 100)); expect(find.byType(GridView), findsOneWidget); - expect( - find.ancestor( - of: find.byType(GridView), - matching: find.byType(ShaderMask), - ), - findsNothing, - ); + expect(find.byType(ShaderMask), findsNothing); for (var index = 0; index < 9; index++) { final card = find.byKey(Key('drama-grid-skeleton-$index')); expect(card, findsOneWidget); expect( - find.ancestor(of: card, matching: find.byType(ShaderMask)), + find.descendant(of: card, matching: find.byType(IgnorePointer)), findsOneWidget, ); + final overlay = find.descendant( + of: card, + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).gradient != null, + ), + ); + expect(overlay, findsOneWidget); + final decoration = + tester.widget(overlay).decoration as BoxDecoration; + final gradient = decoration.gradient! as LinearGradient; + expect(gradient.colors, const [ + Colors.transparent, + Color(0x0DFFFFFF), + Colors.transparent, + ]); + expect(gradient.stops, const [0.42, 0.5, 0.58]); + expect(gradient.stops![2] - gradient.stops![0], lessThanOrEqualTo(0.25)); } }); } From 9117bf58b8217ede69f29f245d13dfcd730f9020 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 27 Oct 2026 11:16:24 +0800 Subject: [PATCH 22/29] =?UTF-8?q?fix(ui):=20=E6=8B=86=E5=88=86=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E9=AA=A8=E6=9E=B6=E5=BA=95=E8=89=B2=EF=BC=8C=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=8F=AF=E8=A7=81=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_colors.dart | 5 +++++ lib/pages/drama_detail/widgets/drama_episode_states.dart | 4 ++-- lib/pages/recommend/recommend_page.dart | 4 ++-- test/theme/app_theme_test.dart | 2 ++ 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index b94958b..b6e52fa 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -78,7 +78,12 @@ abstract final class AppColors { /// 中性深灰卡片与控件表面。 static const contentCard = Color(0xFF16161A); static const contentSurface = Color(0xFF1C1C1E); + + /// 首页、分类与搜索网格骨架的极暗底色。 static const skeletonSurface = Color(0xFF0E0E0E); + + /// 播放器、推荐与详情页大面积媒体占位的深灰底色。 + static const skeletonMedia = Color(0xFF1C1C1E); static const contentChip = Color(0xFF2C2C2E); static const contentBorder = Color(0x1AFFFFFF); static const filterChipSelected = Color(0xFF21090E); diff --git a/lib/pages/drama_detail/widgets/drama_episode_states.dart b/lib/pages/drama_detail/widgets/drama_episode_states.dart index 0e85e28..6f65db3 100644 --- a/lib/pages/drama_detail/widgets/drama_episode_states.dart +++ b/lib/pages/drama_detail/widgets/drama_episode_states.dart @@ -13,7 +13,7 @@ const double _gridSpacing = 8; const double _radius = 10; const double _horizontal = 14; const double _sectionTop = 16; -const double _minOpacity = 0.65; +const double _minOpacity = 0.45; const double _maxOpacity = 0.8; const Duration _breathDuration = Duration(milliseconds: 900); @@ -94,7 +94,7 @@ class _DramaEpisodesLoadingState extends State opacity: _opacity, child: Container( decoration: BoxDecoration( - color: AppColors.skeletonSurface, + color: AppColors.skeletonMedia, borderRadius: BorderRadius.circular(_radius), ), ), diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 137be25..ed3005f 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -426,7 +426,7 @@ class _RecommendFeedSkeleton extends StatelessWidget { height: 176, child: DecoratedBox( decoration: BoxDecoration( - color: AppColors.skeletonSurface, + color: AppColors.skeletonMedia, borderRadius: BorderRadius.all(Radius.circular(12)), ), ), @@ -465,7 +465,7 @@ class _SkeletonLine extends StatelessWidget { width: width, height: height, decoration: BoxDecoration( - color: AppColors.skeletonSurface, + color: AppColors.skeletonMedia, borderRadius: BorderRadius.circular(6), ), ); diff --git a/test/theme/app_theme_test.dart b/test/theme/app_theme_test.dart index 442b452..2fea8f1 100644 --- a/test/theme/app_theme_test.dart +++ b/test/theme/app_theme_test.dart @@ -26,6 +26,8 @@ void main() { expect(AppColors.cardAlt, const Color(0xFF101010)); expect(AppColors.raised, const Color(0xFF1C1C1E)); expect(AppColors.border, const Color(0xFF1C1C1E)); + expect(AppColors.skeletonSurface, const Color(0xFF0E0E0E)); + expect(AppColors.skeletonMedia, const Color(0xFF1C1C1E)); expect(AppColors.textPrimary, const Color(0xFFFFFFFF)); expect(AppColors.textSecondary, const Color(0xFFB0B0B0)); expect(AppColors.textTertiary, const Color(0xFF8E8E93)); From d206465ee1aa44ae91496bff8ed5fefb80406beb Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 27 Oct 2026 11:24:21 +0800 Subject: [PATCH 23/29] =?UTF-8?q?fix(recommend):=20=E9=A6=96=E5=B1=8F?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=85=9C=E5=87=BA=E9=87=8D=E8=AF=95=E7=A9=BA?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/services/home_feed_controller.dart | 1 + lib/pages/recommend/recommend_page.dart | 154 ++++++++++++--- .../recommend_refresh_scheduling_test.dart | 177 ++++++++++++++++++ 3 files changed, 302 insertions(+), 30 deletions(-) diff --git a/lib/core/data/services/home_feed_controller.dart b/lib/core/data/services/home_feed_controller.dart index 7326983..7d31048 100644 --- a/lib/core/data/services/home_feed_controller.dart +++ b/lib/core/data/services/home_feed_controller.dart @@ -33,6 +33,7 @@ class HomeFeedController extends ChangeNotifier { String? get cursor => _state(_channel).cursor; bool get hasMore => _state(_channel).hasMore; bool get loaded => _state(_channel).loaded; + bool get isLoading => _state(_channel).loading; bool get loadingMore => _state(_channel).loadingMore; bool get loadingFirstPage => !loaded && _state(_channel).loading; diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index ed3005f..8508219 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -49,10 +49,16 @@ class RecommendPage extends StatefulWidget { class _RecommendPageState extends State with AutomaticKeepAliveClientMixin { + static const Duration _recommendRetryDelay = Duration(milliseconds: 1500); + static const int _maxRecommendRetries = 3; + RecommendInnerTab _tab = RecommendInnerTab.feed; final PageController _pageController = PageController(); int _scrubCancelRequest = 0; bool _recommendDataRefreshScheduled = false; + bool _recommendRefreshInFlight = false; + int _recommendRetryAttempts = 0; + Timer? _recommendRetryTimer; HomeFeedChannel? _observedFeedChannel; @override @@ -71,9 +77,13 @@ class _RecommendPageState extends State super.didUpdateWidget(oldWidget); if (oldWidget.feedController != widget.feedController) { oldWidget.feedController.removeListener(_handleFeedControllerChanged); + _cancelRecommendRetries(); _observedFeedChannel = widget.feedController.channel; widget.feedController.addListener(_handleFeedControllerChanged); } + if (oldWidget.active && !widget.active) { + _cancelRecommendRetries(); + } if (_requiresRecommendDataRefresh(oldWidget)) { _scheduleRecommendData(); } @@ -88,9 +98,32 @@ class _RecommendPageState extends State void _handleFeedControllerChanged() { final channel = widget.feedController.channel; - if (channel == _observedFeedChannel) return; - _observedFeedChannel = channel; - if (widget.active) _scheduleRecommendData(); + if (channel != _observedFeedChannel) { + _observedFeedChannel = channel; + _cancelRecommendRetries(); + if (widget.active) _scheduleRecommendData(); + return; + } + _handleRecommendFeedState(); + } + + void _handleRecommendFeedState() { + if (!mounted || !widget.active) return; + final feed = widget.feedController; + if (feed.dramas.isNotEmpty) { + _cancelRecommendRetries(); + return; + } + if (feed.loaded && + feed.lastError == null && + !feed.isLoading && + !_recommendRefreshInFlight) { + _cancelRecommendRetries(); + return; + } + if (feed.loaded && feed.lastError != null && !_recommendRefreshInFlight) { + _scheduleRecommendRetry(); + } } void _scheduleRecommendData() { @@ -100,8 +133,15 @@ class _RecommendPageState extends State _recommendDataRefreshScheduled = false; if (!mounted || !widget.active) return; final feed = widget.feedController; - if (!feed.loaded && !feed.loadingFirstPage) { - unawaited(feed.refreshCurrent()); + if (feed.dramas.isEmpty || feed.lastError != null) { + _recommendRetryAttempts = 0; + if (!feed.isLoading && !_recommendRefreshInFlight) { + if (feed.loaded) { + _scheduleRecommendRetry(); + } else { + unawaited(_refreshRecommendData()); + } + } } final playlists = _readPlaylists(); if (playlists != null && !playlists.loaded && !playlists.loading) { @@ -110,9 +150,51 @@ class _RecommendPageState extends State }); } + void _scheduleRecommendRetry() { + if (!mounted || + !widget.active || + _recommendRefreshInFlight || + _recommendRetryTimer != null || + _recommendRetryAttempts >= _maxRecommendRetries) { + return; + } + setState(() { + _recommendRetryTimer = Timer(_recommendRetryDelay, () { + _recommendRetryTimer = null; + if (!mounted || !widget.active) return; + _recommendRetryAttempts += 1; + unawaited(_refreshRecommendData()); + }); + }); + } + + Future _refreshRecommendData() async { + if (!mounted || !widget.active || _recommendRefreshInFlight) return; + setState(() => _recommendRefreshInFlight = true); + final succeeded = await widget.feedController.refreshCurrent(); + if (!mounted) return; + _recommendRefreshInFlight = false; + if (!widget.active) return; + final feed = widget.feedController; + if (succeeded || (feed.loaded && feed.lastError == null)) { + _cancelRecommendRetries(); + setState(() {}); + return; + } + setState(() {}); + _scheduleRecommendRetry(); + } + + void _cancelRecommendRetries() { + _recommendRetryTimer?.cancel(); + _recommendRetryTimer = null; + _recommendRetryAttempts = 0; + } + @override void dispose() { widget.feedController.removeListener(_handleFeedControllerChanged); + _cancelRecommendRetries(); _pageController.dispose(); super.dispose(); } @@ -199,39 +281,51 @@ class _RecommendPageState extends State } Widget _recommendFeed(BuildContext context) { + final feed = widget.feedController; final dramas = widget.feedController.dramas; - if (dramas.isEmpty) { - final l10n = AppLocalizations.of(context)!; - if (widget.feedController.loadingFirstPage) { - return const _RecommendFeedSkeleton(); - } + if (dramas.isNotEmpty) { + return PlayerPage( + key: const Key('recommend-feed-player'), + homeMode: true, + embedded: true, + active: widget.active && _tab == RecommendInnerTab.feed, + showHomeFeedHeader: false, + drama: dramas.first, + initialIndex: 0, + feedDramas: dramas, + feedChannel: widget.feedController.channel, + onOpenFullPlayer: (drama, episode) => + _openDrama(drama, episodeId: episode.id), + onRefreshFeed: widget.feedController.refreshCurrent, + onLoadMore: widget.feedController.loadMore, + onImmersiveChanged: widget.onImmersiveChanged, + scrubCancelRequest: _scrubCancelRequest, + ); + } + if (!feed.loaded || + feed.isLoading || + _recommendRefreshInFlight || + _recommendRetryTimer != null) { + return const _RecommendFeedSkeleton(); + } + final l10n = AppLocalizations.of(context)!; + if (feed.lastError != null) { return ColoredBox( color: AppColors.mediaBlack, child: EmptyState( key: const Key('recommend-feed-empty'), - icon: Icons.movie_outlined, - title: l10n.noStories, - actionLabel: l10n.retry, - onAction: () => unawaited(widget.feedController.refreshCurrent()), + icon: Icons.wifi_off_rounded, + title: l10n.networkError, ), ); } - return PlayerPage( - key: const Key('recommend-feed-player'), - homeMode: true, - embedded: true, - active: widget.active && _tab == RecommendInnerTab.feed, - showHomeFeedHeader: false, - drama: dramas.first, - initialIndex: 0, - feedDramas: dramas, - feedChannel: widget.feedController.channel, - onOpenFullPlayer: (drama, episode) => - _openDrama(drama, episodeId: episode.id), - onRefreshFeed: widget.feedController.refreshCurrent, - onLoadMore: widget.feedController.loadMore, - onImmersiveChanged: widget.onImmersiveChanged, - scrubCancelRequest: _scrubCancelRequest, + return ColoredBox( + color: AppColors.mediaBlack, + child: EmptyState( + key: const Key('recommend-feed-empty'), + icon: Icons.movie_outlined, + title: l10n.noStories, + ), ); } diff --git a/test/recommend/recommend_refresh_scheduling_test.dart b/test/recommend/recommend_refresh_scheduling_test.dart index 523adcc..cb9dbfc 100644 --- a/test/recommend/recommend_refresh_scheduling_test.dart +++ b/test/recommend/recommend_refresh_scheduling_test.dart @@ -11,6 +11,14 @@ import '../support/fake_catalog.dart'; import '../support/fake_category.dart'; void main() { + testWidgets('should_show_skeleton_before_feed_is_loaded', (tester) async { + await _pumpRecommend(tester, active: false); + + expect(find.byKey(const Key('recommend-feed-skeleton')), findsOneWidget); + expect(find.byKey(const Key('recommend-feed-empty')), findsNothing); + expect(find.text('Retry'), findsNothing); + }); + testWidgets('should_refresh_once_when_parent_rebuilds_repeatedly', ( tester, ) async { @@ -52,6 +60,64 @@ void main() { expect(fixture.controller.refreshCalls, 2); }); + testWidgets('should_auto_retry_three_times_then_show_network_error', ( + tester, + ) async { + final fixture = await _pumpFailingRecommend(tester); + + expect(fixture.controller.refreshCalls, 1); + expect(find.byKey(const Key('recommend-feed-skeleton')), findsOneWidget); + expect(find.byKey(const Key('recommend-feed-empty')), findsNothing); + + for (var attempt = 0; attempt < 3; attempt++) { + await tester.pump(const Duration(milliseconds: 1500)); + await tester.pump(); + expect(fixture.controller.refreshCalls, attempt + 2); + if (attempt < 2) { + expect( + find.byKey(const Key('recommend-feed-skeleton')), + findsOneWidget, + ); + expect(find.byKey(const Key('recommend-feed-empty')), findsNothing); + } + } + + final l10n = AppLocalizations.of( + tester.element(find.byType(RecommendPage)), + )!; + expect(fixture.controller.refreshCalls, 4); + expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing); + expect(find.byKey(const Key('recommend-feed-empty')), findsOneWidget); + expect(find.text(l10n.networkError), findsOneWidget); + expect(find.text(l10n.retry), findsNothing); + }); + + testWidgets('should_cancel_pending_auto_retry_when_disposed', (tester) async { + final fixture = await _pumpFailingRecommend(tester); + expect(fixture.controller.refreshCalls, 1); + + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + await tester.pump(const Duration(seconds: 2)); + + expect(fixture.controller.refreshCalls, 1); + }); + + testWidgets( + 'should_show_no_stories_without_retry_when_loaded_feed_is_empty', + (tester) async { + final fixture = await _pumpEmptyRecommend(tester); + + final l10n = AppLocalizations.of( + tester.element(find.byType(RecommendPage)), + )!; + expect(fixture.controller.refreshCalls, 1); + expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing); + expect(find.byKey(const Key('recommend-feed-empty')), findsOneWidget); + expect(find.text(l10n.noStories), findsOneWidget); + expect(find.text(l10n.retry), findsNothing); + }, + ); + testWidgets('should_not_refresh_after_dispose_when_channel_changes', ( tester, ) async { @@ -96,6 +162,58 @@ Future<_RecommendFixture> _pumpRecommend( return fixture; } +Future<_StatefulRecommendFixture> _pumpFailingRecommend( + WidgetTester tester, +) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final catalog = FakeCatalogRepository(); + final controller = _StatefulHomeFeedController(catalog); + final categories = CategoryController( + FakeCategoryRepository(catalog: catalog), + ); + addTearDown(controller.dispose); + addTearDown(categories.dispose); + + final fixture = _StatefulRecommendFixture( + controller: controller, + categoryController: categories, + ); + await tester.pumpWidget(fixture.app()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); + return fixture; +} + +Future<_StatefulRecommendFixture> _pumpEmptyRecommend( + WidgetTester tester, +) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final catalog = FakeCatalogRepository(); + final controller = _StatefulHomeFeedController(catalog, fail: false); + final categories = CategoryController( + FakeCategoryRepository(catalog: catalog), + ); + addTearDown(controller.dispose); + addTearDown(categories.dispose); + + final fixture = _StatefulRecommendFixture( + controller: controller, + categoryController: categories, + ); + await tester.pumpWidget(fixture.app()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); + return fixture; +} + class _RecommendFixture { const _RecommendFixture({ required this.controller, @@ -123,6 +241,32 @@ class _RecommendFixture { } } +class _StatefulRecommendFixture { + const _StatefulRecommendFixture({ + required this.controller, + required this.categoryController, + }); + + final _StatefulHomeFeedController controller; + final CategoryController categoryController; + + Widget app() { + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ChangeNotifierProvider.value( + value: categoryController, + child: RecommendPage( + feedController: controller, + active: true, + videoOnly: true, + onSearch: () {}, + ), + ), + ); + } +} + class _RecommendHarness extends StatefulWidget { const _RecommendHarness({ super.key, @@ -181,3 +325,36 @@ class _RecordingHomeFeedController extends HomeFeedController { return true; } } + +class _StatefulHomeFeedController extends HomeFeedController { + _StatefulHomeFeedController(super.repository, {this.fail = true}); + + final bool fail; + int refreshCalls = 0; + bool _loaded = false; + bool _isLoading = false; + String? _lastError; + + @override + bool get loaded => _loaded; + + @override + bool get isLoading => _isLoading; + + @override + String? get lastError => _lastError; + + @override + Future refreshCurrent() async { + refreshCalls++; + _isLoading = true; + _lastError = null; + notifyListeners(); + await Future.delayed(Duration.zero); + _isLoading = false; + _loaded = true; + if (fail) _lastError = 'network error'; + notifyListeners(); + return !fail; + } +} From e028ca40b19212e0da163677d7f992855ed48539 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Tue, 27 Oct 2026 13:35:41 +0800 Subject: [PATCH 24/29] =?UTF-8?q?fix(ui):=20=E7=BB=9F=E4=B8=80=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E9=A1=B5=E5=8F=B3=E4=BE=A7=E6=93=8D=E4=BD=9C=E6=A0=8F?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F=E4=B8=BA=E6=94=B6=E8=97=8F-=E7=82=B9?= =?UTF-8?q?=E8=B5=9E-=E8=AF=84=E8=AE=BA-=E5=88=86=E4=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与推荐页操作栏保持一致,同步更新布局断言。 --- lib/pages/player/widgets/player_overlay.dart | 16 ++++++++-------- test/player/player_fullscreen_test.dart | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 561f486..a89f284 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -178,14 +178,6 @@ class PlayerPlaybackOverlay extends StatelessWidget { selected: data.saved, onTap: actions.onSave, ), - InteractionRailItem( - key: const Key('player-rail-comments'), - asset: 'assets/icon/rail/rail_comment.png', - label: data.l10n.comments, - count: data.commentCount, - countKey: const Key('player-rail-comments-count'), - onTap: actions.onComments, - ), InteractionRailItem( key: const Key('player-rail-like'), asset: 'assets/icon/rail/rail_like.png', @@ -196,6 +188,14 @@ class PlayerPlaybackOverlay extends StatelessWidget { selectedColor: AppColors.doubleTapHeartRed, onTap: actions.onLike, ), + InteractionRailItem( + key: const Key('player-rail-comments'), + asset: 'assets/icon/rail/rail_comment.png', + label: data.l10n.comments, + count: data.commentCount, + countKey: const Key('player-rail-comments-count'), + onTap: actions.onComments, + ), InteractionRailItem( key: const Key('player-rail-share'), asset: 'assets/icon/rail/rail_share.png', diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index dbf9662..1b73b56 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -1359,9 +1359,9 @@ void main() { final shareRect = tester.getRect( find.byKey(const Key('player-rail-share')), ); - expect(commentsRect.top - saveRect.bottom, closeTo(24, 0.1)); - expect(likeRect.top - commentsRect.bottom, closeTo(24, 0.1)); - expect(shareRect.top - likeRect.bottom, closeTo(24, 0.1)); + expect(likeRect.top - saveRect.bottom, closeTo(24, 0.1)); + expect(commentsRect.top - likeRect.bottom, closeTo(24, 0.1)); + expect(shareRect.top - commentsRect.bottom, closeTo(24, 0.1)); final saveButton = tester.widget( find.descendant( From d6df5fbe5fc1aba5beaa04d6e0b52019898376ee Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Tue, 27 Oct 2026 13:54:28 +0800 Subject: [PATCH 25/29] =?UTF-8?q?feat(core):=20=E8=AF=B7=E6=B1=82=E5=A4=B4?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=B8=8A=E6=8A=A5=E8=AE=BE=E5=A4=87=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E4=B8=8E=E6=9C=BA=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 DeviceProfileReader,首次读取后缓存,插件不可用时安全降级 - 拦截器为所有请求补 X-Client-Platform / Os / Model,匿名请求同样携带 --- lib/app.dart | 5 ++ lib/core/data/remote/api_client.dart | 6 ++ lib/core/data/remote/api_interceptors.dart | 14 ++++- lib/core/data/remote/device_profile.dart | 69 +++++++++++++++++++++ pubspec.lock | 72 ++++++++++++++++------ pubspec.yaml | 3 + 6 files changed, 148 insertions(+), 21 deletions(-) create mode 100644 lib/core/data/remote/device_profile.dart diff --git a/lib/app.dart b/lib/app.dart index c47a354..e7e2503 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -14,6 +14,7 @@ import 'core/data/remote/catalog_api.dart'; import 'core/data/remote/category_api.dart'; import 'core/data/remote/comment_api.dart'; import 'core/data/remote/commerce_api.dart'; +import 'core/data/remote/device_profile.dart'; import 'core/data/remote/download_api.dart'; import 'core/data/remote/interaction_api.dart'; import 'core/data/remote/library_api.dart'; @@ -116,6 +117,9 @@ class LumaDramaApp extends StatefulWidget { class _LumaDramaAppState extends State { late final AuthSessionStore _sessionStore = AuthSessionStore(); + /// 设备画像只读一次:供拦截器在每次请求头里带上系统与机型。 + late final DeviceProfileReader _deviceProfile = DeviceProfileReader(); + /// 全 App 共用一个 ApiClient:这样目录、账号、后续的互动/进度请求 /// 都自动带 `Authorization` 与 `X-Device-Id`,401 的刷新重试也只有一处实现。 late final ApiClient _apiClient = ApiClient( @@ -123,6 +127,7 @@ class _LumaDramaAppState extends State { tokenRefresher: () => _auth.refresh(), onUnauthorized: () => _auth.clear(), deviceIdReader: _sessionStore.deviceId, + deviceProfileReader: _deviceProfile.read, ); late final AuthController _auth = AuthController( diff --git a/lib/core/data/remote/api_client.dart b/lib/core/data/remote/api_client.dart index e37543e..0c04f1b 100644 --- a/lib/core/data/remote/api_client.dart +++ b/lib/core/data/remote/api_client.dart @@ -5,6 +5,7 @@ import 'package:dio/dio.dart'; import '../../config/app_config.dart'; import 'api_exception.dart'; import 'api_interceptors.dart'; +import 'device_profile.dart'; import 'network_log_interceptor.dart'; /// `ApiException` 从这里也能拿到:历史调用方一律 `import 'api_client.dart'`,不必改 import。 @@ -24,6 +25,7 @@ class ApiClient { this.tokenRefresher, this.onUnauthorized, this.deviceIdReader, + this.deviceProfileReader, }) : _fixedBaseUrl = baseUrl { _dio = Dio( BaseOptions( @@ -40,6 +42,7 @@ class ApiClient { AuthHeaderInterceptor( accessTokenReader: accessTokenReader, deviceIdReader: deviceIdReader, + deviceProfileReader: deviceProfileReader, ), const EnvelopeInterceptor(), NetworkLogInterceptor(), @@ -67,6 +70,9 @@ class ApiClient { /// 设备号(`X-Device-Id`):注册风控、后续下载绑定都要用 final Future Function()? deviceIdReader; + /// 设备画像(系统/机型版本):后台列表展示客户端系统用 + final Future Function()? deviceProfileReader; + Future get(String path, {Map? query}) { return _request('GET', path, query: query); } diff --git a/lib/core/data/remote/api_interceptors.dart b/lib/core/data/remote/api_interceptors.dart index cee6819..5962190 100644 --- a/lib/core/data/remote/api_interceptors.dart +++ b/lib/core/data/remote/api_interceptors.dart @@ -5,6 +5,7 @@ import 'dart:convert'; import 'package:dio/dio.dart'; import 'api_exception.dart'; +import 'device_profile.dart'; import 'network_log_interceptor.dart'; /// 请求级开关:放在 `RequestOptions.extra` 里,由 `ApiClient` 写入、拦截器读取。 @@ -32,7 +33,11 @@ class TokenExpiredException implements Exception { /// /// 每个请求都重新取一次令牌,所以刷新成功后重发的那次会自动带上新令牌。 class AuthHeaderInterceptor extends Interceptor { - AuthHeaderInterceptor({this.accessTokenReader, this.deviceIdReader}); + AuthHeaderInterceptor({ + this.accessTokenReader, + this.deviceIdReader, + this.deviceProfileReader, + }); /// 当前 access token;未登录返回 null 或空串。 final String? Function()? accessTokenReader; @@ -40,6 +45,9 @@ class AuthHeaderInterceptor extends Interceptor { /// 设备号(`X-Device-Id`):注册风控、下载绑定都用它。 final Future Function()? deviceIdReader; + /// 设备画像(系统/机型):后台判断客户端系统用,匿名请求同样要带。 + final Future Function()? deviceProfileReader; + @override Future onRequest( RequestOptions options, @@ -54,6 +62,10 @@ class AuthHeaderInterceptor extends Interceptor { if (deviceId != null && deviceId.isNotEmpty) { options.headers['X-Device-Id'] = deviceId; } + final profile = await deviceProfileReader?.call(); + if (profile != null) { + options.headers.addAll(profile.headers); + } handler.next(options); } } diff --git a/lib/core/data/remote/device_profile.dart b/lib/core/data/remote/device_profile.dart new file mode 100644 index 0000000..3115bd1 --- /dev/null +++ b/lib/core/data/remote/device_profile.dart @@ -0,0 +1,69 @@ +// Author: fruit.com.cn + +import 'dart:io' show Platform; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; + +/// 设备画像:随每个请求以请求头上报,供后台列表识别客户端系统。 +/// +/// 之前只有登录接口在 body 里带 `platform` / `osVersion`,匿名互动没有任何系统信息, +/// 后台只能显示「—」。改成请求头后,点赞、收藏、评论、观看等接口都能带上。 +class DeviceProfile { + const DeviceProfile({ + this.platform = '', + this.osVersion = '', + this.model = '', + }); + + /// ANDROID / IOS / WEB;桌面端等无法归类时为空。 + final String platform; + + /// 系统版本,Android 取 release(如 14),iOS 取 systemVersion(如 17.2)。 + final String osVersion; + + /// 机型,Android 取 model(如 SM-G991B),iOS 取 utsname.machine(如 iPhone15,2)。 + final String model; + + Map get headers => { + if (platform.isNotEmpty) 'X-Client-Platform': platform, + if (osVersion.isNotEmpty) 'X-Client-Os': osVersion, + if (model.isNotEmpty) 'X-Client-Model': model, + }; +} + +/// 读取一次并缓存设备画像:插件调用有开销,且整个进程内不会变。 +class DeviceProfileReader { + DeviceProfileReader({DeviceInfoPlugin? plugin}) + : _plugin = plugin ?? DeviceInfoPlugin(); + + final DeviceInfoPlugin _plugin; + Future? _cached; + + Future read() => _cached ??= _load(); + + Future _load() async { + if (kIsWeb) return const DeviceProfile(platform: 'WEB'); + try { + if (Platform.isAndroid) { + final info = await _plugin.androidInfo; + return DeviceProfile( + platform: 'ANDROID', + osVersion: info.version.release, + model: info.model, + ); + } + if (Platform.isIOS) { + final info = await _plugin.iosInfo; + return DeviceProfile( + platform: 'IOS', + osVersion: info.systemVersion, + model: info.utsname.machine, + ); + } + } catch (_) { + // 插件不可用(widget 测试没有原生通道)时退化为 dart:io 能拿到的信息 + } + return const DeviceProfile(); + } +} diff --git a/pubspec.lock b/pubspec.lock index 156b4f0..a4e2eaf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -113,6 +113,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.9" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: "86add5ef97215562d2e090535b0a16f197902b10c369c558a100e74ea06e8659" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.0.3" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.3" dio: dependency: "direct main" description: @@ -216,7 +232,7 @@ packages: source: hosted version: "2.0.2" html: - dependency: transitive + dependency: "direct main" description: name: html sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" @@ -248,10 +264,10 @@ packages: dependency: transitive description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.flutter-io.cn" source: hosted - version: "0.20.2" + version: "0.20.3" jni: dependency: transitive description: @@ -328,10 +344,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.flutter-io.cn" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -344,10 +360,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.flutter-io.cn" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: @@ -380,14 +396,6 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.0.0" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "2f1564aadb14215e478563b5406cb80729d022158c01dc50c96013c2f7eb4790" - url: "https://pub.flutter-io.cn" - source: hosted - version: "7.1.0" path: dependency: transitive description: @@ -444,6 +452,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "2f1564aadb14215e478563b5406cb80729d022158c01dc50c96013c2f7eb4790" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.1.0" platform: dependency: transitive description: @@ -613,10 +629,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.11" + version: "0.7.12" typed_data: dependency: transitive description: @@ -637,10 +653,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.0" + version: "2.4.3" video_player: dependency: "direct main" description: @@ -705,6 +721,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.2.0" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.5" xdg_directories: dependency: transitive description: @@ -730,5 +762,5 @@ packages: source: hosted version: "3.1.4" sdks: - dart: ">=3.12.2 <4.0.0" + dart: ">=3.13.0 <4.0.0" flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 0c1a46b..8537396 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,9 @@ dependencies: sdk: flutter path_provider: 2.1.5 html: ^0.15.6 + # 设备画像上报(系统版本 + 机型),后台列表据此展示客户端系统; + # 用缓存里的 9.0.3,避免拉新版带来的原生构建变更。 + device_info_plus: 9.0.3 dev_dependencies: flutter_test: From 935de13343abc1bcdbba39abbae35f4f6274fa31 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Tue, 27 Oct 2026 14:00:19 +0800 Subject: [PATCH 26/29] =?UTF-8?q?refactor(core):=20=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5=E6=94=B6=E6=95=9B=E4=B8=BA=E5=8D=95=E4=B8=AA?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 去掉 device_info_plus 依赖与机型/系统版本,只保留 X-Client-Platform。 --- lib/app.dart | 7 +-- lib/core/data/remote/api_client.dart | 9 ++- lib/core/data/remote/api_interceptors.dart | 13 ++--- lib/core/data/remote/device_profile.dart | 68 +++------------------- pubspec.lock | 32 ---------- pubspec.yaml | 3 - 6 files changed, 19 insertions(+), 113 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index e7e2503..5fd2cf8 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -117,17 +117,14 @@ class LumaDramaApp extends StatefulWidget { class _LumaDramaAppState extends State { late final AuthSessionStore _sessionStore = AuthSessionStore(); - /// 设备画像只读一次:供拦截器在每次请求头里带上系统与机型。 - late final DeviceProfileReader _deviceProfile = DeviceProfileReader(); - /// 全 App 共用一个 ApiClient:这样目录、账号、后续的互动/进度请求 - /// 都自动带 `Authorization` 与 `X-Device-Id`,401 的刷新重试也只有一处实现。 + /// 都自动带 `Authorization`、`X-Device-Id` 与平台头,401 的刷新重试也只有一处实现。 late final ApiClient _apiClient = ApiClient( accessTokenReader: () => _auth.accessToken, tokenRefresher: () => _auth.refresh(), onUnauthorized: () => _auth.clear(), deviceIdReader: _sessionStore.deviceId, - deviceProfileReader: _deviceProfile.read, + platformReader: clientPlatform, ); late final AuthController _auth = AuthController( diff --git a/lib/core/data/remote/api_client.dart b/lib/core/data/remote/api_client.dart index 0c04f1b..41f73dd 100644 --- a/lib/core/data/remote/api_client.dart +++ b/lib/core/data/remote/api_client.dart @@ -5,7 +5,6 @@ import 'package:dio/dio.dart'; import '../../config/app_config.dart'; import 'api_exception.dart'; import 'api_interceptors.dart'; -import 'device_profile.dart'; import 'network_log_interceptor.dart'; /// `ApiException` 从这里也能拿到:历史调用方一律 `import 'api_client.dart'`,不必改 import。 @@ -25,7 +24,7 @@ class ApiClient { this.tokenRefresher, this.onUnauthorized, this.deviceIdReader, - this.deviceProfileReader, + this.platformReader, }) : _fixedBaseUrl = baseUrl { _dio = Dio( BaseOptions( @@ -42,7 +41,7 @@ class ApiClient { AuthHeaderInterceptor( accessTokenReader: accessTokenReader, deviceIdReader: deviceIdReader, - deviceProfileReader: deviceProfileReader, + platformReader: platformReader, ), const EnvelopeInterceptor(), NetworkLogInterceptor(), @@ -70,8 +69,8 @@ class ApiClient { /// 设备号(`X-Device-Id`):注册风控、后续下载绑定都要用 final Future Function()? deviceIdReader; - /// 设备画像(系统/机型版本):后台列表展示客户端系统用 - final Future Function()? deviceProfileReader; + /// 客户端平台(`X-Client-Platform`):后台区分 iOS / Android + final String Function()? platformReader; Future get(String path, {Map? query}) { return _request('GET', path, query: query); diff --git a/lib/core/data/remote/api_interceptors.dart b/lib/core/data/remote/api_interceptors.dart index 5962190..9fdea13 100644 --- a/lib/core/data/remote/api_interceptors.dart +++ b/lib/core/data/remote/api_interceptors.dart @@ -5,7 +5,6 @@ import 'dart:convert'; import 'package:dio/dio.dart'; import 'api_exception.dart'; -import 'device_profile.dart'; import 'network_log_interceptor.dart'; /// 请求级开关:放在 `RequestOptions.extra` 里,由 `ApiClient` 写入、拦截器读取。 @@ -36,7 +35,7 @@ class AuthHeaderInterceptor extends Interceptor { AuthHeaderInterceptor({ this.accessTokenReader, this.deviceIdReader, - this.deviceProfileReader, + this.platformReader, }); /// 当前 access token;未登录返回 null 或空串。 @@ -45,8 +44,8 @@ class AuthHeaderInterceptor extends Interceptor { /// 设备号(`X-Device-Id`):注册风控、下载绑定都用它。 final Future Function()? deviceIdReader; - /// 设备画像(系统/机型):后台判断客户端系统用,匿名请求同样要带。 - final Future Function()? deviceProfileReader; + /// 客户端平台(`X-Client-Platform`):后台区分 iOS / Android,匿名请求同样要带。 + final String Function()? platformReader; @override Future onRequest( @@ -62,9 +61,9 @@ class AuthHeaderInterceptor extends Interceptor { if (deviceId != null && deviceId.isNotEmpty) { options.headers['X-Device-Id'] = deviceId; } - final profile = await deviceProfileReader?.call(); - if (profile != null) { - options.headers.addAll(profile.headers); + final platform = platformReader?.call() ?? ''; + if (platform.isNotEmpty) { + options.headers['X-Client-Platform'] = platform; } handler.next(options); } diff --git a/lib/core/data/remote/device_profile.dart b/lib/core/data/remote/device_profile.dart index 3115bd1..f03191d 100644 --- a/lib/core/data/remote/device_profile.dart +++ b/lib/core/data/remote/device_profile.dart @@ -2,68 +2,14 @@ import 'dart:io' show Platform; -import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; -/// 设备画像:随每个请求以请求头上报,供后台列表识别客户端系统。 +/// 客户端平台,后台只用来区分 iOS / Android。 /// -/// 之前只有登录接口在 body 里带 `platform` / `osVersion`,匿名互动没有任何系统信息, -/// 后台只能显示「—」。改成请求头后,点赞、收藏、评论、观看等接口都能带上。 -class DeviceProfile { - const DeviceProfile({ - this.platform = '', - this.osVersion = '', - this.model = '', - }); - - /// ANDROID / IOS / WEB;桌面端等无法归类时为空。 - final String platform; - - /// 系统版本,Android 取 release(如 14),iOS 取 systemVersion(如 17.2)。 - final String osVersion; - - /// 机型,Android 取 model(如 SM-G991B),iOS 取 utsname.machine(如 iPhone15,2)。 - final String model; - - Map get headers => { - if (platform.isNotEmpty) 'X-Client-Platform': platform, - if (osVersion.isNotEmpty) 'X-Client-Os': osVersion, - if (model.isNotEmpty) 'X-Client-Model': model, - }; -} - -/// 读取一次并缓存设备画像:插件调用有开销,且整个进程内不会变。 -class DeviceProfileReader { - DeviceProfileReader({DeviceInfoPlugin? plugin}) - : _plugin = plugin ?? DeviceInfoPlugin(); - - final DeviceInfoPlugin _plugin; - Future? _cached; - - Future read() => _cached ??= _load(); - - Future _load() async { - if (kIsWeb) return const DeviceProfile(platform: 'WEB'); - try { - if (Platform.isAndroid) { - final info = await _plugin.androidInfo; - return DeviceProfile( - platform: 'ANDROID', - osVersion: info.version.release, - model: info.model, - ); - } - if (Platform.isIOS) { - final info = await _plugin.iosInfo; - return DeviceProfile( - platform: 'IOS', - osVersion: info.systemVersion, - model: info.utsname.machine, - ); - } - } catch (_) { - // 插件不可用(widget 测试没有原生通道)时退化为 dart:io 能拿到的信息 - } - return const DeviceProfile(); - } +/// 系统版本、机型一律不上报:后台列表需要的只是「这台设备是安卓还是苹果」。 +String clientPlatform() { + if (kIsWeb) return 'WEB'; + if (Platform.isAndroid) return 'ANDROID'; + if (Platform.isIOS) return 'IOS'; + return ''; } diff --git a/pubspec.lock b/pubspec.lock index a4e2eaf..13c5681 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -113,22 +113,6 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.9" - device_info_plus: - dependency: "direct main" - description: - name: device_info_plus - sha256: "86add5ef97215562d2e090535b0a16f197902b10c369c558a100e74ea06e8659" - url: "https://pub.flutter-io.cn" - source: hosted - version: "9.0.3" - device_info_plus_platform_interface: - dependency: transitive - description: - name: device_info_plus_platform_interface - sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f - url: "https://pub.flutter-io.cn" - source: hosted - version: "7.0.3" dio: dependency: "direct main" description: @@ -721,22 +705,6 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.2.0" - win32: - dependency: transitive - description: - name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e - url: "https://pub.flutter-io.cn" - source: hosted - version: "5.15.0" - win32_registry: - dependency: transitive - description: - name: win32_registry - sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.1.5" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8537396..0c1a46b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,9 +48,6 @@ dependencies: sdk: flutter path_provider: 2.1.5 html: ^0.15.6 - # 设备画像上报(系统版本 + 机型),后台列表据此展示客户端系统; - # 用缓存里的 9.0.3,避免拉新版带来的原生构建变更。 - device_info_plus: 9.0.3 dev_dependencies: flutter_test: From 1b19513efb7ce7ef3ff5b2a199c4305bd954f0e1 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Tue, 27 Oct 2026 14:15:50 +0800 Subject: [PATCH 27/29] =?UTF-8?q?feat(core):=20=E8=AE=BE=E5=A4=87=E5=8F=B7?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E7=B3=BB=E7=BB=9F=E6=A0=87=E8=AF=86=EF=BC=8C?= =?UTF-8?q?=E9=87=8D=E8=A3=85=E4=B8=8D=E5=86=8D=E5=8F=98=E6=88=90=E6=96=B0?= =?UTF-8?q?=E6=B8=B8=E5=AE=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Android 读 SSAID、iOS 读 IDFV,均免权限,由原生通道提供,不引插件 - 取不到时回退本地 UUIDv4,替代原来 32 位随机 + 时间戳的拼接 --- .../com/stormg/lumadrama/MainActivity.kt | 15 +++++ ios/Runner/AppDelegate.swift | 16 +++++ .../data/services/auth_session_store.dart | 45 +++++++++---- .../data/services/device_id_provider.dart | 36 ++++++++++ test/core/device_id_test.dart | 65 +++++++++++++++++++ 5 files changed, 165 insertions(+), 12 deletions(-) create mode 100644 lib/core/data/services/device_id_provider.dart create mode 100644 test/core/device_id_test.dart diff --git a/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt index 8f25c38..71a3a09 100644 --- a/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt +++ b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt @@ -12,6 +12,7 @@ import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper +import android.provider.Settings import android.util.Rational import android.content.pm.PackageManager import android.view.WindowManager @@ -65,6 +66,20 @@ class MainActivity : FlutterActivity() { result.success(false) } } + // 设备标识:SSAID 免权限,卸载重装不变,避免客户端自己生成的 id 每次重装都变 + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/device") + .setMethodCallHandler { call, result -> + if (call.method != "ssaid") { + result.notImplemented() + return@setMethodCallHandler + } + val value = try { + Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID) + } catch (_: Exception) { + null + } + result.success(value) + } MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/share") .setMethodCallHandler { call, result -> if (call.method != "shareText") { diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index b33feff..43a3513 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -63,6 +63,7 @@ final class RunnerFlutterViewController: FlutterViewController { func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) registerWirelessDataChannel(engineBridge) + registerDeviceIdChannel(engineBridge) let channel = FlutterMethodChannel( name: "com.stormg.lumadrama/share", binaryMessenger: engineBridge.applicationRegistrar.messenger() @@ -144,6 +145,21 @@ final class RunnerFlutterViewController: FlutterViewController { } } + /// 设备标识:IDFV 免权限,卸载本 App 不变,避免客户端自生成的 id 每次重装都变。 + private func registerDeviceIdChannel(_ engineBridge: FlutterImplicitEngineBridge) { + let channel = FlutterMethodChannel( + name: "com.stormg.lumadrama/device", + binaryMessenger: engineBridge.applicationRegistrar.messenger() + ) + channel.setMethodCallHandler { call, result in + guard call.method == "idfv" else { + result(FlutterMethodNotImplemented) + return + } + result(UIDevice.current.identifierForVendor?.uuidString) + } + } + private func registerWirelessDataChannel(_ engineBridge: FlutterImplicitEngineBridge) { let channel = FlutterMethodChannel( name: WirelessDataPrompt.channelName, diff --git a/lib/core/data/services/auth_session_store.dart b/lib/core/data/services/auth_session_store.dart index 20afccb..cf178b7 100644 --- a/lib/core/data/services/auth_session_store.dart +++ b/lib/core/data/services/auth_session_store.dart @@ -4,6 +4,7 @@ import 'dart:math'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/auth_session.dart'; +import 'device_id_provider.dart'; /// 令牌与设备号的本地持久化。 /// @@ -11,13 +12,17 @@ import '../models/auth_session.dart'; /// `SharedPreferences.getInstance()` 在新版本里已标注弃用,两套混用还会让 /// widget 测试只 mock 到其中一套。 /// -/// 设备号是后端注册风控与后续"下载设备绑定"的依据,所以**首次生成后就固定下来**; -/// 不引 UUID 库(保持极简依赖),用「时间戳 + 随机数」拼一个够用的 id。 +/// 设备号优先取系统标识(Android SSAID / iOS IDFV),这样卸载重装后还是同一台设备; +/// 系统标识不可用时才回退到本地生成并持久化的 UUID,保证任何环境都能跑。 class AuthSessionStore { - AuthSessionStore({SharedPreferencesAsync? preferences}) - : _preferences = preferences ?? SharedPreferencesAsync(); + AuthSessionStore({ + SharedPreferencesAsync? preferences, + DeviceIdProvider? deviceIdProvider, + }) : _preferences = preferences ?? SharedPreferencesAsync(), + _deviceIdProvider = deviceIdProvider ?? DeviceIdProvider(); final SharedPreferencesAsync _preferences; + final DeviceIdProvider _deviceIdProvider; static const _kAccessToken = 'auth.accessToken'; static const _kRefreshToken = 'auth.refreshToken'; @@ -28,18 +33,34 @@ class AuthSessionStore { Future deviceId() async { if (_cachedDeviceId != null) return _cachedDeviceId!; - var value = await _preferences.getString(_kDeviceId) ?? ''; - if (value.isEmpty) { - final random = Random(); - value = - 'd-${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}-' - '${random.nextInt(1 << 32).toRadixString(36)}'; - await _preferences.setString(_kDeviceId, value); - } + final systemId = await _deviceIdProvider.systemDeviceId(); + final value = systemId ?? await _localDeviceId(); _cachedDeviceId = value; return value; } + /// 系统标识取不到时的兜底:本地 UUIDv4,生成一次后固定下来。 + Future _localDeviceId() async { + final stored = await _preferences.getString(_kDeviceId) ?? ''; + if (stored.isNotEmpty) return stored; + final generated = 'd-${_uuidV4()}'; + await _preferences.setString(_kDeviceId, generated); + return generated; + } + + /// UUIDv4:122 位随机,碰撞概率可忽略,不引第三方依赖。 + String _uuidV4() { + final random = Random.secure(); + final bytes = List.generate(16, (_) => random.nextInt(256)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + String hex(int start, int end) => bytes + .sublist(start, end) + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return '${hex(0, 4)}-${hex(4, 6)}-${hex(6, 8)}-${hex(8, 10)}-${hex(10, 16)}'; + } + Future read() async { final accessToken = await _preferences.getString(_kAccessToken) ?? ''; final refreshToken = await _preferences.getString(_kRefreshToken) ?? ''; diff --git a/lib/core/data/services/device_id_provider.dart b/lib/core/data/services/device_id_provider.dart new file mode 100644 index 0000000..ab702db --- /dev/null +++ b/lib/core/data/services/device_id_provider.dart @@ -0,0 +1,36 @@ +// Author: fruit.com.cn + +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// 读取系统级设备标识,两端都不需要任何权限。 +/// +/// - Android:SSAID(`Settings.Secure.ANDROID_ID`),卸载重装不变; +/// - iOS:IDFV(`identifierForVendor`),卸载本 App 不变,同厂商应用全删才会变。 +/// +/// 两端都由原生侧提供,不引插件:项目 iOS 走 SPM,避免混入 CocoaPods。 +/// 取不到(Web、测试环境、通道异常)时返回 null,由调用方回退到本地生成的 id。 +class DeviceIdProvider { + static const MethodChannel _channel = MethodChannel( + 'com.stormg.lumadrama/device', + ); + + Future systemDeviceId() async { + if (kIsWeb) return null; + try { + if (Platform.isAndroid) { + final ssaid = await _channel.invokeMethod('ssaid'); + if (ssaid != null && ssaid.isNotEmpty) return 'a-$ssaid'; + } + if (Platform.isIOS) { + final idfv = await _channel.invokeMethod('idfv'); + if (idfv != null && idfv.isNotEmpty) return 'i-$idfv'; + } + } catch (_) { + // 原生通道不可用(widget 测试)时走本地兜底 + } + return null; + } +} diff --git a/test/core/device_id_test.dart b/test/core/device_id_test.dart new file mode 100644 index 0000000..1ef86dc --- /dev/null +++ b/test/core/device_id_test.dart @@ -0,0 +1,65 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/data/services/device_id_provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +/// 可控的系统设备标识来源。 +class _FakeDeviceIdProvider implements DeviceIdProvider { + _FakeDeviceIdProvider(this.value); + + final String? value; + int calls = 0; + + @override + Future systemDeviceId() async { + calls++; + return value; + } +} + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + test('should_prefer_system_device_id_over_local_value', () async { + final provider = _FakeDeviceIdProvider('a-9774d56d682e549c'); + final store = AuthSessionStore(deviceIdProvider: provider); + + expect(await store.deviceId(), 'a-9774d56d682e549c'); + }); + + test('should_fall_back_to_stable_local_uuid_when_system_id_missing', () async { + final store = AuthSessionStore( + deviceIdProvider: _FakeDeviceIdProvider(null), + ); + + final first = await store.deviceId(); + final second = await store.deviceId(); + + // 同一次运行内固定,且符合 UUIDv4 形态 + expect(first, second); + expect(first, startsWith('d-')); + expect( + RegExp( + r'^d-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + ).hasMatch(first), + isTrue, + reason: '本地兜底必须是 UUIDv4,避免 32 位随机带来的碰撞', + ); + }); + + test('should_cache_system_id_within_one_session', () async { + final provider = _FakeDeviceIdProvider('i-E621E1F8-C36C-495A-93FC-0C247A3E6E5F'); + final store = AuthSessionStore(deviceIdProvider: provider); + + await store.deviceId(); + await store.deviceId(); + + expect(provider.calls, 1); + }); +} From a3f3481f4f3ca9bb59a6bca928d7a0a9ed75f36f Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Tue, 27 Oct 2026 15:45:20 +0800 Subject: [PATCH 28/29] =?UTF-8?q?feat:=20phase1=20UI=20prep=20=E2=80=94=20?= =?UTF-8?q?=E8=AE=BE=E5=A4=87ID=E7=B3=BB=E7=BB=9F=E4=BC=A0=E9=80=92=20+=20?= =?UTF-8?q?=E5=9B=BD=E5=AE=B6=E6=9C=AC=E5=9C=B0=E5=8C=96=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=20+=20=E7=BB=A7=E7=BB=AD=E8=A7=82=E7=9C=8B=E7=9B=B4=E8=BF=9B?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设备标识: Android SSAID/ iOS IDFV 原生通道 → Dart DeviceIdProvider 国家解析: 注册/登录从后端 IP → 国家/语言/时区/币种 自动落库 账号资料: 全部本地化展示 (account_display.dart) 我的页: 继续观看卡片点击直接进播放页 --- .../com/stormg/lumadrama/MainActivity.kt | 46 +++- ios/Runner/AppDelegate.swift | 14 +- ios/Runner/Info.plist | 2 +- lib/core/config/app_config.dart | 2 +- lib/core/data/remote/auth_api.dart | 5 +- lib/core/data/services/auth_controller.dart | 2 + .../data/services/auth_session_store.dart | 9 + .../data/services/device_id_provider.dart | 14 + lib/core/formatting/account_display.dart | 239 ++++++++++++++++++ lib/pages/account/account_profile_page.dart | 37 ++- lib/pages/mine/mine_view.dart | 35 +++ .../mine/widgets/mine_continue_watching.dart | 12 +- test/account/account_display_test.dart | 40 +++ test/core/device_id_test.dart | 18 +- test/mine/mine_preview_layout_test.dart | 18 ++ 15 files changed, 468 insertions(+), 25 deletions(-) create mode 100644 lib/core/formatting/account_display.dart create mode 100644 test/account/account_display_test.dart diff --git a/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt index 71a3a09..b423dd4 100644 --- a/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt +++ b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt @@ -4,6 +4,7 @@ package com.stormg.lumadrama import android.content.Intent import android.app.PictureInPictureParams +import android.content.Context import android.content.res.Configuration import android.graphics.Bitmap import android.media.MediaMetadataRetriever @@ -13,6 +14,7 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.provider.Settings +import android.telephony.TelephonyManager import android.util.Rational import android.content.pm.PackageManager import android.view.WindowManager @@ -22,6 +24,7 @@ import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import java.io.ByteArrayOutputStream +import java.util.Locale import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean @@ -69,16 +72,19 @@ class MainActivity : FlutterActivity() { // 设备标识:SSAID 免权限,卸载重装不变,避免客户端自己生成的 id 每次重装都变 MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/device") .setMethodCallHandler { call, result -> - if (call.method != "ssaid") { - result.notImplemented() - return@setMethodCallHandler + when (call.method) { + "ssaid" -> { + val value = try { + Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID) + } catch (_: Exception) { + null + } + result.success(value) + } + // 设备区域:注册时作为服务端 IP 解析失败后的兜底 + "region" -> result.success(currentRegion()) + else -> result.notImplemented() } - val value = try { - Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID) - } catch (_: Exception) { - null - } - result.success(value) } MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/share") .setMethodCallHandler { call, result -> @@ -171,6 +177,28 @@ class MainActivity : FlutterActivity() { ?.bottom ?: 0 + /** 网络运营商国家优先,其次 SIM 卡,最后系统语言;都拿不到返回空串。 */ + private fun currentRegion(): String { + return try { + val telephony = getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager + val localeCountry = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + resources.configuration.locales.get(0).country + } else { + @Suppress("DEPRECATION") + resources.configuration.locale.country + } + val candidates = listOfNotNull( + telephony?.networkCountryIso, + telephony?.simCountryIso, + localeCountry + ) + candidates.firstOrNull { it.isNotBlank() }?.uppercase(Locale.ROOT) ?: "" + } catch (_: Exception) { + // 个别 ROM 读运营商信息会抛 SecurityException,别让注册流程卡住 + "" + } + } + private fun emitImeInset(value: Int) { if (value == imeInset) return imeInset = value diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 43a3513..6b22270 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -146,17 +146,25 @@ final class RunnerFlutterViewController: FlutterViewController { } /// 设备标识:IDFV 免权限,卸载本 App 不变,避免客户端自生成的 id 每次重装都变。 + /// 同通道顺带提供设备区域(ISO-2),注册时作为服务端 IP 解析失败后的兜底。 private func registerDeviceIdChannel(_ engineBridge: FlutterImplicitEngineBridge) { let channel = FlutterMethodChannel( name: "com.stormg.lumadrama/device", binaryMessenger: engineBridge.applicationRegistrar.messenger() ) channel.setMethodCallHandler { call, result in - guard call.method == "idfv" else { + switch call.method { + case "idfv": + result(UIDevice.current.identifierForVendor?.uuidString) + case "region": + if #available(iOS 16.0, *) { + result(Locale.current.region?.identifier) + } else { + result(Locale.current.regionCode) + } + default: result(FlutterMethodNotImplemented) - return } - result(UIDevice.current.identifierForVendor?.uuidString) } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 80b4716..1e27100 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -48,7 +48,7 @@ NSIncludesSubdomains - 10.43.47.1 + 192.168.31.243 NSExceptionAllowsInsecureHTTPLoads diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index d9ed463..391fcaa 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -14,7 +14,7 @@ class AppConfig { /// 开发调试默认服务地址,当前指向本机局域网 IP。 /// /// 换网络时只改这一行,地址不要带结尾斜杠。 - static const String devHostBaseUrl = 'http://10.43.47.1:8081'; + static const String devHostBaseUrl = 'http://192.168.31.243:8081'; /// 本机后端地址:通过 `adb reverse tcp:8081 tcp:8081` 映射到开发机, /// 探测可达时 App 直连本机服务;不可达时回落到公网地址。 diff --git a/lib/core/data/remote/auth_api.dart b/lib/core/data/remote/auth_api.dart index b452aec..603e181 100644 --- a/lib/core/data/remote/auth_api.dart +++ b/lib/core/data/remote/auth_api.dart @@ -56,6 +56,7 @@ class AuthApi { required String target, required String code, required String deviceId, + String countryCode = '', String platform = '', String osVersion = '', String appVersion = '', @@ -66,6 +67,7 @@ class AuthApi { 'targetType': 'PHONE', 'target': target, 'code': code, + 'countryCode': countryCode, 'deviceId': deviceId, 'platform': platform, 'osVersion': osVersion, @@ -79,6 +81,7 @@ class AuthApi { required String email, required String password, required String deviceId, + String countryCode = '', String platform = '', String osVersion = '', String appVersion = '', @@ -89,7 +92,7 @@ class AuthApi { 'targetType': 'EMAIL', 'target': email, 'password': password, - 'countryCode': 'ID', + 'countryCode': countryCode, 'deviceId': deviceId, 'platform': platform, 'osVersion': osVersion, diff --git a/lib/core/data/services/auth_controller.dart b/lib/core/data/services/auth_controller.dart index 7364098..12ed578 100644 --- a/lib/core/data/services/auth_controller.dart +++ b/lib/core/data/services/auth_controller.dart @@ -48,6 +48,7 @@ class AuthController extends ChangeNotifier { email: email, password: password, deviceId: await _store.deviceId(), + countryCode: await _store.deviceRegion(), platform: _platform, osVersion: _osVersion, ); @@ -95,6 +96,7 @@ class AuthController extends ChangeNotifier { target: target, code: code, deviceId: await _store.deviceId(), + countryCode: await _store.deviceRegion(), platform: _platform, osVersion: _osVersion, ); diff --git a/lib/core/data/services/auth_session_store.dart b/lib/core/data/services/auth_session_store.dart index cf178b7..26174e8 100644 --- a/lib/core/data/services/auth_session_store.dart +++ b/lib/core/data/services/auth_session_store.dart @@ -30,6 +30,7 @@ class AuthSessionStore { static const _kDeviceId = 'device.id'; String? _cachedDeviceId; + String? _cachedRegion; Future deviceId() async { if (_cachedDeviceId != null) return _cachedDeviceId!; @@ -39,6 +40,14 @@ class AuthSessionStore { return value; } + /// 设备区域(ISO-2),取不到返回空串;只在注册时作为服务端 IP 解析失败后的兜底。 + Future deviceRegion() async { + if (_cachedRegion != null) return _cachedRegion!; + final value = await _deviceIdProvider.deviceRegion() ?? ''; + _cachedRegion = value; + return value; + } + /// 系统标识取不到时的兜底:本地 UUIDv4,生成一次后固定下来。 Future _localDeviceId() async { final stored = await _preferences.getString(_kDeviceId) ?? ''; diff --git a/lib/core/data/services/device_id_provider.dart b/lib/core/data/services/device_id_provider.dart index ab702db..34f790c 100644 --- a/lib/core/data/services/device_id_provider.dart +++ b/lib/core/data/services/device_id_provider.dart @@ -33,4 +33,18 @@ class DeviceIdProvider { } return null; } + + /// 设备所在区域(ISO-2),仅用于服务端 IP 解析失败时兜底注册国家。 + /// Android 依次取网络运营商 / SIM 卡 / 系统语言的国家,iOS 取系统 region。 + Future deviceRegion() async { + if (kIsWeb) return null; + try { + final region = await _channel.invokeMethod('region'); + final value = region?.trim().toUpperCase() ?? ''; + if (value.length == 2) return value; + } catch (_) { + // 原生通道不可用(widget 测试)时返回 null,由服务端兜底 + } + return null; + } } diff --git a/lib/core/formatting/account_display.dart b/lib/core/formatting/account_display.dart new file mode 100644 index 0000000..0a4a410 --- /dev/null +++ b/lib/core/formatting/account_display.dart @@ -0,0 +1,239 @@ +// Author: fruit.com.cn + +/// 账号资料页只读字段的展示格式化。 +/// +/// 后端存的是 ISO 国家码、BCP-47 语言标签和 IANA 时区(`ID` / `id-ID` / `Asia/Jakarta`), +/// 直接展示对用户没有意义,这里统一转成当前界面语言的名称。 +/// 国家与城市名只维护中文与英文两套:界面语言为印尼语时回退英文名, +/// 语言名与账号状态则三种界面语言都有。 +library; + +const _countryNamesZh = { + 'ID': '印度尼西亚', + 'MY': '马来西亚', + 'SG': '新加坡', + 'TH': '泰国', + 'VN': '越南', + 'PH': '菲律宾', + 'KH': '柬埔寨', + 'MM': '缅甸', + 'LA': '老挝', + 'BN': '文莱', + 'CN': '中国', + 'TW': '中国台湾', + 'HK': '中国香港', + 'MO': '中国澳门', + 'JP': '日本', + 'KR': '韩国', + 'IN': '印度', + 'PK': '巴基斯坦', + 'BD': '孟加拉国', + 'LK': '斯里兰卡', + 'NP': '尼泊尔', + 'US': '美国', + 'CA': '加拿大', + 'MX': '墨西哥', + 'BR': '巴西', + 'AR': '阿根廷', + 'CL': '智利', + 'CO': '哥伦比亚', + 'PE': '秘鲁', + 'VE': '委内瑞拉', + 'EC': '厄瓜多尔', + 'GT': '危地马拉', + 'DO': '多米尼加', + 'GB': '英国', + 'DE': '德国', + 'FR': '法国', + 'ES': '西班牙', + 'IT': '意大利', + 'NL': '荷兰', + 'PL': '波兰', + 'RU': '俄罗斯', + 'UA': '乌克兰', + 'TR': '土耳其', + 'SA': '沙特阿拉伯', + 'AE': '阿联酋', + 'IL': '以色列', + 'EG': '埃及', + 'NG': '尼日利亚', + 'ZA': '南非', + 'KE': '肯尼亚', + 'MA': '摩洛哥', + 'AU': '澳大利亚', + 'NZ': '新西兰', +}; + +const _countryNamesEn = { + 'ID': 'Indonesia', + 'MY': 'Malaysia', + 'SG': 'Singapore', + 'TH': 'Thailand', + 'VN': 'Vietnam', + 'PH': 'Philippines', + 'KH': 'Cambodia', + 'MM': 'Myanmar', + 'LA': 'Laos', + 'BN': 'Brunei', + 'CN': 'China', + 'TW': 'Taiwan', + 'HK': 'Hong Kong', + 'MO': 'Macau', + 'JP': 'Japan', + 'KR': 'South Korea', + 'IN': 'India', + 'PK': 'Pakistan', + 'BD': 'Bangladesh', + 'LK': 'Sri Lanka', + 'NP': 'Nepal', + 'US': 'United States', + 'CA': 'Canada', + 'MX': 'Mexico', + 'BR': 'Brazil', + 'AR': 'Argentina', + 'CL': 'Chile', + 'CO': 'Colombia', + 'PE': 'Peru', + 'VE': 'Venezuela', + 'EC': 'Ecuador', + 'GT': 'Guatemala', + 'DO': 'Dominican Republic', + 'GB': 'United Kingdom', + 'DE': 'Germany', + 'FR': 'France', + 'ES': 'Spain', + 'IT': 'Italy', + 'NL': 'Netherlands', + 'PL': 'Poland', + 'RU': 'Russia', + 'UA': 'Ukraine', + 'TR': 'Turkey', + 'SA': 'Saudi Arabia', + 'AE': 'United Arab Emirates', + 'IL': 'Israel', + 'EG': 'Egypt', + 'NG': 'Nigeria', + 'ZA': 'South Africa', + 'KE': 'Kenya', + 'MA': 'Morocco', + 'AU': 'Australia', + 'NZ': 'New Zealand', +}; + +const _timezoneNamesZh = { + 'Asia/Jakarta': '雅加达', + 'Asia/Kuala_Lumpur': '吉隆坡', + 'Asia/Singapore': '新加坡', + 'Asia/Bangkok': '曼谷', + 'Asia/Ho_Chi_Minh': '胡志明市', + 'Asia/Manila': '马尼拉', + 'Asia/Phnom_Penh': '金边', + 'Asia/Yangon': '仰光', + 'Asia/Vientiane': '万象', + 'Asia/Brunei': '斯里巴加湾市', + 'Asia/Shanghai': '上海', + 'Asia/Taipei': '台北', + 'Asia/Hong_Kong': '香港', + 'Asia/Macau': '澳门', + 'Asia/Tokyo': '东京', + 'Asia/Seoul': '首尔', + 'Asia/Kolkata': '加尔各答', + 'Asia/Karachi': '卡拉奇', + 'Asia/Dhaka': '达卡', + 'Asia/Colombo': '科伦坡', + 'Asia/Kathmandu': '加德满都', + 'America/New_York': '纽约', + 'America/Toronto': '多伦多', + 'America/Mexico_City': '墨西哥城', + 'America/Sao_Paulo': '圣保罗', + 'America/Argentina/Buenos_Aires': '布宜诺斯艾利斯', + 'America/Santiago': '圣地亚哥', + 'America/Bogota': '波哥大', + 'America/Lima': '利马', + 'America/Caracas': '加拉加斯', + 'America/Guayaquil': '瓜亚基尔', + 'America/Guatemala': '危地马拉城', + 'America/Santo_Domingo': '圣多明各', + 'Europe/London': '伦敦', + 'Europe/Berlin': '柏林', + 'Europe/Paris': '巴黎', + 'Europe/Madrid': '马德里', + 'Europe/Rome': '罗马', + 'Europe/Amsterdam': '阿姆斯特丹', + 'Europe/Warsaw': '华沙', + 'Europe/Moscow': '莫斯科', + 'Europe/Kyiv': '基辅', + 'Europe/Istanbul': '伊斯坦布尔', + 'Asia/Riyadh': '利雅得', + 'Asia/Dubai': '迪拜', + 'Asia/Jerusalem': '耶路撒冷', + 'Africa/Cairo': '开罗', + 'Africa/Lagos': '拉各斯', + 'Africa/Johannesburg': '约翰内斯堡', + 'Africa/Nairobi': '内罗毕', + 'Africa/Casablanca': '卡萨布兰卡', + 'Australia/Sydney': '悉尼', + 'Pacific/Auckland': '奥克兰', +}; + +const _languageNames = { + 'id': {'zh': '印度尼西亚语', 'en': 'Indonesian', 'id': 'Bahasa Indonesia'}, + 'en': {'zh': '英语', 'en': 'English', 'id': 'Inggris'}, + 'zh': {'zh': '中文', 'en': 'Chinese', 'id': 'Mandarin'}, +}; + +const _statusNames = { + 'normal': {'zh': '正常', 'en': 'Active', 'id': 'Aktif'}, + 'limited': {'zh': '受限', 'en': 'Limited', 'id': 'Dibatasi'}, + 'banned': {'zh': '已封禁', 'en': 'Banned', 'id': 'Diblokir'}, + 'deactivated': {'zh': '已注销', 'en': 'Deactivated', 'id': 'Dinonaktifkan'}, +}; + +String _pick(Map names, String localeName) => + names[localeName] ?? names['en']!; + +/// 国家码 → 国家名;未收录的码原样返回。 +String countryName(String code, String localeName) { + final key = code.trim().toUpperCase(); + if (key.isEmpty) return ''; + final names = localeName == 'zh' ? _countryNamesZh : _countryNamesEn; + return names[key] ?? key; +} + +/// IANA 时区 → 城市名;中文界面查表,其余语言取时区里的城市段。 +String timezoneName(String timezone, String localeName) { + final value = timezone.trim(); + if (value.isEmpty) return ''; + if (localeName == 'zh') { + final name = _timezoneNamesZh[value]; + if (name != null) return name; + } + final city = value.split('/').last.replaceAll('_', ' '); + return city.isEmpty ? value : city; +} + +/// BCP-47 语言标签 → 语言名,按主语言归类(`zh-TW` 也算中文)。 +String languageName(String language, String localeName) { + final value = language.trim(); + if (value.isEmpty) return ''; + final primary = value.split(RegExp('[-_]')).first.toLowerCase(); + final names = _languageNames[primary]; + return names == null ? value : _pick(names, localeName); +} + +/// 账号状态 → 展示文案;未知状态原样返回。 +String accountStatusText(String status, String localeName) { + final value = status.trim().toLowerCase(); + final names = _statusNames[value]; + return names == null ? status : _pick(names, localeName); +} + +/// VIP 等级:0 表示未开通,避免用户看到一个孤零零的 0。 +String vipLevelText(int level, String localeName) { + if (level > 0) return 'VIP $level'; + return switch (localeName) { + 'zh' => '未开通', + 'id' => 'Belum aktif', + _ => 'Not subscribed', + }; +} diff --git a/lib/pages/account/account_profile_page.dart b/lib/pages/account/account_profile_page.dart index 87b15d4..d9994a8 100644 --- a/lib/pages/account/account_profile_page.dart +++ b/lib/pages/account/account_profile_page.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import '../../core/data/models/account_profile.dart'; import '../../core/data/repositories/account_repository.dart'; import '../../core/data/services/auth_controller.dart'; +import '../../core/formatting/account_display.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_date_time.dart'; import '../../core/theme/app_colors.dart'; @@ -225,6 +226,7 @@ class _AccountProfilePageState extends State { ); Widget _readOnlyCard(AccountProfile profile, AppLocalizations l10n) { + final localeName = Localizations.localeOf(context).languageCode; return _AccountCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -238,13 +240,34 @@ class _AccountProfilePageState extends State { ), const SizedBox(height: 10), _info(l10n.accountMaskedAccount, profile.maskedTarget), - _info(l10n.accountCountry, profile.countryCode), + _info( + l10n.accountCountry, + countryName(profile.countryCode, localeName), + ), _info(l10n.accountCurrency, profile.currency), - _info(l10n.accountStatus, profile.status), - _info(l10n.accountLanguage, profile.language), - _info(l10n.accountTimezone, profile.timezone), - _info(l10n.accountRegion, profile.regionCode), - _info(l10n.accountVipLevel, '${profile.vipLevel}'), + _info( + l10n.accountStatus, + accountStatusText(profile.status, localeName), + ), + _info( + l10n.accountLanguage, + languageName(profile.language, localeName), + ), + _info( + l10n.accountTimezone, + timezoneName(profile.timezone, localeName), + ), + // region 与 country 后端写的是同一个值,重复时不再单列一行 + if (profile.regionCode.isNotEmpty && + profile.regionCode != profile.countryCode) + _info( + l10n.accountRegion, + countryName(profile.regionCode, localeName), + ), + _info( + l10n.accountVipLevel, + vipLevelText(profile.vipLevel, localeName), + ), if (profile.createdAt != null) _info( l10n.accountCreatedAt, @@ -275,7 +298,7 @@ class _AccountProfilePageState extends State { ), Flexible( child: Text( - value.isEmpty ? '-' : value, + value.isEmpty ? '—' : value, textAlign: TextAlign.right, style: const TextStyle(fontSize: AppDimens.body), ), diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 3c57d6c..18ab58d 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -6,8 +6,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../../core/data/models/drama.dart'; +import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/auth_controller.dart'; +import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/locale_controller.dart'; import '../../core/data/services/wallet_controller.dart'; @@ -22,6 +25,7 @@ import '../downloads/downloads_page.dart'; import '../account/account_profile_page.dart'; import '../filter/filter_page.dart'; import '../notices/notices_page.dart'; +import '../player/open_player.dart'; import '../settings/settings_page.dart'; import '../store/vip_page.dart'; import '../wallet/wallet_page.dart'; @@ -222,6 +226,7 @@ class _MyPageState extends State { const SizedBox(height: AppDimens.mineSectionGap), MineContinueWatching( records: continueWatching, + onOpenRecord: _openContinueRecord, onOpenAll: _openHistoryTab, ), ], @@ -294,6 +299,36 @@ class _MyPageState extends State { unawaited(_openHistoryPage()); } + /// 继续观看卡片:补齐剧集后直接进全屏播放页,播放器自己会定位到记录的秒数。 + Future _openContinueRecord(WatchProgress record) async { + final catalog = context.read(); + // 续看记录只有剧的概要信息,先拼一个占位对象让目录去取完整详情 + final summary = Drama( + id: record.dramaId, + title: record.dramaTitle ?? '', + synopsis: '', + posterUrl: record.dramaCover ?? '', + ); + Drama drama; + try { + drama = await catalog.requireDetail(summary); + } catch (_) { + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.detailLoadFailed))); + return; + } + if (!mounted) return; + await playDramaFromCatalog( + context, + drama, + episodeId: record.episodeId.isEmpty ? null : record.episodeId, + ); + if (mounted) unawaited(logic.refreshRecent()); + } + Future _openHistoryPage() async { await _push(const WatchRecordsPage()); if (mounted) unawaited(logic.refreshRecent()); diff --git a/lib/pages/mine/widgets/mine_continue_watching.dart b/lib/pages/mine/widgets/mine_continue_watching.dart index ac06b13..b6c2c06 100644 --- a/lib/pages/mine/widgets/mine_continue_watching.dart +++ b/lib/pages/mine/widgets/mine_continue_watching.dart @@ -12,10 +12,14 @@ class MineContinueWatching extends StatelessWidget { const MineContinueWatching({ super.key, required this.records, + required this.onOpenRecord, required this.onOpenAll, }); final List records; + + /// 单张卡片:直接进入该剧的播放页,并定位到记录的集。 + final ValueChanged onOpenRecord; final VoidCallback onOpenAll; @override @@ -94,7 +98,11 @@ class MineContinueWatching extends StatelessWidget { ); } - Widget _card(AppLocalizations l10n, WatchProgress record, String languageCode) { + Widget _card( + AppLocalizations l10n, + WatchProgress record, + String languageCode, + ) { final title = record.localizedTitle(languageCode).trim(); final episode = record.episodeNumber; final hasProgress = record.duration > Duration.zero; @@ -103,7 +111,7 @@ class MineContinueWatching extends StatelessWidget { child: InkWell( key: Key('mine-continue-${record.dramaId}'), borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), - onTap: onOpenAll, + onTap: () => onOpenRecord(record), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/test/account/account_display_test.dart b/test/account/account_display_test.dart new file mode 100644 index 0000000..2b95e4b --- /dev/null +++ b/test/account/account_display_test.dart @@ -0,0 +1,40 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/formatting/account_display.dart'; + +void main() { + test('should_localize_country_name', () { + expect(countryName('ID', 'zh'), '印度尼西亚'); + expect(countryName('us', 'en'), 'United States'); + expect(countryName('TH', 'id'), 'Thailand'); + // 未收录的码原样返回,避免展示成空白 + expect(countryName('ZZ', 'zh'), 'ZZ'); + expect(countryName('', 'zh'), ''); + }); + + test('should_localize_timezone_to_city', () { + expect(timezoneName('Asia/Jakarta', 'zh'), '雅加达'); + expect(timezoneName('Asia/Jakarta', 'en'), 'Jakarta'); + expect( + timezoneName('America/Argentina/Buenos_Aires', 'en'), + 'Buenos Aires', + ); + expect(timezoneName('', 'zh'), ''); + }); + + test('should_localize_language_by_primary_tag', () { + expect(languageName('id-ID', 'zh'), '印度尼西亚语'); + expect(languageName('zh-TW', 'en'), 'Chinese'); + expect(languageName('en-US', 'id'), 'Inggris'); + expect(languageName('', 'zh'), ''); + }); + + test('should_localize_status_and_vip_level', () { + expect(accountStatusText('normal', 'zh'), '正常'); + expect(accountStatusText('banned', 'id'), 'Diblokir'); + expect(accountStatusText('weird', 'zh'), 'weird'); + expect(vipLevelText(0, 'zh'), '未开通'); + expect(vipLevelText(2, 'zh'), 'VIP 2'); + }); +} diff --git a/test/core/device_id_test.dart b/test/core/device_id_test.dart index 1ef86dc..699b1b0 100644 --- a/test/core/device_id_test.dart +++ b/test/core/device_id_test.dart @@ -8,9 +8,10 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p /// 可控的系统设备标识来源。 class _FakeDeviceIdProvider implements DeviceIdProvider { - _FakeDeviceIdProvider(this.value); + _FakeDeviceIdProvider(this.value, {this.region}); final String? value; + final String? region; int calls = 0; @override @@ -18,6 +19,9 @@ class _FakeDeviceIdProvider implements DeviceIdProvider { calls++; return value; } + + @override + Future deviceRegion() async => region; } void main() { @@ -62,4 +66,16 @@ void main() { expect(provider.calls, 1); }); + + test('should_return_device_region_or_empty_string', () async { + final store = AuthSessionStore( + deviceIdProvider: _FakeDeviceIdProvider(null, region: 'TH'), + ); + final missing = AuthSessionStore( + deviceIdProvider: _FakeDeviceIdProvider(null), + ); + + expect(await store.deviceRegion(), 'TH'); + expect(await missing.deviceRegion(), ''); + }); } diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index 2c9cfcc..7e8fe57 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -437,6 +437,24 @@ void main() { expect(find.byKey(const Key('following-history-grid')), findsOneWidget); }); + testWidgets('should_open_player_when_tapping_continue_watching_card', ( + tester, + ) async { + await _pumpApp( + tester, + signedIn: true, + history: InMemoryWatchHistoryRepository(seed: [_recent]), + ); + await _openMine(tester); + + await tester.tap(find.byKey(const Key('mine-continue-city-lights-demo'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-scaffold')), findsOneWidget); + // 继续观看直接进播放页,不应该先落到历史列表 + expect(find.byKey(const Key('following-page')), findsNothing); + }); + testWidgets('should_open_vip_from_mine_without_floating_player', ( tester, ) async { From c027acc91f6f82a646525a74391d5e493b78b277 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Tue, 27 Oct 2026 19:57:42 +0800 Subject: [PATCH 29/29] =?UTF-8?q?feat(recommend):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=8E=A8=E8=8D=90=E9=A2=91=E9=81=93=E9=A1=B5=E5=B9=B6=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E9=A6=96=E9=A1=B5=E6=B2=89=E6=B5=B8=E5=BC=8F=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tab1 推荐页改为接口分类频道:频道导航可横滑,选中频道自动滚动到可视区,内容区左右滑动切换分类 - 精选频道:顶部固定高度 banner 轮播图 + 接口 tag 筛选的上新短剧横滑 + 其余短剧双列网格;其他分类三列网格 - 分类内容支持懒加载、下拉刷新与上拉分页加载 - 卡片角标文案与背景色取自接口 tag/tagColor,短剧标题统一白色 - 恢复 tab0 为沉浸式全屏视频首页及其三 inner-tab 顶导(搜索/礼物入口) - l10n 新增原创出品、立即观看、上新、编辑精选、全N集等多语言文案 --- lib/core/theme/app_colors.dart | 9 +- lib/core/widgets/app_root_tab.dart | 20 +- lib/l10n/app_en.arb | 26 +- lib/l10n/app_id.arb | 31 +- lib/l10n/app_localizations.dart | 44 +- lib/l10n/app_localizations_en.dart | 29 +- lib/l10n/app_localizations_id.dart | 29 +- lib/l10n/app_localizations_zh.dart | 29 +- lib/l10n/app_zh.arb | 31 +- lib/pages/home/home_view.dart | 46 +- lib/pages/home/widgets/home_feed_card.dart | 2 +- lib/pages/player/player_view.dart | 1 + .../player/widgets/home_feed_overlay.dart | 43 +- .../widgets/home_feed_overlay_parts.dart | 13 +- .../widgets/home_feed_recommend_panel.dart | 437 ++++++++++------ .../player/widgets/interaction_rail.dart | 68 ++- .../recommend/recommend_discover_page.dart | 477 ++++++++++++++++++ lib/pages/recommend/recommend_page.dart | 5 + .../recommend/widgets/home_hero_carousel.dart | 396 +++++++++++++++ .../widgets/home_recommend_content.dart | 252 +++++++++ .../widgets/recommend_channel_nav.dart | 222 ++++++++ .../recommend/widgets/recommend_top_nav.dart | 126 +++-- 22 files changed, 2018 insertions(+), 318 deletions(-) create mode 100644 lib/pages/recommend/recommend_discover_page.dart create mode 100644 lib/pages/recommend/widgets/home_hero_carousel.dart create mode 100644 lib/pages/recommend/widgets/home_recommend_content.dart create mode 100644 lib/pages/recommend/widgets/recommend_channel_nav.dart diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index b6e52fa..1bfe192 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -133,9 +133,10 @@ abstract final class AppColors { /// 底部导航底色。 static const bar = ink; - /// 底部导航专用:纯黑背景、选中白与未选中灰。 + /// 底部导航专用:纯黑背景、选中品牌红与未选中白。 static const navBackground = Color(0xFF000000); - static const navSelected = Color(0xFFFFFFFF); + static const brandRed = Color(0xFFF04E4E); + static const navSelected = brandRed; /// 卡片阴影(深色几乎不用阴影)。 static const cardShadow = Color(0x00000000); @@ -188,8 +189,8 @@ abstract final class AppColors { /// 封面集数胶囊。 static const badgeBeige = raised; - /// 导航未选中。 - static const navUnselected = textSecondary; + /// 导航未选中(设计稿为白色线框 + 白色文字)。 + static const navUnselected = Color(0xFFFFFFFF); /// 首页内容角标。 static const badgeHot = Color(0xFFE5484D); diff --git a/lib/core/widgets/app_root_tab.dart b/lib/core/widgets/app_root_tab.dart index c9f0c96..c5a9c3f 100644 --- a/lib/core/widgets/app_root_tab.dart +++ b/lib/core/widgets/app_root_tab.dart @@ -7,18 +7,12 @@ import 'app_bottom_nav.dart'; /// 客户端根导航的固定顺序。 enum AppRootTab { - home( - assetPath: 'assets/icon/film_reel.png', - selectedAssetPath: 'assets/icon/film_reel_filled.png', - ), + home(), recommend(), following(), mine(); - const AppRootTab({this.assetPath, this.selectedAssetPath}); - - final String? assetPath; - final String? selectedAssetPath; + const AppRootTab(); } extension AppRootTabUi on AppRootTab { @@ -30,15 +24,15 @@ extension AppRootTabUi on AppRootTab { }; IconData get icon => switch (this) { - AppRootTab.home => Icons.theaters_outlined, - AppRootTab.recommend => Icons.smart_display_outlined, + AppRootTab.home => Icons.home_outlined, + AppRootTab.recommend => Icons.celebration_outlined, AppRootTab.following => Icons.bookmark_border_rounded, AppRootTab.mine => Icons.person_outline_rounded, }; IconData get selectedIcon => switch (this) { - AppRootTab.home => Icons.theaters, - AppRootTab.recommend => Icons.smart_display_rounded, + AppRootTab.home => Icons.home_rounded, + AppRootTab.recommend => Icons.celebration_rounded, AppRootTab.following => Icons.bookmark_rounded, AppRootTab.mine => Icons.person_rounded, }; @@ -75,8 +69,6 @@ class AppRootBottomNav extends StatelessWidget { tabKey: tab.tabKey, icon: tab.icon, selectedIcon: tab.selectedIcon, - assetPath: tab.assetPath, - selectedAssetPath: tab.selectedAssetPath, label: tab.label(l10n), selected: tab == selected, onTap: () => onSelected(tab), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bb5f156..0536a3e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -55,12 +55,24 @@ "viewers": {"type": "String"} } }, - "watchFullSeries": "Watch full series · {episodes} eps", + "watchFullSeries": "Watch full series {episodes} eps", "@watchFullSeries": { "placeholders": { "episodes": {"type": "String"} } }, + "feedEpisodePrefix": "EP{episode} | ", + "@feedEpisodePrefix": { + "placeholders": { + "episode": {"type": "int"} + } + }, + "feedWatchingCount": "{count} watching", + "@feedWatchingCount": { + "placeholders": { + "count": {"type": "String"} + } + }, "feedTabAnime": "Anime", "feedTabReal": "Live-action", "feedTabForYou": "For you", @@ -265,6 +277,18 @@ "pictureInPicture": "Picture in picture", "pipUnavailable": "Picture in picture is unavailable on this device", "originalTag": "Original", + "originalProduction": "Original", + "watchNowAction": "Watch Now", + "newArrivalBadge": "NEW", + "editorPick": "Editor's Pick", + "totalEpisodesShort": "{count} eps", + "@totalEpisodesShort": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "dramaTag": "Short drama", "episodeLabel": "Episode {number}", "@episodeLabel": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 3a2cf44..c6be6ec 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -49,7 +49,24 @@ "hotBadge": "Hits", "filterAll": "Semua", "seriesMeta": "Serial · {episodes} eps | {viewers} menonton", - "watchFullSeries": "Tonton serial lengkap · {episodes} eps", + "watchFullSeries": "Tonton serial lengkap {episodes} eps", + "@watchFullSeries": { + "placeholders": { + "episodes": {"type": "String"} + } + }, + "feedEpisodePrefix": "EP{episode} | ", + "@feedEpisodePrefix": { + "placeholders": { + "episode": {"type": "int"} + } + }, + "feedWatchingCount": "{count} menonton", + "@feedWatchingCount": { + "placeholders": { + "count": {"type": "String"} + } + }, "feedTabAnime": "Anime", "feedTabReal": "Live-action", "feedTabForYou": "Untukmu", @@ -239,6 +256,18 @@ "pictureInPicture": "Gambar dalam gambar", "pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini", "originalTag": "Original", + "originalProduction": "Original", + "watchNowAction": "Tonton Sekarang", + "newArrivalBadge": "BARU", + "editorPick": "Pilihan Editor", + "totalEpisodesShort": "{count} eps", + "@totalEpisodesShort": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "dramaTag": "Drama pendek", "episodeLabel": "Episode {number}", "@episodeLabel": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f9b1055..0975ce6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -397,9 +397,21 @@ abstract class AppLocalizations { /// No description provided for @watchFullSeries. /// /// In en, this message translates to: - /// **'Watch full series · {episodes} eps'** + /// **'Watch full series {episodes} eps'** String watchFullSeries(String episodes); + /// No description provided for @feedEpisodePrefix. + /// + /// In en, this message translates to: + /// **'EP{episode} | '** + String feedEpisodePrefix(int episode); + + /// No description provided for @feedWatchingCount. + /// + /// In en, this message translates to: + /// **'{count} watching'** + String feedWatchingCount(String count); + /// No description provided for @feedTabAnime. /// /// In en, this message translates to: @@ -1198,6 +1210,36 @@ abstract class AppLocalizations { /// **'Original'** String get originalTag; + /// No description provided for @originalProduction. + /// + /// In en, this message translates to: + /// **'Original'** + String get originalProduction; + + /// No description provided for @watchNowAction. + /// + /// In en, this message translates to: + /// **'Watch Now'** + String get watchNowAction; + + /// No description provided for @newArrivalBadge. + /// + /// In en, this message translates to: + /// **'NEW'** + String get newArrivalBadge; + + /// No description provided for @editorPick. + /// + /// In en, this message translates to: + /// **'Editor\'s Pick'** + String get editorPick; + + /// No description provided for @totalEpisodesShort. + /// + /// In en, this message translates to: + /// **'{count} eps'** + String totalEpisodesShort(int count); + /// No description provided for @dramaTag. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 82b3a5d..2ba335d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -162,7 +162,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String watchFullSeries(String episodes) { - return 'Watch full series · $episodes eps'; + return 'Watch full series $episodes eps'; + } + + @override + String feedEpisodePrefix(int episode) { + return 'EP$episode | '; + } + + @override + String feedWatchingCount(String count) { + return '$count watching'; } @override @@ -594,6 +604,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get originalTag => 'Original'; + @override + String get originalProduction => 'Original'; + + @override + String get watchNowAction => 'Watch Now'; + + @override + String get newArrivalBadge => 'NEW'; + + @override + String get editorPick => 'Editor\'s Pick'; + + @override + String totalEpisodesShort(int count) { + return '$count eps'; + } + @override String get dramaTag => 'Short drama'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 18bef15..4e9d1dd 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -162,7 +162,17 @@ class AppLocalizationsId extends AppLocalizations { @override String watchFullSeries(String episodes) { - return 'Tonton serial lengkap · $episodes eps'; + return 'Tonton serial lengkap $episodes eps'; + } + + @override + String feedEpisodePrefix(int episode) { + return 'EP$episode | '; + } + + @override + String feedWatchingCount(String count) { + return '$count menonton'; } @override @@ -595,6 +605,23 @@ class AppLocalizationsId extends AppLocalizations { @override String get originalTag => 'Original'; + @override + String get originalProduction => 'Original'; + + @override + String get watchNowAction => 'Tonton Sekarang'; + + @override + String get newArrivalBadge => 'BARU'; + + @override + String get editorPick => 'Pilihan Editor'; + + @override + String totalEpisodesShort(int count) { + return '$count eps'; + } + @override String get dramaTag => 'Drama pendek'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 1acfb79..dbf6af9 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -159,7 +159,17 @@ class AppLocalizationsZh extends AppLocalizations { @override String watchFullSeries(String episodes) { - return '观看完整剧集 · 全$episodes集'; + return '观看完整剧集 全$episodes集'; + } + + @override + String feedEpisodePrefix(int episode) { + return '第$episode集|'; + } + + @override + String feedWatchingCount(String count) { + return '共$count人在看'; } @override @@ -587,6 +597,23 @@ class AppLocalizationsZh extends AppLocalizations { @override String get originalTag => '原创'; + @override + String get originalProduction => '原创出品'; + + @override + String get watchNowAction => '立即观看'; + + @override + String get newArrivalBadge => '上新'; + + @override + String get editorPick => '编辑精选'; + + @override + String totalEpisodesShort(int count) { + return '全$count集'; + } + @override String get dramaTag => '短剧'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f6a4262..b94416d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -49,7 +49,24 @@ "hotBadge": "爆剧", "filterAll": "全部", "seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追", - "watchFullSeries": "观看完整剧集 · 全{episodes}集", + "watchFullSeries": "观看完整剧集 全{episodes}集", + "@watchFullSeries": { + "placeholders": { + "episodes": {"type": "String"} + } + }, + "feedEpisodePrefix": "第{episode}集|", + "@feedEpisodePrefix": { + "placeholders": { + "episode": {"type": "int"} + } + }, + "feedWatchingCount": "共{count}人在看", + "@feedWatchingCount": { + "placeholders": { + "count": {"type": "String"} + } + }, "feedTabAnime": "漫剧", "feedTabReal": "真人剧", "feedTabForYou": "推荐", @@ -239,6 +256,18 @@ "pictureInPicture": "画中画", "pipUnavailable": "此设备无法使用画中画", "originalTag": "原创", + "originalProduction": "原创出品", + "watchNowAction": "立即观看", + "newArrivalBadge": "上新", + "editorPick": "编辑精选", + "totalEpisodesShort": "全{count}集", + "@totalEpisodesShort": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "dramaTag": "短剧", "episodeLabel": "第 {number} 集", "@episodeLabel": { diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 24c26c6..66e2ce3 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -12,7 +12,6 @@ import '../../core/data/services/home_feed_controller.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/data/services/favorite_controller.dart'; -import '../../core/data/services/wallet_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; @@ -20,16 +19,13 @@ import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/app_root_tab.dart'; import '../discover/search_page.dart'; import '../following/following_page.dart'; -import '../filter/filter_page.dart'; import '../mine/mine_types.dart'; import '../recommend/recommend_page.dart'; +import '../recommend/recommend_discover_page.dart'; import '../mine/mine_view.dart'; -import '../store/vip_page.dart'; import '../wallet/wallet_page.dart'; import 'home_binding.dart'; import 'home_logic.dart'; -import 'widgets/home_feed_grid.dart'; -import 'widgets/home_grid_header.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -176,8 +172,8 @@ class _HomeTabsState extends State<_HomeTabs> { Widget _body(BuildContext context) { final pages = [ - _homeGrid(context), _recommend(context), + _recommendDiscover(context), FollowingPage( onGoHome: () => logic.select(0), favoriteController: context.read(), @@ -194,27 +190,23 @@ class _HomeTabsState extends State<_HomeTabs> { ); } - Widget _homeGrid(BuildContext context) { - return HomeFeedGrid( - feedController: widget.feedController, - callbacks: HomeGridCallbacks( - onSearch: () => _openSearch(context), - onOpenVip: () => unawaited(_openVip(context)), - onOpenWallet: () => unawaited(_openWallet(context)), - onOpenFilter: () => _pushPage(context, const FilterPage()), - ), - onOpenDrama: (drama) => unawaited(logic.playDrama(context, drama)), - ); - } - Widget _recommend(BuildContext context) { return RecommendPage( feedController: widget.feedController, catalogApi: _catalogApi(context), - active: logic.tab == 1, + active: logic.tab == 0, videoOnly: logic.videoImmersive, onImmersiveChanged: logic.onImmersiveChanged, onSearch: () => _openSearch(context), + onOpenWallet: () => unawaited(_openWallet(context)), + ); + } + + Widget _recommendDiscover(BuildContext context) { + return RecommendDiscoverPage( + active: logic.tab == 1, + onSearch: () => _openSearch(context), + onOpenWallet: () => unawaited(_openWallet(context)), ); } @@ -232,20 +224,6 @@ class _HomeTabsState extends State<_HomeTabs> { ).push(MaterialPageRoute(builder: (_) => const SearchPage())); } - void _pushPage(BuildContext context, Widget page) { - Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); - } - - Future _openVip(BuildContext context) async { - if (!await ensureSignedIn(context) || !context.mounted) return; - final wallet = context.read(); - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => VipPage(onPurchased: () => unawaited(wallet.load())), - ), - ); - } - Future _openWallet(BuildContext context) async { if (!await ensureSignedIn(context) || !context.mounted) return; await Navigator.of( diff --git a/lib/pages/home/widgets/home_feed_card.dart b/lib/pages/home/widgets/home_feed_card.dart index fa002d9..e8aeaa5 100644 --- a/lib/pages/home/widgets/home_feed_card.dart +++ b/lib/pages/home/widgets/home_feed_card.dart @@ -50,7 +50,7 @@ class HomeFeedCard extends StatelessWidget { overflow: TextOverflow.ellipsis, textAlign: TextAlign.left, style: const TextStyle( - color: AppColors.navSelected, + color: AppColors.textPrimary, fontSize: AppDimens.cardTitleFont, fontWeight: FontWeight.w500, height: AppDimens.cardTitleLineHeight, diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 357008f..6aede6d 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -1200,6 +1200,7 @@ class _PlayerPageState extends State unawaited(_presentFullPlayer(context, index)); }, onEpisodes: () => logic.showEpisodes(context), + onOpenSearch: widget.onSearch, onMore: _openCurrentMore, onScrubStart: videoReady ? () => logic.beginScrub(videoPort!) diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index dc10f8b..b281f23 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -22,11 +22,13 @@ part 'home_feed_overlay_parts.dart'; const double _progressHeight = 2; const double _progressHitHeight = 32; -const double _pageHorizontal = 14; -const double _railRight = 8; +const double _pageHorizontal = 20; +const double _railRight = 14; const double _railGap = 12; -const double _bottomInfoInset = 32; -const double _railBottomOffset = 60; +// 观看条高 44;2pt 进度线视觉上沿距观看条顶约 7pt(线中心距进度条盒底 8)。 +// 信息面板底边取 44+16=60,使简介↔进度线、进度线↔观看条两侧间距均为 7pt。 +const double _bottomInfoInset = homeFeedWatchAllHeight + 16; +const double _railBottomOffset = 40; const Duration _overlayFadeDuration = Duration(milliseconds: 120); const List _boostTextShadows = [ Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)), @@ -52,6 +54,7 @@ class HomeFeedOverlay extends StatefulWidget { required this.onDetail, required this.onOpenFullPlayer, this.onEpisodes, + this.onOpenSearch, required this.onTogglePlay, required this.onBoostStart, required this.onBoostEnd, @@ -92,6 +95,9 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback onDetail; final VoidCallback onOpenFullPlayer; final VoidCallback? onEpisodes; + + /// 底部热搜胶囊点击(降级版:跳搜索页)。 + final VoidCallback? onOpenSearch; final VoidCallback onTogglePlay; final VoidCallback onBoostStart; final VoidCallback onBoostEnd; @@ -173,7 +179,7 @@ class _HomeFeedOverlayState extends State { Positioned( left: 0, right: 0, - bottom: 20, + bottom: homeFeedWatchAllHeight + 12, child: AnimatedOpacity( duration: _overlayFadeDuration, opacity: widget.boosting ? 1 : 0, @@ -184,9 +190,9 @@ class _HomeFeedOverlayState extends State { ), ), Positioned( - left: 0, - right: 0, - bottom: 0, + left: _pageHorizontal, + right: _pageHorizontal, + bottom: homeFeedWatchAllHeight, child: _FeedProgressBar( controller: widget.controller, locked: widget.locked, @@ -203,6 +209,24 @@ class _HomeFeedOverlayState extends State { onSeek: widget.onSeek, ), ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: AnimatedOpacity( + duration: _overlayFadeDuration, + opacity: widget.visible && !_scrubbing && !widget.boosting + ? 1 + : 0, + child: IgnorePointer( + ignoring: !widget.visible || _scrubbing || widget.boosting, + child: HomeFeedWatchAllBar( + drama: widget.drama, + onTap: widget.onOpenFullPlayer, + ), + ), + ), + ), ], ), ); @@ -273,7 +297,7 @@ class _HomeFeedOverlayState extends State { onSave: widget.onSave, onLike: widget.onLike, onComments: widget.onComments, - onEpisodes: widget.onEpisodes ?? widget.onOpenFullPlayer, + onShare: widget.onShare, ); } @@ -283,6 +307,7 @@ class _HomeFeedOverlayState extends State { episode: widget.episode, onDetail: widget.onDetail, onOpenFullPlayer: widget.onOpenFullPlayer, + onOpenSearch: widget.onOpenSearch, ); } } diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 3f534cc..d938d2d 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -6,7 +6,6 @@ const double _thumbSize = 6; const double _thumbActiveSize = 14; const double _trackActiveHeight = 6; const double _lineCenterFromBottom = 8; -const Duration _trackResizeDuration = Duration(milliseconds: 120); const double _timeBubbleWidth = 96; const double _timeBubbleMargin = 12; const Key _inactiveTrackKey = Key('home-feed-progress-inactive'); @@ -371,10 +370,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { children: [ Align( alignment: Alignment.centerLeft, - child: AnimatedContainer( + child: Container( key: const Key('home-feed-progress-track'), - duration: _trackResizeDuration, - curve: Curves.easeOut, width: width, height: trackHeight, color: AppColors.border, @@ -382,10 +379,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { ), Align( alignment: Alignment.centerLeft, - child: AnimatedContainer( + child: Container( key: const Key('home-feed-progress-played'), - duration: dragging ? Duration.zero : _trackResizeDuration, - curve: Curves.easeOut, width: playedWidth, height: trackHeight, color: AppColors.blue, @@ -401,10 +396,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { child: SizedBox.square( dimension: _thumbActiveSize, child: Center( - child: AnimatedContainer( + child: Container( key: const Key('home-feed-progress-thumb'), - duration: _trackResizeDuration, - curve: Curves.easeOut, width: size, height: size, decoration: const BoxDecoration( diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart index 56c049f..7352b31 100644 --- a/lib/pages/player/widgets/home_feed_recommend_panel.dart +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -1,8 +1,7 @@ // Author: fruit.com.cn -import 'dart:math' as math; - import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/formatting/localized_content.dart'; @@ -11,15 +10,22 @@ import '../../../l10n/app_localizations.dart'; import '../../home/widgets/home_drama_classification.dart'; import 'interaction_rail.dart'; -const double _seriesFont = 19; -const double _tagFont = 13; -const double _synopsisFont = 13; +const double _seriesFont = 21; +const double _tagFont = 14; +const double _synopsisFont = 15; const double _watchAllHeight = 44; const List _textShadows = [ Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)), ]; final RegExp _tagSplitter = RegExp(r'[,/、·]'); +/// 通栏「观看完整剧集」半透明白底条高度,overlay 用它安排底部避让。 +const double homeFeedWatchAllHeight = _watchAllHeight; + +/// 播放量紧凑展示(跟随语言):zh 20万 / en 200K / id 200 rb。 +String _compactViewers(int count, Locale locale) => + NumberFormat.compact(locale: locale.languageCode).format(count); + class HomeFeedInfoPanel extends StatefulWidget { const HomeFeedInfoPanel({ super.key, @@ -27,6 +33,7 @@ class HomeFeedInfoPanel extends StatefulWidget { required this.episode, required this.onDetail, required this.onOpenFullPlayer, + this.onOpenSearch, }); final Drama drama; @@ -34,6 +41,9 @@ class HomeFeedInfoPanel extends StatefulWidget { final VoidCallback onDetail; final VoidCallback onOpenFullPlayer; + /// 热搜胶囊点击;为空或播放量为 0 时不显示胶囊。 + final VoidCallback? onOpenSearch; + @override State createState() => _HomeFeedInfoPanelState(); } @@ -48,9 +58,22 @@ class _HomeFeedInfoPanelState extends State { final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim(); final genres = _genreValues(Localizations.localeOf(context).languageCode); return Column( + key: const Key('home-feed-info-panel'), mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (widget.onOpenSearch != null && widget.drama.viewCount > 0) ...[ + _HotSearchPill( + label: l10n.feedWatchingCount( + _compactViewers( + widget.drama.viewCount, + Localizations.localeOf(context), + ), + ), + onTap: widget.onOpenSearch!, + ), + const SizedBox(height: 8), + ], GestureDetector( key: const Key('home-feed-series-name'), behavior: HitTestBehavior.opaque, @@ -67,12 +90,12 @@ class _HomeFeedInfoPanelState extends State { style: const TextStyle( color: AppColors.textPrimary, fontSize: _seriesFont, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, shadows: _textShadows, ), ), ), - const SizedBox(width: 4), + const SizedBox(width: 2), const Icon( Icons.chevron_right_rounded, size: 20, @@ -81,119 +104,96 @@ class _HomeFeedInfoPanelState extends State { ], ), ), - const SizedBox(height: 8), + const SizedBox(height: 6), _tagMetaRow(l10n, genres), if (synopsis.isNotEmpty) ...[ const SizedBox(height: 8), _synopsis(l10n, synopsis), ], - const SizedBox(height: 12), - _watchAll(context, l10n), ], ); } Widget _tagMetaRow(AppLocalizations l10n, List genres) { - return Row( + final chips = [ + if (isHotDrama(widget.drama)) + _TagChip( + key: const Key('home-feed-hot-badge'), + label: l10n.homeBadgeHot, + filled: true, + ), + for (final genre in genres) + _TagChip(key: Key('home-feed-tag-$genre'), label: genre), + ]; + return SizedBox( key: const Key('home-feed-tags'), - children: [ - if (isHotDrama(widget.drama)) ...[ - Container( - key: const Key('home-feed-hot-badge'), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppColors.error, - borderRadius: BorderRadius.circular(3), - ), - child: Text( - l10n.homeBadgeHot, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 10, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(width: 8), - ], - Flexible( - fit: FlexFit.loose, - child: Text( - genres.join(' '), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.overlayLabel, - fontSize: _tagFont, - ), - ), - ), - const SizedBox(width: 8), - Flexible( - child: Text( - l10n.favoriteEpisodeProgress( - widget.episode.number, - widget.drama.episodeCount, - ), - key: const Key('home-feed-episode-progress'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.overlayLabel, - fontSize: _tagFont, - ), - ), - ), - ], + height: 25, + child: ListView.separated( + scrollDirection: Axis.horizontal, + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: chips.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (_, index) => chips[index], + ), ); } Widget _synopsis(AppLocalizations l10n, String synopsis) { - const style = TextStyle( - color: AppColors.overlaySecondary, + final prefix = l10n.feedEpisodePrefix(widget.episode.number); + final baseStyle = const TextStyle( + color: AppColors.textPrimary, fontSize: _synopsisFont, - height: 1.35, + height: 1.3, shadows: _textShadows, ); + final span = TextSpan( + children: [ + TextSpan( + text: prefix, + style: baseStyle.copyWith(fontWeight: FontWeight.w600), + ), + TextSpan(text: synopsis, style: baseStyle), + ], + ); + if (_synopsisExpanded) { + return _expandedSynopsis(span, baseStyle, l10n); + } return LayoutBuilder( builder: (context, constraints) { + final toggle = _synopsisToggle( + key: const Key('home-feed-synopsis-more'), + label: l10n.expandSynopsis, + ); final painter = TextPainter( - text: TextSpan(text: synopsis, style: style), - maxLines: 2, + text: span, + maxLines: 1, textDirection: Directionality.of(context), textScaler: MediaQuery.textScalerOf(context), )..layout(maxWidth: constraints.maxWidth); - final overflows = painter.didExceedMaxLines; - if (!overflows) { - return Text( - synopsis, + if (!painter.didExceedMaxLines) { + return Text.rich( + span, key: const Key('home-feed-synopsis'), - style: style, + maxLines: 1, + style: baseStyle, ); } - if (_synopsisExpanded) { - return _expandedSynopsis(constraints.maxWidth, synopsis, style, l10n); - } - return Stack( + // 设计稿:单行省略号 + 行内固定「展开」。 + return Row( + key: const Key('home-feed-synopsis-collapsed'), children: [ - Padding( - padding: const EdgeInsets.only(right: 40), - child: Text( - synopsis, + Expanded( + child: Text.rich( + span, key: const Key('home-feed-synopsis'), - maxLines: 2, + maxLines: 1, overflow: TextOverflow.ellipsis, - style: style, - ), - ), - Positioned( - right: 0, - bottom: 0, - child: _synopsisToggle( - key: const Key('home-feed-synopsis-more'), - label: l10n.expandSynopsis, + style: baseStyle, ), ), + const SizedBox(width: 2), + toggle, ], ); }, @@ -201,26 +201,28 @@ class _HomeFeedInfoPanelState extends State { } Widget _expandedSynopsis( - double maxWidth, - String synopsis, + InlineSpan span, TextStyle style, AppLocalizations l10n, ) { - return SizedBox( - width: maxWidth, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(synopsis, key: const Key('home-feed-synopsis'), style: style), - Align( - alignment: Alignment.centerRight, - child: _synopsisToggle( - key: const Key('home-feed-synopsis-more'), - label: l10n.showLess, - ), + return Column( + key: const Key('home-feed-synopsis-expanded'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( + span, + key: const Key('home-feed-synopsis'), + style: style, + ), + const SizedBox(height: 2), + Align( + alignment: Alignment.centerRight, + child: _synopsisToggle( + key: const Key('home-feed-synopsis-more'), + label: l10n.showLess, ), - ], - ), + ), + ], ); } @@ -234,54 +236,12 @@ class _HomeFeedInfoPanelState extends State { color: AppColors.textPrimary, fontSize: _synopsisFont, fontWeight: FontWeight.w600, + shadows: _textShadows, ), ), ); } - Widget _watchAll(BuildContext context, AppLocalizations l10n) { - final screenWidth = MediaQuery.sizeOf(context).width; - return LayoutBuilder( - builder: (context, constraints) { - final buttonWidth = math.min(screenWidth * 0.7, constraints.maxWidth); - return Align( - alignment: Alignment.centerLeft, - child: SizedBox( - width: buttonWidth, - height: _watchAllHeight, - child: DecoratedBox( - key: const Key('home-feed-full-series-bg'), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppColors.actionRed, AppColors.blue], - ), - borderRadius: BorderRadius.circular(8), - ), - child: Material( - color: AppColors.transparent, - child: InkWell( - key: const Key('home-feed-full-series'), - onTap: widget.onOpenFullPlayer, - borderRadius: BorderRadius.circular(8), - child: Center( - child: Text( - l10n.homeWatchAll, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 15, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - ), - ), - ); - }, - ); - } - List _genreValues(String languageCode) { final localized = widget.drama.localizedTypeLabels(languageCode); if (localized.isNotEmpty) return localized.take(3).toList(growable: false); @@ -297,6 +257,147 @@ class _HomeFeedInfoPanelState extends State { } } +/// 热搜榜降级胶囊:🔍 共 xxx 人在看 ›(暂无榜单接口,点击跳搜索页)。 +class _HotSearchPill extends StatelessWidget { + const _HotSearchPill({required this.label, required this.onTap}); + + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + key: const Key('home-feed-hot-search-pill'), + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: AppColors.textPrimary.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.search_rounded, + size: 15, + color: AppColors.textPrimary, + ), + const SizedBox(width: 5), + Text( + label, + maxLines: 1, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 2), + const Icon( + Icons.chevron_right_rounded, + size: 16, + color: AppColors.textPrimary, + ), + ], + ), + ), + ); + } +} + +/// 热剧红胶囊 / 类型深色半透明填充胶囊。 +class _TagChip extends StatelessWidget { + const _TagChip({super.key, required this.label, this.filled = false}); + + final String label; + final bool filled; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2), + alignment: Alignment.center, + decoration: BoxDecoration( + color: filled + ? AppColors.brandRed + : AppColors.textPrimary.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + maxLines: 1, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _tagFont, + fontWeight: FontWeight.w500, + height: 1.2, + shadows: _textShadows, + ), + ), + ); + } +} + +/// 底部通栏条:🎬 观看完整剧集 全 N 集。 +class HomeFeedWatchAllBar extends StatelessWidget { + const HomeFeedWatchAllBar({ + super.key, + required this.drama, + required this.onTap, + }); + + final Drama drama; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Material( + key: const Key('home-feed-full-series-bg'), + color: AppColors.textPrimary.withValues(alpha: 0.15), + child: InkWell( + key: const Key('home-feed-full-series'), + onTap: onTap, + child: SizedBox( + height: _watchAllHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + const Icon( + Icons.movie_rounded, + size: 22, + color: AppColors.onPrimary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + l10n.watchFullSeries('${drama.episodeCount}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + size: 22, + color: AppColors.textPrimary, + ), + ], + ), + ), + ), + ), + ); + } +} + class HomeFeedActionRail extends StatelessWidget { const HomeFeedActionRail({ super.key, @@ -305,7 +406,7 @@ class HomeFeedActionRail extends StatelessWidget { required this.onSave, required this.onLike, required this.onComments, - required this.onEpisodes, + required this.onShare, this.favoriteCount, this.likeCount, this.commentCount, @@ -319,13 +420,17 @@ class HomeFeedActionRail extends StatelessWidget { final VoidCallback onSave; final VoidCallback onLike; final VoidCallback onComments; - final VoidCallback onEpisodes; + final VoidCallback onShare; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return InteractionRail( - itemGap: 14, + itemGap: 18, + buttonSize: 48, + iconSize: 36, + countFontSize: 15, + iconCountGap: 6, items: [ InteractionRailItem( key: const Key('rail-save'), @@ -334,18 +439,9 @@ class HomeFeedActionRail extends StatelessWidget { count: favoriteCount, countKey: const Key('rail-save-count'), selected: saved, + selectedColor: AppColors.brandRed, onTap: onSave, ), - InteractionRailItem( - key: const Key('rail-like'), - asset: 'assets/icon/rail/rail_like.png', - label: l10n.like, - count: likeCount, - countKey: const Key('rail-like-count'), - selected: liked, - selectedColor: AppColors.doubleTapHeartRed, - onTap: onLike, - ), InteractionRailItem( key: const Key('rail-comments'), asset: 'assets/icon/rail/rail_comment.png', @@ -355,11 +451,22 @@ class HomeFeedActionRail extends StatelessWidget { onTap: onComments, ), InteractionRailItem( - key: const Key('rail-episodes'), - icon: Icons.layers_rounded, - label: l10n.episodes, - countKey: const Key('rail-episodes-label'), - onTap: onEpisodes, + key: const Key('rail-like'), + asset: 'assets/icon/rail/rail_like.png', + label: l10n.like, + count: likeCount, + countKey: const Key('rail-like-count'), + selected: liked, + selectedColor: AppColors.brandRed, + onTap: onLike, + ), + InteractionRailItem( + key: const Key('rail-share'), + icon: Icons.reply_rounded, + mirrorIcon: true, + label: l10n.share, + countKey: const Key('rail-share-label'), + onTap: onShare, ), ], ); diff --git a/lib/pages/player/widgets/interaction_rail.dart b/lib/pages/player/widgets/interaction_rail.dart index 38f1369..1b3f3ba 100644 --- a/lib/pages/player/widgets/interaction_rail.dart +++ b/lib/pages/player/widgets/interaction_rail.dart @@ -5,8 +5,10 @@ import 'package:flutter/material.dart'; import '../../../core/formatting/count_format.dart'; import '../../../core/theme/app_colors.dart'; -const double _buttonSize = 44; -const double _iconSize = 32; +const double _defaultButtonSize = 44; +const double _defaultIconSize = 32; +const double _defaultCountFont = 12; +const double _defaultIconCountGap = 2; const double _defaultItemGap = 24; /// 互动栏单项:图标、计数与点击。 @@ -21,6 +23,7 @@ class InteractionRailItem { this.countKey, this.selected = false, this.selectedColor = AppColors.blue, + this.mirrorIcon = false, }) : assert((asset == null) != (icon == null)); final Key key; @@ -32,6 +35,9 @@ class InteractionRailItem { final Key? countKey; final bool selected; final Color selectedColor; + + /// 水平镜像图标(分享弯箭头用 reply 镜像得到)。 + final bool mirrorIcon; } /// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。 @@ -40,10 +46,18 @@ class InteractionRail extends StatelessWidget { super.key, required this.items, this.itemGap = _defaultItemGap, + this.buttonSize = _defaultButtonSize, + this.iconSize = _defaultIconSize, + this.countFontSize = _defaultCountFont, + this.iconCountGap = _defaultIconCountGap, }); final List items; final double itemGap; + final double buttonSize; + final double iconSize; + final double countFontSize; + final double iconCountGap; @override Widget build(BuildContext context) { @@ -52,7 +66,13 @@ class InteractionRail extends StatelessWidget { children: [ for (var index = 0; index < items.length; index++) ...[ if (index > 0) SizedBox(height: itemGap), - _RailButton(item: items[index]), + _RailButton( + item: items[index], + buttonSize: buttonSize, + iconSize: iconSize, + countFontSize: countFontSize, + iconCountGap: iconCountGap, + ), ], ], ); @@ -60,53 +80,61 @@ class InteractionRail extends StatelessWidget { } class _RailButton extends StatelessWidget { - const _RailButton({required this.item}); + const _RailButton({ + required this.item, + required this.buttonSize, + required this.iconSize, + required this.countFontSize, + required this.iconCountGap, + }); final InteractionRailItem item; + final double buttonSize; + final double iconSize; + final double countFontSize; + final double iconCountGap; @override Widget build(BuildContext context) { + final tint = item.selected ? item.selectedColor : AppColors.onPrimary; return Column( key: item.key, mainAxisSize: MainAxisSize.min, children: [ SizedBox.square( - dimension: _buttonSize, + dimension: buttonSize, child: IconButton( onPressed: item.onTap, padding: EdgeInsets.zero, icon: item.asset != null ? Image.asset( item.asset!, - width: _iconSize, - height: _iconSize, + width: iconSize, + height: iconSize, fit: BoxFit.contain, - color: item.selected - ? item.selectedColor - : AppColors.onPrimary, + color: tint, colorBlendMode: BlendMode.srcIn, ) - : Icon( - item.icon, - size: _iconSize, - color: item.selected - ? item.selectedColor - : AppColors.onPrimary, + : Transform.flip( + flipX: item.mirrorIcon, + child: Icon(item.icon, size: iconSize, color: tint), ), ), ), - const SizedBox(height: 2), + SizedBox(height: iconCountGap), Text( item.count == null ? item.label : formatInteractionCount(item.count!), key: item.countKey, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.onPrimary, height: 1, - fontSize: 12, + fontSize: countFontSize, fontWeight: FontWeight.w600, - shadows: [Shadow(blurRadius: 6, color: AppColors.mediaBlack)], + shadows: const [ + Shadow(blurRadius: 6, color: AppColors.mediaBlack), + ], ), ), ], diff --git a/lib/pages/recommend/recommend_discover_page.dart b/lib/pages/recommend/recommend_discover_page.dart new file mode 100644 index 0000000..e9ebe34 --- /dev/null +++ b/lib/pages/recommend/recommend_discover_page.dart @@ -0,0 +1,477 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/drama.dart'; +import '../../core/data/models/home_feed.dart'; +import '../../core/data/remote/catalog_api.dart'; +import '../../core/data/services/category_controller.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/paged_list_footer.dart'; +import '../../l10n/app_localizations.dart'; +import '../player/open_player.dart'; +import 'widgets/home_hero_carousel.dart'; +import 'widgets/home_recommend_content.dart'; +import 'widgets/recommend_channel_nav.dart'; + +/// 底部第二个 tab「推荐」(礼花)。 +/// +/// 频道全部来自分类接口;内容区是一个横向 [PageView],左右滑动即可切换频道, +/// 顶部频道导航随之高亮,并把选中频道滚动到可见区域。 +/// 第一个频道(接口的「精选」)在网格顶部额外挂一张 banners 轮播图, +/// 轮播数据来自 `GET /app/home/feed`。 +class RecommendDiscoverPage extends StatefulWidget { + const RecommendDiscoverPage({ + super.key, + required this.active, + required this.onSearch, + this.onOpenWallet, + }); + + final bool active; + final VoidCallback onSearch; + + /// 顶部金色礼物盒(钱包入口)。 + final VoidCallback? onOpenWallet; + + @override + State createState() => _RecommendDiscoverPageState(); +} + +class _RecommendDiscoverPageState extends State + with AutomaticKeepAliveClientMixin { + /// 当前频道 id;频道全部来自分类接口,首项即「精选」。 + String? _channelId; + final Set _requestedCategories = {}; + final PageController _pageController = PageController(); + + /// `GET /app/home/feed` 返回的轮播图,只挂在第一个「精选」频道。 + List _banners = const []; + bool _bannersRequested = false; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadBanners()); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + /// 点击顶部频道:更新高亮并让 PageView 滑到对应页。 + void _selectChannel(String id, int index) { + if (id == _channelId && _pageController.hasClients) { + // 已在该页时仍允许回到顶部对齐。 + return; + } + setState(() => _channelId = id); + _ensureCategory(id); + if (_pageController.hasClients) { + unawaited( + _pageController.animateToPage( + index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ), + ); + } + } + + /// 左右滑动翻页:更新高亮(不反向驱动 PageView)。 + void _onPageChanged(int index, List orderedIds) { + if (index < 0 || index >= orderedIds.length) return; + final id = orderedIds[index]; + if (id != _channelId) setState(() => _channelId = id); + _ensureCategory(id); + } + + void _ensureCategory(String id) { + if (_requestedCategories.contains(id)) return; + _requestedCategories.add(id); + final categories = context.read(); + if (categories.dramaPageLoader == null) return; + final slot = categories.slotOf(id); + if (slot.dramas.isNotEmpty || slot.loading) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(categories.openCategory(id)); + }); + } + + /// 下拉刷新:重拉该分类第一页;精选频道同时刷新轮播图。 + Future _refreshCategory(String id, bool isFeatured) async { + final categories = context.read(); + final tasks = >[categories.openCategory(id)]; + if (isFeatured) tasks.add(_loadBanners(force: true)); + await Future.wait(tasks); + } + + /// 轮播图只依赖 home/feed 的 banners;接口缺失(如测试注入)时静默跳过, + /// 不影响下方分类网格。 + Future _loadBanners({bool force = false}) async { + if (_bannersRequested && !force) return; + _bannersRequested = true; + CatalogApi? api; + try { + api = context.read(); + } on ProviderNotFoundException { + api = null; + } + if (api == null) return; + try { + final feed = await api.homeFeed(); + if (mounted && feed.banners.isNotEmpty) { + setState(() => _banners = feed.banners); + } + } catch (_) { + // 轮播拉取失败时保留纯网格。 + } + } + + Future _openDrama(Drama drama) => + playDramaFromCatalog(context, drama); + + @override + Widget build(BuildContext context) { + super.build(context); + final categories = context.watch(); + final mediaTop = MediaQuery.of(context).padding.top; + final categoryList = categories.categories; + final orderedIds = categoryList.map((c) => c.id).toList(); + + // 频道来自接口分类;默认选中第一个分类(即「精选」)。 + _channelId ??= categoryList.isEmpty ? null : categoryList.first.id; + final selectedIndex = _channelId == null + ? 0 + : orderedIds.indexOf(_channelId!).clamp(0, orderedIds.length - 1); + + return ColoredBox( + key: const Key('recommend-page'), + color: AppColors.appBackground, + child: Stack( + children: [ + Positioned.fill( + child: categoryList.isEmpty + ? const Center( + child: CircularProgressIndicator( + color: AppColors.brandRed, + ), + ) + : PageView.builder( + key: const Key('recommend-channel-pager'), + controller: _pageController, + itemCount: categoryList.length, + onPageChanged: (i) => _onPageChanged(i, orderedIds), + itemBuilder: (context, index) { + final category = categoryList[index]; + return _ChannelFeed( + key: PageStorageKey( + 'recommend-channel-${category.id}', + ), + categoryId: category.id, + isFeatured: index == 0, + banners: _banners, + carouselActive: widget.active && index == selectedIndex, + mediaTop: mediaTop, + onRequest: _ensureCategory, + onRefresh: () => _refreshCategory(category.id, index == 0), + onOpenDrama: _openDrama, + ); + }, + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: IgnorePointer( + child: SizedBox( + height: mediaTop + RecommendChannelNav.height, + child: const DecoratedBox( + key: Key('recommend-top-gradient'), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.mediaBlack, AppColors.transparent], + ), + ), + ), + ), + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + bottom: false, + child: RecommendChannelNav( + categories: categoryList, + selectedId: _channelId, + onSelected: (id) { + final index = orderedIds.indexOf(id); + if (index >= 0) _selectChannel(id, index); + }, + onSearch: widget.onSearch, + onGift: widget.onOpenWallet, + ), + ), + ), + ], + ), + ); + } +} + +/// 单个分类频道的内容:可选顶部轮播 + 该分类短剧三列网格。 +class _ChannelFeed extends StatefulWidget { + const _ChannelFeed({ + super.key, + required this.categoryId, + required this.isFeatured, + required this.banners, + required this.carouselActive, + required this.mediaTop, + required this.onRequest, + required this.onRefresh, + required this.onOpenDrama, + }); + + final String categoryId; + final bool isFeatured; + final List banners; + final bool carouselActive; + final double mediaTop; + final ValueChanged onRequest; + final Future Function() onRefresh; + final ValueChanged onOpenDrama; + + @override + State<_ChannelFeed> createState() => _ChannelFeedState(); +} + +class _ChannelFeedState extends State<_ChannelFeed> + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + widget.onRequest(widget.categoryId); + } + + Drama? _dramaForBanner(FeedBanner banner, List pool) { + final id = banner.dramaId; + if (id == null) return null; + for (final drama in pool) { + if (drama.id == id) return drama; + } + return null; + } + + @override + Widget build(BuildContext context) { + super.build(context); + final categories = context.watch(); + final slot = categories.slotOf(widget.categoryId); + final dramas = slot.dramas; + final showCarousel = widget.isFeatured && widget.banners.isNotEmpty; + final bottomInset = MediaQuery.of(context).padding.bottom; + + // 精选频道:按接口下发的标签(tag)把「上新」筛出来横滑,其余短剧一行两个。 + List newArrivals = const []; + List rest = dramas; + if (widget.isFeatured && dramas.isNotEmpty) { + final lang = Localizations.localeOf(context).languageCode; + final badge = AppLocalizations.of(context)!.newArrivalBadge; + newArrivals = dramas + .where((drama) => _isNewArrivalTag(drama, lang, badge)) + .toList(growable: false); + if (newArrivals.isNotEmpty) { + final newIds = newArrivals.map((drama) => drama.id).toSet(); + rest = dramas + .where((drama) => !newIds.contains(drama.id)) + .toList(growable: false); + } + } + + final sliverList = []; + if (showCarousel) { + sliverList.add( + SliverToBoxAdapter( + child: HomeHeroCarousel( + banners: widget.banners, + active: widget.carouselActive, + dramaFor: (banner) => _dramaForBanner(banner, dramas), + onWatch: widget.onOpenDrama, + ), + ), + ); + } + if (dramas.isEmpty) { + sliverList.add( + SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: EdgeInsets.only( + top: showCarousel ? 0 : widget.mediaTop + RecommendChannelNav.height, + ), + child: Center(child: _placeholder(slot.loading)), + ), + ), + ); + } else if (widget.isFeatured) { + // 无轮播图时内容需避开顶部悬浮导航。 + final contentTop = + showCarousel ? 24.0 : widget.mediaTop + RecommendChannelNav.height + 16; + if (newArrivals.isNotEmpty) { + sliverList.add( + SliverToBoxAdapter(child: SizedBox(height: contentTop)), + ); + sliverList.add( + SliverToBoxAdapter( + child: HomeNewArrivalRow( + dramas: newArrivals, + onOpen: widget.onOpenDrama, + ), + ), + ); + sliverList.add( + const SliverToBoxAdapter(child: SizedBox(height: 28)), + ); + } + sliverList.add( + _gridSliver( + rest, + topPadding: newArrivals.isNotEmpty ? 0 : contentTop, + crossAxisCount: 2, + ), + ); + } else { + sliverList.add( + _gridSliver( + dramas, + topPadding: showCarousel ? 20 : widget.mediaTop + RecommendChannelNav.height + 16, + crossAxisCount: 3, + ), + ); + } + sliverList.add( + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: PagedListFooter( + key: const Key('recommend-category-footer'), + loading: slot.loadingMore, + hasMore: slot.hasMore, + hasItems: dramas.isNotEmpty, + errorMessage: null, + onRetry: () => categories.loadMore(widget.categoryId), + ), + ), + ), + ); + + return NotificationListener( + onNotification: (notification) { + if (notification.depth != 0) return false; + if (notification.metrics.extentAfter > 300) return false; + if (notification is! ScrollUpdateNotification && + notification is! ScrollEndNotification && + notification is! OverscrollNotification) { + return false; + } + unawaited(categories.loadMore(widget.categoryId)); + return false; + }, + child: AppPullToRefresh( + onRefresh: widget.onRefresh, + child: CustomScrollView( + key: PageStorageKey('recommend-feed-${widget.categoryId}'), + physics: AppPullToRefresh.physics, + slivers: sliverList, + ), + ), + ); + } + + Widget _placeholder(bool loading) { + if (loading) { + return const CircularProgressIndicator(color: AppColors.brandRed); + } + final l10n = AppLocalizations.of(context)!; + return EmptyState( + key: const Key('recommend-category-empty'), + icon: Icons.movie_outlined, + title: l10n.noStories, + ); + } + + Widget _gridSliver( + List dramas, { + required double topPadding, + required int crossAxisCount, + }) { + const pageSide = 14.0; + const cardGap = 9.0; + const rowGap = 16.0; + const cardBodyExtent = 44.0; // 封面下间距 + 剧名行 + 集数行 + // 两列时卡更大,卡间距略放宽。 + final spacing = crossAxisCount == 2 ? 12.0 : cardGap; + return SliverPadding( + padding: EdgeInsets.fromLTRB(pageSide, topPadding, pageSide, 0), + sliver: SliverLayoutBuilder( + builder: (context, constraints) { + final itemWidth = + (constraints.crossAxisExtent - spacing * (crossAxisCount - 1)) / + crossAxisCount; + final itemExtent = itemWidth * 157 / 110 + cardBodyExtent; + return SliverGrid( + key: Key('recommend-category-grid-${widget.categoryId}'), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + crossAxisSpacing: spacing, + mainAxisSpacing: rowGap, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final drama = dramas[index]; + return HomePosterCard( + key: Key('recommend-category-card-${drama.id}'), + drama: drama, + onTap: () => widget.onOpenDrama(drama), + ); + }, + childCount: dramas.length, + ), + ); + }, + ), + ); + } +} + +/// 判定接口下发的短剧标签是否为「上新」。 +/// +/// 完全以后端返回的 `tag` / `tagLocales`(经 [Drama.localizedTag] 本地化) +/// 为准;兼容「上新 / NEW / BARU」三种文案,并兼容「本周上新」这类带前后缀的写法。 +bool _isNewArrivalTag(Drama drama, String lang, String localizedBadge) { + final tag = drama.localizedTag(lang).trim(); + if (tag.isEmpty) return false; + if (tag == localizedBadge) return true; + final lower = tag.toLowerCase(); + return tag.contains('上新') || lower == 'new' || lower == 'baru'; +} diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 8508219..93c98ec 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -30,6 +30,7 @@ class RecommendPage extends StatefulWidget { this.catalogApi, required this.active, required this.onSearch, + this.onOpenWallet, this.videoOnly = false, this.onImmersiveChanged, }); @@ -39,6 +40,9 @@ class RecommendPage extends StatefulWidget { final bool active; final VoidCallback onSearch; + /// 顶部金色礼物盒(钱包入口)。 + final VoidCallback? onOpenWallet; + /// 画中画时只留视频,不画顶栏。 final bool videoOnly; final ValueChanged? onImmersiveChanged; @@ -272,6 +276,7 @@ class _RecommendPageState extends State onVideo: _tab == RecommendInnerTab.feed, onSelected: _selectTab, onSearch: _handleSearch, + onGift: widget.onOpenWallet, ), ), ), diff --git a/lib/pages/recommend/widgets/home_hero_carousel.dart b/lib/pages/recommend/widgets/home_hero_carousel.dart new file mode 100644 index 0000000..a8bfb6e --- /dev/null +++ b/lib/pages/recommend/widgets/home_hero_carousel.dart @@ -0,0 +1,396 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/data/models/home_feed.dart'; +import '../../../core/data/services/favorite_controller.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; + +/// 推荐页顶部静态头图轮播:封面 + 居中剧名/简介 + 立即观看/收藏 + 轮播点。 +class HomeHeroCarousel extends StatefulWidget { + const HomeHeroCarousel({ + super.key, + required this.banners, + required this.dramaFor, + required this.onWatch, + this.active = true, + }); + + final List banners; + + /// 按 banner 关联到的剧目;为空时只展示头图。 + final Drama? Function(FeedBanner banner) dramaFor; + + /// 点击「立即观看」或直接点按头图。 + final ValueChanged onWatch; + + final bool active; + + /// 固定轮播高度;后台按该比例(屏宽 : height)出图即可铺满不裁切。 + static const double height = 420; + + @override + State createState() => _HomeHeroCarouselState(); +} + +class _HomeHeroCarouselState extends State { + static const Duration _autoInterval = Duration(milliseconds: 4500); + static const Duration _pageDuration = Duration(milliseconds: 480); + static const double _dotSize = 4; + static const double _dotGap = 9; + static const double _ctaHeight = 38; + + final PageController _controller = PageController(); + Timer? _timer; + int _index = 0; + bool _pressed = false; + bool _tickerEnabled = true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _syncTimer()); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final enabled = TickerMode.valuesOf(context).enabled; + if (enabled != _tickerEnabled) { + _tickerEnabled = enabled; + _syncTimer(); + } + } + + @override + void didUpdateWidget(covariant HomeHeroCarousel oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.banners.length != widget.banners.length) { + if (_index >= widget.banners.length) _index = 0; + _syncTimer(); + } + if (oldWidget.banners.length != widget.banners.length || + oldWidget.active != widget.active) { + _syncTimer(); + } + } + + @override + void dispose() { + _timer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + void _syncTimer() { + _timer?.cancel(); + _timer = null; + final canAdvance = mounted && + widget.active && + _tickerEnabled && + !_pressed && + widget.banners.length > 1; + if (!canAdvance) return; + _timer = Timer.periodic(_autoInterval, (_) => _advance()); + } + + void _advance() { + if (!_controller.hasClients || widget.banners.length < 2) return; + final next = (_index + 1) % widget.banners.length; + _controller.animateToPage( + next, + duration: _pageDuration, + curve: Curves.easeOutCubic, + ); + } + + @override + Widget build(BuildContext context) { + if (widget.banners.isEmpty) return const SizedBox.shrink(); + return SizedBox( + key: const Key('home-hero-carousel'), + height: HomeHeroCarousel.height, + child: Listener( + onPointerDown: (_) { + _pressed = true; + _timer?.cancel(); + }, + onPointerUp: (_) { + _pressed = false; + _syncTimer(); + }, + onPointerCancel: (_) { + _pressed = false; + _syncTimer(); + }, + child: Stack( + fit: StackFit.expand, + children: [ + PageView.builder( + controller: _controller, + itemCount: widget.banners.length, + onPageChanged: (value) => setState(() => _index = value), + itemBuilder: (context, index) => _bannerPage(context, index), + ), + Positioned(left: 0, right: 0, bottom: 14, child: _dots(context)), + ], + ), + ), + ); + } + + Widget _bannerPage(BuildContext context, int index) { + final banner = widget.banners[index]; + final drama = widget.dramaFor(banner); + return Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + banner.imageUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: AppColors.contentSurface, + ), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x99000000), + Color(0x00000000), + Color(0x66000000), + Color(0xB3000000), + ], + stops: [0, 0.22, 0.62, 1], + ), + ), + ), + if (drama != null) + Align( + alignment: const Alignment(0, 0.6), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: _HeroCopy( + key: Key('home-hero-copy-$index'), + drama: drama, + ctaHeight: _ctaHeight, + onWatch: () => widget.onWatch(drama), + ), + ), + ), + if (drama != null) + Positioned.fill( + child: Material( + color: AppColors.transparent, + child: InkWell( + key: Key('home-hero-$index'), + highlightColor: AppColors.transparent, + onTap: () => widget.onWatch(drama), + ), + ), + ), + ], + ); + } + + Widget _dots(BuildContext context) { + if (widget.banners.length < 2) return const SizedBox.shrink(); + return ExcludeSemantics( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var index = 0; index < widget.banners.length; index++) + Container( + key: Key('home-hero-dot-$index'), + width: _dotSize, + height: _dotSize, + margin: const EdgeInsets.symmetric(horizontal: _dotGap / 2), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: index == _index + ? AppColors.brandRed + : AppColors.textPrimary.withValues(alpha: 0.4), + ), + ), + ], + ), + ); + } +} + +/// Hero 中部居中文案与按钮组。 +class _HeroCopy extends StatelessWidget { + const _HeroCopy({ + super.key, + required this.drama, + required this.ctaHeight, + required this.onWatch, + }); + + final Drama drama; + final double ctaHeight; + final VoidCallback onWatch; + + static const Color _titleCream = Color(0xFFE9D3B8); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final lang = Localizations.localeOf(context).languageCode; + final types = drama.localizedTypeLabels(lang); + final meta = [ + if (types.isNotEmpty) types.first, + l10n.totalEpisodesShort(drama.episodeCount), + ].join(' · '); + + return Column( + key: const Key('home-hero-copy'), + mainAxisSize: MainAxisSize.min, + children: [ + Text( + drama.localizedTitle(lang), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: _titleCream, + fontSize: 28, + fontWeight: FontWeight.w600, + height: 1.15, + shadows: [Shadow(color: Color(0x80000000), blurRadius: 12)], + ), + ), + const SizedBox(height: 8), + Text( + meta, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: AppColors.textPrimary.withValues(alpha: 0.92), + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 10), + Text( + drama.localizedSynopsis(lang), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: AppColors.textPrimary.withValues(alpha: 0.95), + fontSize: 15, + fontWeight: FontWeight.w400, + shadows: const [ + Shadow(color: Color(0x80000000), blurRadius: 8), + ], + ), + ), + const SizedBox(height: 20), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _WatchButton(height: ctaHeight, label: l10n.watchNowAction, onTap: onWatch), + const SizedBox(width: 12), + _FavoriteCircle(size: ctaHeight, drama: drama), + ], + ), + ], + ); + } +} + +class _WatchButton extends StatelessWidget { + const _WatchButton({ + required this.height, + required this.label, + required this.onTap, + }); + + final double height; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: AppColors.brandRed, + borderRadius: BorderRadius.circular(height / 2), + child: InkWell( + key: const Key('home-hero-watch'), + borderRadius: BorderRadius.circular(height / 2), + onTap: onTap, + child: SizedBox( + height: height, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.play_arrow_rounded, + size: 20, + color: AppColors.textPrimary, + ), + const SizedBox(width: 2), + Text( + label, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _FavoriteCircle extends StatelessWidget { + const _FavoriteCircle({required this.size, required this.drama}); + + final double size; + final Drama drama; + + @override + Widget build(BuildContext context) { + final favorites = context.watch(); + final saved = favorites.isSaved(drama.id); + return Material( + color: saved ? AppColors.brandRed : AppColors.transparent, + shape: CircleBorder( + side: BorderSide( + color: saved + ? AppColors.brandRed + : AppColors.textPrimary.withValues(alpha: 0.85), + width: 1.5, + ), + ), + child: InkWell( + key: const Key('home-hero-favorite'), + customBorder: const CircleBorder(), + onTap: () => unawaited(favorites.toggle(drama.id, card: drama)), + child: SizedBox( + width: size, + height: size, + child: Icon( + saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded, + size: 20, + color: AppColors.textPrimary, + ), + ), + ), + ); + } +} diff --git a/lib/pages/recommend/widgets/home_recommend_content.dart b/lib/pages/recommend/widgets/home_recommend_content.dart new file mode 100644 index 0000000..d7c4b2f --- /dev/null +++ b/lib/pages/recommend/widgets/home_recommend_content.dart @@ -0,0 +1,252 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; + +/// 设计稿(首页 1)推荐页内容区规格: +/// 海报卡 110×157、页边距 14、卡距 9、圆角 12。 +const double _pageSide = 14; +const double _cardWidth = 110; +const double _cardCoverHeight = 157; +const double _cardGap = 9; +const double _cardRadius = 12; + +/// 上新横滑卡板块。无板块标题,卡片左上角带「上新」角标。 +/// 内层横向 ListView 会自行识别横向手势,不与外层纵向滚动冲突。 +class HomeNewArrivalRow extends StatelessWidget { + const HomeNewArrivalRow({ + super.key, + required this.dramas, + required this.onOpen, + }); + + final List dramas; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + if (dramas.isEmpty) return const SizedBox.shrink(); + return SizedBox( + key: const Key('home-new-arrival-row'), + height: + _cardCoverHeight + 8 + 20 + 2 + 16, // 封面 + 剧名行 + 集数行 + 余量 + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: _pageSide), + itemCount: dramas.length, + separatorBuilder: (_, _) => const SizedBox(width: _cardGap), + itemBuilder: (context, index) => SizedBox( + width: _cardWidth, + child: HomePosterCard( + key: Key('home-new-arrival-${dramas[index].id}'), + drama: dramas[index], + badge: AppLocalizations.of(context)!.newArrivalBadge, + onTap: () => onOpen(dramas[index]), + ), + ), + ), + ); + } +} + +/// 110×157 海报 + 卡下剧名/集数。 +/// +/// 封面左上角标签优先用接口返回的短剧标签(`drama.tag` + `drama.tagColor`); +/// 接口没有标签时,若传了 [badge](如「上新」)则退回显示该角标。 +class HomePosterCard extends StatelessWidget { + const HomePosterCard({ + super.key, + required this.drama, + required this.onTap, + this.badge, + }); + + final Drama drama; + final VoidCallback onTap; + final String? badge; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final lang = Localizations.localeOf(context).languageCode; + final tag = drama.localizedTag(lang); + final tagLabel = tag.isNotEmpty ? tag : (badge ?? ''); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(_cardRadius), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(_cardRadius), + child: AspectRatio( + aspectRatio: _cardWidth / _cardCoverHeight, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: AppColors.contentSurface, + ), + if (tagLabel.isNotEmpty) + Positioned( + left: 8, + top: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: tag.isNotEmpty + ? drama.tagRibbonColor(AppColors.brandRed) + : AppColors.brandRed, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + tagLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 11, + fontWeight: FontWeight.w600, + height: 1.1, + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Text( + drama.localizedTitle(lang), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.2, + ), + ), + const SizedBox(height: 2), + Text( + l10n.totalEpisodesShort(drama.episodeCount), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: 12, + height: 1.2, + ), + ), + ], + ), + ); + } +} + +/// 编辑精选横幅:满宽背景图 + 左上小标签、大标题、副标题。 +class HomeEditorPickBanner extends StatelessWidget { + const HomeEditorPickBanner({ + super.key, + required this.drama, + required this.onOpen, + }); + + final Drama drama; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final lang = Localizations.localeOf(context).languageCode; + return Padding( + key: const Key('home-editor-pick'), + padding: const EdgeInsets.symmetric(horizontal: _pageSide), + child: SizedBox( + height: 72, + child: ClipRRect( + borderRadius: BorderRadius.circular(_cardRadius), + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: AppColors.contentSurface, + ), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0xE6000000), Color(0x4D000000), Color(0x1A000000)], + ), + ), + ), + Positioned.fill( + child: Material( + color: AppColors.transparent, + child: InkWell(onTap: () => onOpen(drama)), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.editorPick, + maxLines: 1, + style: TextStyle( + color: AppColors.textPrimary.withValues(alpha: 0.85), + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 2, + ), + ), + const SizedBox(height: 4), + Text( + drama.localizedTitle(lang), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w600, + height: 1.15, + ), + ), + const SizedBox(height: 2), + Text( + drama.localizedSynopsis(lang), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: AppColors.textPrimary.withValues(alpha: 0.8), + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/recommend/widgets/recommend_channel_nav.dart b/lib/pages/recommend/widgets/recommend_channel_nav.dart new file mode 100644 index 0000000..74bbdcc --- /dev/null +++ b/lib/pages/recommend/widgets/recommend_channel_nav.dart @@ -0,0 +1,222 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/content_category.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +const double _navHeight = 56; +const double _tabGap = 20; +const double _tabFont = 17; +const double _searchHitSize = 40; +const double _searchIconSize = 26; +const double _giftIconSize = 30; +const double _underlineWidth = 26; +const double _underlineHeight = 3; + +/// 推荐页(礼花 tab)顶部频道导航:频道全部来自分类接口,右侧搜索/礼物。 +class RecommendChannelNav extends StatefulWidget { + const RecommendChannelNav({ + super.key, + required this.categories, + required this.selectedId, + required this.onSelected, + required this.onSearch, + this.onGift, + }); + + static const double height = _navHeight; + + /// 接口返回的内容分类(首项即「精选」)。 + final List categories; + final String? selectedId; + final ValueChanged onSelected; + final VoidCallback onSearch; + + /// 右上角金色礼物盒(钱包入口);为空时不显示。 + final VoidCallback? onGift; + + @override + State createState() => _RecommendChannelNavState(); +} + +class _RecommendChannelNavState extends State { + static const double _revealLeftInset = 20; + + final ScrollController _scrollController = ScrollController(); + final GlobalKey _scrollViewKey = GlobalKey(); + final Map _tabKeys = {}; + + GlobalKey _keyFor(String id) => + _tabKeys.putIfAbsent(id, () => GlobalKey()); + + @override + void didUpdateWidget(covariant RecommendChannelNav oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.selectedId != null && + (widget.selectedId != oldWidget.selectedId || + widget.categories.length != oldWidget.categories.length)) { + WidgetsBinding.instance.addPostFrameCallback((_) => _revealSelected()); + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + /// 把选中频道平滑滚动到可视区域:对齐到滚动区左缘 20pt。 + void _revealSelected() { + final id = widget.selectedId; + if (id == null || !_scrollController.hasClients) return; + final tabCtx = _tabKeys[id]?.currentContext; + final tabBox = tabCtx?.findRenderObject() as RenderBox?; + final scrollBox = + _scrollViewKey.currentContext?.findRenderObject() as RenderBox?; + if (tabBox == null || scrollBox == null) return; + + final viewportLeft = scrollBox.localToGlobal(Offset.zero).dx; + final tabLeft = tabBox.localToGlobal(Offset.zero).dx; + final relative = tabLeft - viewportLeft; + final position = _scrollController.position; + final target = + (position.pixels + relative - _revealLeftInset) + .clamp(0.0, position.maxScrollExtent); + if ((target - position.pixels).abs() < 0.5) return; + _scrollController.animateTo( + target, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final lang = Localizations.localeOf(context).languageCode; + final categories = widget.categories; + final tabs = <(String, String, String)>[ + for (final category in categories) + (category.id, category.localizedName(lang), category.id), + ]; + return SizedBox( + key: const Key('recommend-channel-nav'), + height: RecommendChannelNav.height, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: SingleChildScrollView( + key: _scrollViewKey, + controller: _scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(left: 20, right: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < tabs.length; index++) ...[ + if (index > 0) const SizedBox(width: _tabGap), + KeyedSubtree( + key: _keyFor(tabs[index].$3), + child: _tab( + key: Key('recommend-tab-${tabs[index].$3}'), + code: tabs[index].$3, + label: tabs[index].$2, + selected: widget.selectedId == tabs[index].$1, + onTap: () => widget.onSelected(tabs[index].$1), + ), + ), + ], + ], + ), + ), + ), + SizedBox.square( + dimension: _searchHitSize, + child: IconButton( + key: const Key('recommend-search'), + tooltip: l10n.search, + onPressed: widget.onSearch, + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: _searchHitSize, + height: _searchHitSize, + ), + iconSize: _searchIconSize, + color: AppColors.textPrimary, + icon: const Icon(Icons.search_rounded), + ), + ), + if (widget.onGift != null) + SizedBox.square( + dimension: _searchHitSize, + child: IconButton( + key: const Key('recommend-gift'), + tooltip: l10n.wallet, + onPressed: widget.onGift, + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: _searchHitSize, + height: _searchHitSize, + ), + iconSize: _giftIconSize, + icon: Image.asset( + 'assets/icon/gift.png', + width: _giftIconSize, + height: _giftIconSize, + fit: BoxFit.contain, + ), + ), + ), + const SizedBox(width: 8), + ], + ), + ); + } + + Widget _tab({ + required Key key, + required String code, + required String label, + required bool selected, + required VoidCallback onTap, + }) { + return GestureDetector( + key: key, + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 补偿文字下方的 4pt 间距 + 3pt 下划线,让文字视觉中心与右侧图标对齐。 + const SizedBox(height: 4 + _underlineHeight), + Text( + label, + maxLines: 1, + style: TextStyle( + color: AppColors.textPrimary, + fontSize: _tabFont, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, + letterSpacing: 0, + shadows: const [ + Shadow(color: AppColors.mediaBlack, blurRadius: 6), + ], + ), + ), + const SizedBox(height: 4), + Container( + key: Key('recommend-tab-$code-underline'), + width: _underlineWidth, + height: _underlineHeight, + decoration: BoxDecoration( + color: selected ? AppColors.brandRed : AppColors.transparent, + borderRadius: BorderRadius.circular(_underlineHeight / 2), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/recommend/widgets/recommend_top_nav.dart b/lib/pages/recommend/widgets/recommend_top_nav.dart index 907bcca..1a85118 100644 --- a/lib/pages/recommend/widgets/recommend_top_nav.dart +++ b/lib/pages/recommend/widgets/recommend_top_nav.dart @@ -3,16 +3,17 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; import '../recommend_types.dart'; const double _navHeight = 56; -const double _tabGap = 22; +const double _tabGap = 20; const double _tabFont = 17; const double _searchHitSize = 40; const double _searchIconSize = 26; -const double _minNavWidth = 280; +const double _giftIconSize = 30; +const double _underlineWidth = 26; +const double _underlineHeight = 3; class RecommendTopNav extends StatelessWidget { const RecommendTopNav({ @@ -21,6 +22,7 @@ class RecommendTopNav extends StatelessWidget { required this.onSelected, required this.onSearch, this.onVideo = false, + this.onGift, }); static const double height = _navHeight; @@ -30,6 +32,9 @@ class RecommendTopNav extends StatelessWidget { final VoidCallback onSearch; final bool onVideo; + /// 右上角金色礼物盒(钱包入口);为空时不显示。 + final VoidCallback? onGift; + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -41,34 +46,16 @@ class RecommendTopNav extends StatelessWidget { final shadows = onVideo ? const [Shadow(color: AppColors.mediaBlack, blurRadius: 6)] : null; - return LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth < _minNavWidth) { - return const SizedBox(key: Key('recommend-top-nav'), height: height); - } - return _bar(context, items, shadows); - }, - ); - } - - Widget _bar( - BuildContext context, - List<(RecommendInnerTab, String, String)> items, - List? shadows, - ) { - final l10n = AppLocalizations.of(context)!; return SizedBox( key: const Key('recommend-top-nav'), height: height, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.feedPageHorizontal, - ), - child: Stack( - alignment: Alignment.center, - children: [ - FittedBox( - fit: BoxFit.scaleDown, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(left: 20, right: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -76,6 +63,7 @@ class RecommendTopNav extends StatelessWidget { if (index > 0) const SizedBox(width: _tabGap), _tab( key: Key('recommend-tab-${items[index].$3}'), + code: items[index].$3, label: items[index].$2, selected: selected == items[index].$1, shadows: shadows, @@ -85,33 +73,53 @@ class RecommendTopNav extends StatelessWidget { ], ), ), - Align( - alignment: Alignment.centerRight, - child: SizedBox.square( - dimension: _searchHitSize, - child: IconButton( - key: const Key('recommend-search'), - tooltip: l10n.search, - onPressed: onSearch, - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - width: _searchHitSize, - height: _searchHitSize, - ), - iconSize: _searchIconSize, - color: AppColors.textPrimary, - icon: const Icon(Icons.search_rounded), + ), + SizedBox.square( + dimension: _searchHitSize, + child: IconButton( + key: const Key('recommend-search'), + tooltip: l10n.search, + onPressed: onSearch, + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: _searchHitSize, + height: _searchHitSize, + ), + iconSize: _searchIconSize, + color: AppColors.textPrimary, + icon: const Icon(Icons.search_rounded), + ), + ), + if (onGift != null) + SizedBox.square( + dimension: _searchHitSize, + child: IconButton( + key: const Key('recommend-gift'), + tooltip: l10n.wallet, + onPressed: onGift, + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: _searchHitSize, + height: _searchHitSize, + ), + iconSize: _giftIconSize, + icon: Image.asset( + 'assets/icon/gift.png', + width: _giftIconSize, + height: _giftIconSize, + fit: BoxFit.contain, ), ), ), - ], - ), + const SizedBox(width: 8), + ], ), ); } Widget _tab({ required Key key, + required String code, required String label, required bool selected, required List? shadows, @@ -121,23 +129,33 @@ class RecommendTopNav extends StatelessWidget { key: key, behavior: HitTestBehavior.opaque, onTap: onTap, - child: SizedBox( - height: height, - child: Center( - child: Text( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 补偿文字下方的 4pt 间距 + 3pt 下划线,让文字视觉中心与右侧图标对齐。 + const SizedBox(height: 4 + _underlineHeight), + Text( label, maxLines: 1, style: TextStyle( - color: selected - ? AppColors.textPrimary - : AppColors.textTabInactive, + color: AppColors.textPrimary, fontSize: _tabFont, fontWeight: selected ? FontWeight.w700 : FontWeight.w400, letterSpacing: 0, shadows: shadows, ), ), - ), + const SizedBox(height: 4), + Container( + key: Key('recommend-tab-$code-underline'), + width: _underlineWidth, + height: _underlineHeight, + decoration: BoxDecoration( + color: selected ? AppColors.brandRed : AppColors.transparent, + borderRadius: BorderRadius.circular(_underlineHeight / 2), + ), + ), + ], ), ); }