fix(following): 支持未登录追剧与历史
This commit is contained in:
1 parent
0d785bf1f8
commit
06a2f6d7c0
4 files changed
+292
-53
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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<FollowingPage> {
|
||||
history: context.read<WatchHistoryRepository>(),
|
||||
catalog: context.read<CatalogRepository>(),
|
||||
);
|
||||
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<FollowingPage> {
|
||||
|
||||
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<void> _reloadForIdentity() async {
|
||||
await Future.wait([_history.load(), widget.favoriteController.load()]);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -104,7 +111,6 @@ class _FollowingPageState extends State<FollowingPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||
return ColoredBox(
|
||||
key: const Key('following-page'),
|
||||
color: AppColors.appBackground,
|
||||
@@ -114,47 +120,44 @@ class _FollowingPageState extends State<FollowingPage> {
|
||||
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<FollowingPage> {
|
||||
final tab = FollowingTab.values[index];
|
||||
_leaveEditing();
|
||||
_tab.value = tab;
|
||||
if (tab == FollowingTab.history && _auth.isSignedIn) {
|
||||
if (tab == FollowingTab.history) {
|
||||
unawaited(_history.load());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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<AuthController>(
|
||||
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<void> _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<void> _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<Drama> device,
|
||||
required List<Drama> account,
|
||||
}) : _cards = {'device': device, 'account': account};
|
||||
|
||||
final Map<String, List<Drama>> _cards;
|
||||
String scope = 'device';
|
||||
int pageCalls = 0;
|
||||
|
||||
@override
|
||||
Future<PageResult<Drama>> 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<void> remove(String dramaId) async {
|
||||
_cards[scope]!.removeWhere((drama) => drama.id == dramaId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeMany(List<String> dramaIds) async {
|
||||
final ids = dramaIds.toSet();
|
||||
_cards[scope]!.removeWhere((drama) => ids.contains(drama.id));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> toggle(String dramaId) async => true;
|
||||
}
|
||||
|
||||
class _IdentityWatchHistoryRepository implements WatchHistoryRepository {
|
||||
_IdentityWatchHistoryRepository({
|
||||
required List<WatchProgress> device,
|
||||
required List<WatchProgress> account,
|
||||
}) : _records = {'device': device, 'account': account};
|
||||
|
||||
final Map<String, List<WatchProgress>> _records;
|
||||
String scope = 'device';
|
||||
int recentCalls = 0;
|
||||
|
||||
@override
|
||||
Future<void> clear() async => _records[scope]!.clear();
|
||||
|
||||
@override
|
||||
Future<WatchProgress?> progressFor(String dramaId) async {
|
||||
return _records[scope]!
|
||||
.where((record) => record.dramaId == dramaId)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PageResult<WatchProgress>> 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<void> remove(String dramaId) async {
|
||||
_records[scope]!.removeWhere((record) => record.dramaId == dramaId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WatchProgress?> save({
|
||||
required String episodeId,
|
||||
required Duration position,
|
||||
Duration? duration,
|
||||
bool finished = false,
|
||||
}) async => null;
|
||||
}
|
||||
Reference in new issue
Block a user