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] =?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();