fix(mine): 支持未登录展示互动计数

This commit is contained in:
Zhou Weidong committed 2026-10-27 10:26:11 +08:00
1 parent 06a2f6d7c0
commit 8d511933df
4 files changed
+334 -19

No files matched your search

+20 -3
View File
@@ -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();
+65 -11
View File
@@ -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() {
+14
View File
@@ -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 {
+235 -5
View File
@@ -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();