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; +}