feat(interaction): 支持游客点赞收藏评论

This commit is contained in:
Zhou Weidong committed 2026-10-26 22:33:14 +08:00
1 parent 188811fde6
commit a3d50eb935
18 files changed
+364 -121

No files matched your search

+5 -3
View File
@@ -58,14 +58,16 @@ class LikeToggleResult {
/// 控制器统一返回结果;失败消息可直接展示后端 `message`。
class OperationResult<T> {
const OperationResult._(this.data, this.errorMessage);
const OperationResult._(this.data, this.errorMessage, this.errorCode);
const OperationResult.success([T? data]) : this._(data, null);
const OperationResult.success([T? data]) : this._(data, null, null);
const OperationResult.failure(this.errorMessage) : data = null;
const OperationResult.failure(this.errorMessage, {this.errorCode})
: data = null;
final T? data;
final String? errorMessage;
final int? errorCode;
bool get isSuccess => errorMessage == null;
}
+16 -4
View File
@@ -197,7 +197,10 @@ class CommentController extends ChangeNotifier {
_insertPublished(comment, replyTo);
return OperationResult.success(comment);
} catch (failure) {
return OperationResult.failure(_errorMessage(failure));
return OperationResult.failure(
_errorMessage(failure),
errorCode: failure is ApiException ? failure.code : null,
);
} finally {
submitting = false;
_notify();
@@ -217,7 +220,10 @@ class CommentController extends ChangeNotifier {
return const OperationResult.success();
} catch (failure) {
_restoreComment(comment, location);
return OperationResult.failure(_errorMessage(failure));
return OperationResult.failure(
_errorMessage(failure),
errorCode: failure is ApiException ? failure.code : null,
);
} finally {
_deletingCommentIds.remove(comment.id);
_notify();
@@ -253,7 +259,10 @@ class CommentController extends ChangeNotifier {
return OperationResult.success(result);
} catch (failure) {
_replaceComment(current);
return OperationResult.failure(_errorMessage(failure));
return OperationResult.failure(
_errorMessage(failure),
errorCode: failure is ApiException ? failure.code : null,
);
} finally {
_likingCommentIds.remove(comment.id);
_notify();
@@ -277,7 +286,10 @@ class CommentController extends ChangeNotifier {
);
return OperationResult.success(reportId);
} catch (failure) {
return OperationResult.failure(_errorMessage(failure));
return OperationResult.failure(
_errorMessage(failure),
errorCode: failure is ApiException ? failure.code : null,
);
}
}
@@ -3,6 +3,7 @@
import 'package:flutter/foundation.dart';
import '../models/drama.dart';
import '../remote/api_client.dart';
import '../repositories/favorite_repository.dart';
/// 收藏状态:**以服务端为准**。
@@ -24,6 +25,7 @@ class FavoriteController extends ChangeNotifier {
bool hasMore = false;
String? error;
String? loadMoreError;
int? lastErrorCode;
int _pageIndex = 0;
int _requestSeq = 0;
bool _disposed = false;
@@ -112,11 +114,12 @@ class FavoriteController extends ChangeNotifier {
}
}
/// 未登录时不要调用(服务端要求登录态)。
/// 每次切换都以接口状态为准;匿名请求由设备号归属。
///
/// [card] 是当前剧的卡片(播放页/详情页手上就有):收藏成功时直接放进列表,
/// 这样"收藏完打开我的收藏"能立刻看到,不必等下一次整表刷新。
Future<bool> toggle(String dramaId, {Drama? card}) async {
lastErrorCode = null;
final wasSaved = _saved.contains(dramaId);
final previousCards = cards;
final previousTotal = total;
@@ -157,7 +160,7 @@ class FavoriteController extends ChangeNotifier {
}
notify();
return true;
} catch (_) {
} catch (failure) {
// 回滚
cards = previousCards;
total = previousTotal;
@@ -168,6 +171,7 @@ class FavoriteController extends ChangeNotifier {
_saved.remove(dramaId);
}
notify();
if (failure is ApiException) lastErrorCode = failure.code;
return false;
}
}
@@ -115,7 +115,10 @@ class InteractionController extends ChangeNotifier {
} else {
likedIds.remove(id);
}
return OperationResult.failure(_errorMessage(failure));
return OperationResult.failure(
_errorMessage(failure),
errorCode: failure is ApiException ? failure.code : null,
);
} finally {
busyIds.remove(id);
_notify();
@@ -139,7 +142,10 @@ class InteractionController extends ChangeNotifier {
);
return OperationResult.success(reportId);
} catch (failure) {
return OperationResult.failure(_errorMessage(failure));
return OperationResult.failure(
_errorMessage(failure),
errorCode: failure is ApiException ? failure.code : null,
);
}
}
+2 -1
View File
@@ -15,7 +15,7 @@ Future<void> openAuthPage(BuildContext context, {bool signUpMode = false}) {
/// 受限动作的登录闸:已登录返回 true;未登录弹底部提示并引导登录,返回 false。
///
/// 首页/发现/详情保持匿名可看,只有收藏、点赞、评论这类"要对到具体账号"的动作才走这里。
/// 首页/发现/详情与非付费互动保持匿名可用;解锁、权益与消耗类动作走这里。
Future<bool> ensureSignedIn(BuildContext context) async {
final auth = context.read<AuthController>();
if (auth.isSignedIn) return true;
@@ -76,6 +76,7 @@ Future<bool> ensureSignedIn(BuildContext context) async {
if (go == true && context.mounted) {
await openAuthPage(context);
if (context.mounted) return context.read<AuthController>().isSignedIn;
}
return false;
}
+10 -3
View File
@@ -164,7 +164,9 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
),
),
if (_tagValues(context, drama).isNotEmpty)
SliverToBoxAdapter(child: DramaTags(tags: _tagValues(context, drama))),
SliverToBoxAdapter(
child: DramaTags(tags: _tagValues(context, drama)),
),
SliverToBoxAdapter(child: _episodes(l10n, drama, continueIndex)),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
@@ -396,11 +398,14 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
}
Future<void> _toggleFavorite(AppLocalizations l10n) async {
if (!await ensureSignedIn(context)) return;
if (!mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final ok = await logic.toggleFavorite();
if (ok || !mounted) return;
if (logic.favorites.lastErrorCode == 200007) {
await ensureSignedIn(context);
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed)));
@@ -443,7 +448,9 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
}
List<String> _tagValues(BuildContext context, Drama drama) {
final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode);
final localized = drama.localizedTypeLabels(
Localizations.localeOf(context).languageCode,
);
final result = <String>[];
if (localized.isNotEmpty) {
result.addAll(localized.take(_maxTags));
+2 -12
View File
@@ -1,24 +1,14 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/widgets/auth_gate.dart';
/// 评论入口统一登录闸:登录成功且页面仍存在时再打开评论面板。
Future<void> openCommentsWithAuth(
/// 评论列表与非付费互动均支持设备身份,不再把登录作为入口前置条件。
Future<void> openComments(
BuildContext context,
Drama drama, {
required Future<void> Function(BuildContext context, Drama drama) show,
}) async {
final auth = context.read<AuthController>();
if (auth.isSignedIn) {
await show(context, drama);
return;
}
await ensureSignedIn(context);
if (!context.mounted || !auth.isSignedIn) return;
await show(context, drama);
}
+2
View File
@@ -15,6 +15,7 @@ class PlayerBinding {
required PlaybackPositionCache positionCache,
WalletController? wallet,
DownloadController? downloads,
bool Function()? isSignedIn,
}) => PlayerLogic(
history: history,
playback: playback,
@@ -22,5 +23,6 @@ class PlayerBinding {
pictureInPicture: AndroidPictureInPicture(),
wallet: wallet,
downloads: downloads,
isSignedIn: isSignedIn,
);
}
+13 -2
View File
@@ -53,10 +53,12 @@ class PlayerLogic extends ChangeNotifier {
PlaybackPositionCache? positionCache,
Future<void> Function()? waitForFrame,
DateTime Function()? clock,
bool Function()? isSignedIn,
}) : positionCache = positionCache ?? PlaybackPositionCache(),
_waitForFrame =
waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame),
_clock = clock ?? DateTime.now;
_clock = clock ?? DateTime.now,
_isSignedIn = isSignedIn ?? (() => true);
final WatchHistoryRepository history;
final PlaybackRepository playback;
@@ -66,6 +68,7 @@ class PlayerLogic extends ChangeNotifier {
final PlaybackPositionCache positionCache;
final Future<void> Function() _waitForFrame;
final DateTime Function() _clock;
final bool Function() _isSignedIn;
late PageController pages;
late PlaybackQueue queue;
@@ -797,7 +800,10 @@ class PlayerLogic extends ChangeNotifier {
/// 金币解锁当前集;成功后强制刷新票据并直接起播。
Future<bool> unlockEpisode(int index) async {
final service = wallet;
if (service == null || index < 0 || index >= sessionEpisodes.length) {
if (service == null ||
!_isSignedIn() ||
index < 0 ||
index >= sessionEpisodes.length) {
return false;
}
final episode = sessionEpisodes[index];
@@ -1232,6 +1238,7 @@ class PlayerLogic extends ChangeNotifier {
int? revision,
bool? userWantsPlay,
}) async {
if (index < 0 || index >= sessionEpisodes.length) return;
final feedRevisionAtStart = revision ?? feedRevision;
final request = ++pageRequest;
cancelScrub();
@@ -1257,6 +1264,10 @@ class PlayerLogic extends ChangeNotifier {
!isMounted()) {
return;
}
if (!_isSignedIn()) {
unawaited(refreshEpisodeStats(index));
return;
}
}
// 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试)
final ready = await ensureTicket(index);
+10 -2
View File
@@ -12,6 +12,7 @@ import '../../core/data/models/drama.dart';
import '../../core/data/models/episode_stats.dart';
import '../../core/data/repositories/comment_repository.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/download_controller.dart';
import '../../core/data/services/interaction_controller.dart';
@@ -168,6 +169,7 @@ class _PlayerPageState extends State<PlayerPage>
positionCache: context.read<PlaybackPositionCache>(),
wallet: widget.wallet,
downloads: widget.downloads,
isSignedIn: () => context.read<AuthController>().isSignedIn,
);
logic.onEpisodeStatsUpdated = _applyEpisodeStats;
logic.attach(widget, isMounted: () => mounted);
@@ -590,7 +592,7 @@ class _PlayerPageState extends State<PlayerPage>
Episode episode,
) async {
final comments = context.read<CommentRepository>();
await openCommentsWithAuth(
await openComments(
context,
drama,
show: (context, drama) =>
@@ -617,6 +619,13 @@ class _PlayerPageState extends State<PlayerPage>
Widget _buildPlayer(BuildContext context) {
final interactions = context.watch<InteractionController>();
final l10n = AppLocalizations.of(context)!;
final indices = logic.visibleIndices;
if (indices.isEmpty) {
return const ColoredBox(
color: AppColors.mediaBlack,
child: SizedBox.expand(),
);
}
if (kDebugMode && !_loggedStartConditions) {
_loggedStartConditions = true;
debugPrint(
@@ -630,7 +639,6 @@ class _PlayerPageState extends State<PlayerPage>
'mayPlay=${logic.session.debugMayPlay}',
);
}
final indices = logic.visibleIndices;
final activePort = logic.session.active;
final activeVideo = activePort is VideoPlayerPort ? activePort : null;
final canWaitForVideoFrame =
+14 -23
View File
@@ -65,6 +65,11 @@ extension _PlayerViewActions on _PlayerPageState {
) async {
final wallet = logic.wallet;
if (wallet == null) return;
if (!context.read<AuthController>().isSignedIn &&
!await ensureSignedIn(context)) {
return;
}
if (!context.mounted) return;
await showModalBottomSheet<bool>(
context: context,
backgroundColor: AppColors.transparent,
@@ -81,7 +86,7 @@ extension _PlayerViewActions on _PlayerPageState {
}
Future<bool> _unlockEpisode(BuildContext context, int index) async {
if (!await ensureSignedIn(context) || !context.mounted) return false;
if (!context.read<AuthController>().isSignedIn) return false;
final ok = await logic.unlockEpisode(index);
if (ok || !context.mounted) return ok;
final l10n = AppLocalizations.of(context)!;
@@ -119,27 +124,6 @@ extension _PlayerViewActions on _PlayerPageState {
}
return;
}
final signedIn = await ensureSignedIn(context);
if (kDebugMode) {
debugPrint(
'[player-like] phase=auth-result '
'episodeId=${episode.id} signedIn=$signedIn '
'mounted=${context.mounted}',
);
}
if (!signedIn || !context.mounted) return;
final busyAfterAuth = interactions.isEpisodeBusy(episode.id);
final likedAfterAuth = interactions.isEpisodeLiked(episode.id);
if (busyAfterAuth || (likeOnly && likedAfterAuth)) {
if (kDebugMode) {
debugPrint(
'[player-like] phase=toggle-skipped-after-auth '
'episodeId=${episode.id} '
'reason=${busyAfterAuth ? 'busy' : 'already-liked'}',
);
}
return;
}
final result = await interactions.toggleEpisodeLike(episode.id);
if (kDebugMode) {
debugPrint(
@@ -163,6 +147,10 @@ extension _PlayerViewActions on _PlayerPageState {
return;
}
if (!context.mounted) return;
if (result.errorCode == 200007) {
await ensureSignedIn(context);
return;
}
showCenteredToast(
context,
result.errorMessage ?? AppLocalizations.of(context)!.likeFailed,
@@ -171,7 +159,6 @@ extension _PlayerViewActions on _PlayerPageState {
Future<void> _toggleSave(BuildContext context, Drama drama) async {
final favorites = context.read<FavoriteController>();
if (!await ensureSignedIn(context) || !context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final wasSaved = favorites.isSaved(drama.id);
final ok = await favorites.toggle(drama.id, card: drama);
@@ -183,6 +170,10 @@ extension _PlayerViewActions on _PlayerPageState {
}
return;
}
if (favorites.lastErrorCode == 200007) {
await ensureSignedIn(context);
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.favoriteToggleFailed),
@@ -9,7 +9,6 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../../core/data/models/comment.dart';
import '../../../core/data/services/auth_controller.dart';
import '../../../core/data/services/comment_controller.dart';
import '../../../core/platform/haptics.dart';
import '../../../core/platform/keyboard_inset.dart';
@@ -196,23 +195,20 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
return;
}
final l10n = AppLocalizations.of(context)!;
if (result.errorCode == 200007) {
await ensureSignedIn(context);
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result.errorMessage ?? l10n.commentPostFailed)),
);
}
Future<void> _authenticate() async {
await ensureSignedIn(context);
if (!mounted || !context.read<AuthController>().isSignedIn) return;
_inputFocus.requestFocus();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn;
final hasContent = _input.text.trim().isNotEmpty;
final canPost = signedIn && hasContent && !widget.controller.submitting;
final canPost = hasContent && !widget.controller.submitting;
final multiline = _input.text.contains('\n');
final bottomInset = _isAndroid
? _pinnedKeyboardInset
@@ -247,8 +243,8 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
key: const Key('comment-composer-input'),
controller: _input,
focusNode: _inputFocus,
readOnly: !signedIn,
canRequestFocus: signedIn,
readOnly: false,
canRequestFocus: true,
minLines: 1,
maxLines: 4,
maxLength: _maxCommentLength,
@@ -262,9 +258,7 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
),
cursorColor: AppColors.blue,
decoration: InputDecoration(
hintText: signedIn
? l10n.writeComment
: l10n.commentLoginHint,
hintText: l10n.writeComment,
hintStyle: const TextStyle(
color: AppColors.muted,
fontSize: 14,
@@ -278,7 +272,7 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
),
onTap: signedIn ? null : _authenticate,
onTap: null,
),
),
const SizedBox(height: 12),
@@ -11,11 +11,9 @@ import '../../../core/data/models/comment.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/repositories/comment_repository.dart';
import '../../../core/data/repositories/interaction_repository.dart';
import '../../../core/data/services/auth_controller.dart';
import '../../../core/data/services/comment_controller.dart';
import '../../../core/formatting/error_text.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/auth_gate.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../l10n/app_localizations.dart';
import 'comment_composer_sheet.dart';
@@ -29,7 +27,7 @@ const double _sheetRadius = 20;
const double _headerHeight = 44;
const double _composerTopGap = 8;
/// 服务端评论底部弹窗:匿名列表、游标翻页、回复与登录发布。
/// 服务端评论底部弹窗:匿名列表、游标翻页、回复与设备身份发布。
class DramaCommentsSheet extends StatefulWidget {
const DramaCommentsSheet({super.key, required this.drama, this.episodeId});
@@ -92,8 +90,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
}
Future<void> _openComposer({DramaComment? replyTo}) async {
await ensureSignedIn(context);
if (!mounted || !context.read<AuthController>().isSignedIn) return;
if (!mounted) return;
_removeComposerOverlay();
final entry = OverlayEntry(
builder: (_) => CommentComposerSheet(
@@ -222,7 +219,6 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
Widget _content(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final count = _serverCount ?? _controller.exactCount;
final signedIn = context.watch<AuthController>().isSignedIn;
final composerReserve = _composerHeight + _composerTopGap;
return Column(
key: const Key('comment-sheet-content'),
@@ -269,7 +265,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
Padding(
key: const Key('comment-composer-area'),
padding: const EdgeInsets.only(top: _composerTopGap),
child: _fakeComposer(l10n, signedIn: signedIn),
child: _fakeComposer(l10n),
),
],
);
@@ -352,7 +348,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
return const SizedBox(height: 8);
}
Widget _fakeComposer(AppLocalizations l10n, {required bool signedIn}) {
Widget _fakeComposer(AppLocalizations l10n) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _openComposer,
@@ -372,7 +368,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
child: Padding(
padding: const EdgeInsets.only(left: 16),
child: Text(
signedIn ? l10n.writeComment : l10n.commentLoginHint,
l10n.writeComment,
key: const Key('comment-input-placeholder-label'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -49,7 +49,8 @@ class PlayerBlockedNotice extends StatelessWidget {
bool get _needsUnlock =>
accessType == PlayTicket.coinRequired ||
accessType == PlayTicket.vipRequired;
accessType == PlayTicket.vipRequired ||
(accessType == null && episode.locked);
@override
Widget build(BuildContext context) {
+20 -24
View File
@@ -135,7 +135,9 @@ void main() {
debugDefaultTargetPlatformOverride = null;
});
testWidgets('should_gate_guest_comment_input_without_focus', (tester) async {
testWidgets('should_open_guest_comment_composer_without_login', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final repository = FakeCommentRepository(pages: [const CommentPage()]);
@@ -144,34 +146,20 @@ void main() {
final placeholder = find.byKey(const Key('comment-input-placeholder'));
expect(placeholder, findsOneWidget);
expect(find.text('Sign in to comment'), findsOneWidget);
await tester.tap(placeholder);
await tester.pumpAndSettle();
expect(find.text('Sign in to continue'), findsOneWidget);
await tester.tap(find.text('Not now'));
await tester.pumpAndSettle();
expect(find.byKey(const Key('comment-composer-sheet')), findsNothing);
expect(placeholder, findsOneWidget);
expect(find.byKey(const Key('comment-composer-sheet')), findsOneWidget);
expect(find.byKey(const Key('auth-landing')), findsNothing);
});
testWidgets('should_focus_comment_input_after_successful_login', (
tester,
) async {
testWidgets('should_focus_guest_comment_input_immediately', (tester) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final repository = FakeCommentRepository(pages: [const CommentPage()]);
final auth = await _pumpSheet(tester, repository: repository);
await _pumpSheet(tester, repository: repository);
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('Sign In'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
(auth as _TestAuthController).setSignedIn(true);
await tester.pump();
Navigator.of(tester.element(find.byKey(const Key('auth-landing')))).pop();
await tester.pumpAndSettle();
expect(
@@ -218,9 +206,7 @@ void main() {
expect(multilineField.style?.height, 1.4);
});
testWidgets('should_gate_guest_post_without_creating_comment', (
tester,
) async {
testWidgets('should_publish_guest_comment_without_login', (tester) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final repository = FakeCommentRepository(pages: [const CommentPage()]);
@@ -229,8 +215,18 @@ void main() {
await tester.tap(find.byKey(const Key('comment-post')));
await tester.pumpAndSettle();
expect(find.text('Sign in to continue'), findsOneWidget);
expect(repository.publishCalls, isEmpty);
await tester.enterText(
find.byKey(const Key('comment-composer-input')),
'Guest comment',
);
await tester.pump();
await tester.tap(find.byKey(const Key('comment-composer-post')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('auth-landing')), findsNothing);
expect(repository.publishCalls, hasLength(1));
expect(repository.publishCalls.single.content, 'Guest comment');
expect(find.text('Guest comment'), findsOneWidget);
});
testWidgets('should_show_server_comments_and_append_next_cursor_page', (
+54 -15
View File
@@ -596,9 +596,7 @@ void main() {
expect(find.byType(Slider), findsOneWidget);
});
testWidgets('should_pause_feed_when_auth_page_is_pushed_and_resume_on_pop', (
tester,
) async {
testWidgets('should_keep_feed_playing_when_guest_saves', (tester) async {
final players = <_RecordingPlayer>[];
await _pumpApp(
tester,
@@ -615,18 +613,23 @@ void main() {
await tester.tap(find.byKey(const Key('rail-save')));
await tester.pumpAndSettle();
await tester.tap(find.text('Sign In'));
await tester.pumpAndSettle();
expect(find.byType(AuthPage), findsOneWidget);
expect(player.playing, isFalse);
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.byType(AuthPage), findsNothing);
expect(player.playing, isTrue);
});
testWidgets('should_like_without_login_from_recommend_feed', (tester) async {
final interactions = FakeInteractionRepository();
await _pumpApp(tester, interactions: interactions);
await tester.tap(find.byKey(const Key('tab-recommend')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('rail-like')));
await tester.pumpAndSettle();
expect(find.byType(AuthPage), findsNothing);
expect(interactions.likeTargets, ['EPISODE:the-signal-demo-1']);
});
testWidgets('should_keep_video_surface_background_black', (tester) async {
await _pumpApp(tester);
await _openSeriesPlayer(tester);
@@ -643,7 +646,7 @@ void main() {
expect(find.byKey(const Key('player-fallback-scrim')), findsNothing);
});
testWidgets('should_gate_comments_before_opening_sheet_when_signed_out', (
testWidgets('should_open_comments_without_login', (
tester,
) async {
await _pumpApp(tester);
@@ -652,8 +655,8 @@ void main() {
await tester.tap(find.byKey(const Key('player-rail-comments')));
await tester.pumpAndSettle();
expect(find.text('Sign in to continue'), findsOneWidget);
expect(find.byKey(const Key('player-comments-sheet')), findsNothing);
expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget);
expect(find.byType(AuthPage), findsNothing);
});
testWidgets('should_open_feed_comments_when_signed_in', (tester) async {
@@ -1872,6 +1875,12 @@ void main() {
testWidgets('should_open_unlock_panel_with_honest_method_states', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.withData({
'auth.accessToken': 'access',
'auth.refreshToken': 'refresh',
'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}',
});
final interactions = FakeInteractionRepository();
await _pumpApp(
tester,
@@ -1905,6 +1914,36 @@ void main() {
find.text('Available unlock methods vary by title.'),
findsOneWidget,
);
});
testWidgets('should_require_login_before_opening_unlock_flow', (
tester,
) async {
await _pumpApp(
tester,
playback: FakePlaybackRepository(
ticket: const PlayTicket(
episodeId: 'city-lights-demo-1',
accessible: false,
accessType: PlayTicket.coinRequired,
unlockCoin: 30,
),
),
);
await _openSeriesPlayer(tester);
await tester.tap(find.byKey(const Key('player-open-unlock')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byKey(const Key('player-unlock-panel')), findsNothing);
final signIn = find.widgetWithText(FilledButton, 'Sign In');
await tester.ensureVisible(signIn);
await tester.tap(signIn);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(AuthPage), findsOneWidget);
});
}
@@ -0,0 +1,183 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/play_ticket.dart';
import 'package:fruitshorts/core/data/models/player_port.dart';
import 'package:fruitshorts/core/data/models/wallet.dart';
import 'package:fruitshorts/core/data/repositories/commerce_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
import 'package:fruitshorts/core/data/services/playback_session.dart';
import 'package:fruitshorts/core/data/services/wallet_controller.dart';
import 'package:fruitshorts/pages/player/player_logic.dart';
import 'package:fruitshorts/pages/player/player_view.dart';
import '../support/fake_watch_history.dart';
const _lockedDrama = Drama(
id: 'locked',
title: 'Locked',
synopsis: 'synopsis',
posterUrl: '/files/locked.png',
episodes: [
Episode(
id: 'locked-1',
number: 1,
title: 'Locked episode',
isFree: false,
locked: true,
),
],
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('should_not_request_ticket_for_guest_on_locked_episode', () async {
final playback = FakePlaybackRepository();
final logic = _logic(playback, signedIn: false);
logic.attach(
const PlayerPage(drama: _lockedDrama, initialIndex: 0),
isMounted: () => true,
);
logic.start(
factory: (_) => _NoopPlayer(),
coordinator: PlaybackCoordinator(),
);
await _drain(logic);
expect(playback.requests, isEmpty);
expect(logic.session.isLocked, isTrue);
logic.dispose();
await _drain(logic);
});
test('should_request_ticket_after_user_is_signed_in', () async {
final playback = FakePlaybackRepository(
ticket: const PlayTicket(
episodeId: 'locked-1',
accessible: false,
accessType: PlayTicket.coinRequired,
unlockCoin: 30,
),
);
final logic = _logic(playback, signedIn: true);
logic.attach(
const PlayerPage(drama: _lockedDrama, initialIndex: 0),
isMounted: () => true,
);
logic.start(
factory: (_) => _NoopPlayer(),
coordinator: PlaybackCoordinator(),
);
await _drain(logic);
expect(playback.requests, ['locked-1']);
expect(logic.blockedEpisodeId, 'locked-1');
logic.dispose();
await _drain(logic);
});
test('should_resume_unlock_and_play_for_same_episode_after_login', () async {
var signedIn = false;
final playback = FakePlaybackRepository(
ticket: const PlayTicket(
episodeId: 'locked-1',
accessible: true,
accessType: 'ENTITLED',
playUrl: '/files/locked-1.mp4',
),
);
final logic = _logic(
playback,
signedIn: false,
isSignedIn: () => signedIn,
wallet: WalletController(_UnlockCommerceRepository()),
);
logic.attach(
const PlayerPage(drama: _lockedDrama, initialIndex: 0),
isMounted: () => true,
);
logic.start(
factory: (_) => _NoopPlayer(),
coordinator: PlaybackCoordinator(),
);
await _drain(logic);
expect(playback.requests, isEmpty);
expect(logic.blockedEpisodeId, 'locked-1');
signedIn = true;
final unlocked = await logic.unlockEpisode(0);
await _drain(logic);
expect(unlocked, isTrue);
expect(playback.requests, ['locked-1']);
expect(logic.session.active, isNotNull);
logic.dispose();
await _drain(logic);
});
}
PlayerLogic _logic(
PlaybackRepository playback, {
bool signedIn = true,
bool Function()? isSignedIn,
WalletController? wallet,
}) {
return PlayerLogic(
history: FakeWatchHistory(),
playback: playback,
wallet: wallet,
pictureInPicture: AndroidPictureInPicture(
channel: const MethodChannel('test/player_locked_auth_flow/pip'),
),
waitForFrame: () async {},
isSignedIn: isSignedIn ?? () => signedIn,
);
}
Future<void> _drain(PlayerLogic logic) async {
for (var attempt = 0; attempt < 4; attempt++) {
await pumpEventQueue();
await logic.session.playbackSettled;
await logic.session.preloadSettled;
}
}
class _NoopPlayer implements PlayerPort {
@override
Future<void> initialize() async {}
@override
Future<void> play() async {}
@override
Future<void> pause() async {}
@override
Future<void> dispose() async {}
}
class _UnlockCommerceRepository implements CommerceRepository {
@override
Future<WalletSnapshot> fetchWallet() async => const WalletSnapshot(
wallet: CoinWallet(
coinBalance: 100,
totalRechargedCoin: 0,
totalConsumedCoin: 0,
totalRefundedCoin: 0,
),
);
@override
Future<UnlockResult> unlock(String episodeId) async => const UnlockResult(
unlocked: true,
accessType: 'ENTITLED',
coinCost: 30,
coinBalance: 70,
);
}
+3 -3
View File
@@ -266,7 +266,7 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('should_open_auth_gate_without_liking_when_signed_out', (
testWidgets('should_like_without_login_when_signed_out', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
@@ -279,8 +279,8 @@ void main() {
await tester.tap(find.byKey(const Key('player-rail-like')));
await tester.pumpAndSettle();
expect(find.text('Sign in to continue'), findsOneWidget);
expect(interactions.likeTargets, isEmpty);
expect(find.text('Sign in to continue'), findsNothing);
expect(interactions.likeTargets, ['EPISODE:city-lights-demo-1']);
});
testWidgets('should_show_saved_player_drama_in_favorites', (tester) async {