fix(mine): 支持未登录展示互动计数
This commit is contained in:
1 parent
06a2f6d7c0
commit
8d511933df
4 files changed
+334
-19
No files matched your search
@@ -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<void> 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();
|
||||
|
||||
@@ -53,25 +53,70 @@ class MyPage extends StatefulWidget {
|
||||
|
||||
class _MyPageState extends State<MyPage> {
|
||||
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<AuthController>();
|
||||
_favorites = context.read<FavoriteController>();
|
||||
_wasSignedIn = _auth.isSignedIn;
|
||||
_auth.addListener(_onAuthChanged);
|
||||
logic = MineBinding().create(
|
||||
history: context.read<WatchHistoryRepository>(),
|
||||
);
|
||||
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<void> _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<MyPage> {
|
||||
final wallet = context.watch<WalletController>();
|
||||
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<MyPage> {
|
||||
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<MyPage> {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
_push(const WatchRecordsPage());
|
||||
unawaited(_openHistoryPage());
|
||||
}
|
||||
|
||||
void _push(Widget page) {
|
||||
Navigator.of(context).push<void>(MaterialPageRoute(builder: (_) => page));
|
||||
Future<void> _openHistoryPage() async {
|
||||
await _push(const WatchRecordsPage());
|
||||
if (mounted) unawaited(logic.refreshRecent());
|
||||
}
|
||||
|
||||
Future<void> _push(Widget page) {
|
||||
return Navigator.of(
|
||||
context,
|
||||
).push<void>(MaterialPageRoute(builder: (_) => page));
|
||||
}
|
||||
|
||||
void _pushPlaceholder() {
|
||||
_push(const _MinePlaceholderPage());
|
||||
unawaited(_push(const _MinePlaceholderPage()));
|
||||
}
|
||||
|
||||
Future<void> _openProtected(Widget page) async {
|
||||
@@ -260,7 +314,7 @@ class _MyPageState extends State<MyPage> {
|
||||
await ensureSignedIn(context);
|
||||
return;
|
||||
}
|
||||
if (mounted) _push(page);
|
||||
if (mounted) await _push(page);
|
||||
}
|
||||
|
||||
void _openVip() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<AuthController>(
|
||||
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<Text>(
|
||||
find.descendant(of: find.byKey(key), matching: find.byType(Text)).first,
|
||||
)
|
||||
.data!;
|
||||
}
|
||||
|
||||
Future<void> _openMine(WidgetTester tester) async {
|
||||
await tester.tap(find.byKey(const Key('tab-mine')));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -423,6 +551,7 @@ Future<void> _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<void> _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<void> _pumpApp(
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<Drama> device,
|
||||
required List<Drama> account,
|
||||
}) : _cards = {'device': device, 'account': account};
|
||||
|
||||
final Map<String, List<Drama>> _cards;
|
||||
String scope = 'device';
|
||||
|
||||
@override
|
||||
Future<PageResult<Drama>> 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<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 _IdentityMineWatchHistoryRepository implements WatchHistoryRepository {
|
||||
_IdentityMineWatchHistoryRepository({
|
||||
required List<WatchProgress> device,
|
||||
required List<WatchProgress> account,
|
||||
}) : _records = {'device': device, 'account': account};
|
||||
|
||||
final Map<String, List<WatchProgress>> _records;
|
||||
String scope = 'device';
|
||||
|
||||
@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 {
|
||||
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;
|
||||
}
|
||||
|
||||
class _MineWalletCommerceRepository implements CommerceRepository {
|
||||
const _MineWalletCommerceRepository();
|
||||
|
||||
|
||||
Reference in new issue
Block a user