diff --git a/lib/core/data/models/interaction.dart b/lib/core/data/models/interaction.dart index b00700f..259ec26 100644 --- a/lib/core/data/models/interaction.dart +++ b/lib/core/data/models/interaction.dart @@ -58,14 +58,16 @@ class LikeToggleResult { /// 控制器统一返回结果;失败消息可直接展示后端 `message`。 class OperationResult { - 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; } diff --git a/lib/core/data/services/comment_controller.dart b/lib/core/data/services/comment_controller.dart index 26213cb..8762880 100644 --- a/lib/core/data/services/comment_controller.dart +++ b/lib/core/data/services/comment_controller.dart @@ -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, + ); } } diff --git a/lib/core/data/services/favorite_controller.dart b/lib/core/data/services/favorite_controller.dart index d57ca21..dcfb9bc 100644 --- a/lib/core/data/services/favorite_controller.dart +++ b/lib/core/data/services/favorite_controller.dart @@ -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 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; } } diff --git a/lib/core/data/services/interaction_controller.dart b/lib/core/data/services/interaction_controller.dart index a21f247..b0c241b 100644 --- a/lib/core/data/services/interaction_controller.dart +++ b/lib/core/data/services/interaction_controller.dart @@ -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, + ); } } diff --git a/lib/core/widgets/auth_gate.dart b/lib/core/widgets/auth_gate.dart index 9c78b84..b238dfc 100644 --- a/lib/core/widgets/auth_gate.dart +++ b/lib/core/widgets/auth_gate.dart @@ -15,7 +15,7 @@ Future openAuthPage(BuildContext context, {bool signUpMode = false}) { /// 受限动作的登录闸:已登录返回 true;未登录弹底部提示并引导登录,返回 false。 /// -/// 首页/发现/详情保持匿名可看,只有收藏、点赞、评论这类"要对到具体账号"的动作才走这里。 +/// 首页/发现/详情与非付费互动保持匿名可用;解锁、权益与消耗类动作走这里。 Future ensureSignedIn(BuildContext context) async { final auth = context.read(); if (auth.isSignedIn) return true; @@ -76,6 +76,7 @@ Future ensureSignedIn(BuildContext context) async { if (go == true && context.mounted) { await openAuthPage(context); + if (context.mounted) return context.read().isSignedIn; } return false; } diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index 78b038e..4520e5b 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -164,7 +164,9 @@ class _DramaDetailPageState extends State { ), ), 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 { } Future _toggleFavorite(AppLocalizations l10n) async { - if (!await ensureSignedIn(context)) return; if (!mounted) return; unawaited(context.read().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 { } List _tagValues(BuildContext context, Drama drama) { - final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode); + final localized = drama.localizedTypeLabels( + Localizations.localeOf(context).languageCode, + ); final result = []; if (localized.isNotEmpty) { result.addAll(localized.take(_maxTags)); diff --git a/lib/pages/player/comments_auth.dart b/lib/pages/player/comments_auth.dart index c1178bc..fb92e6e 100644 --- a/lib/pages/player/comments_auth.dart +++ b/lib/pages/player/comments_auth.dart @@ -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 openCommentsWithAuth( +/// 评论列表与非付费互动均支持设备身份,不再把登录作为入口前置条件。 +Future openComments( BuildContext context, Drama drama, { required Future Function(BuildContext context, Drama drama) show, }) async { - final auth = context.read(); - if (auth.isSignedIn) { - await show(context, drama); - return; - } - await ensureSignedIn(context); - if (!context.mounted || !auth.isSignedIn) return; await show(context, drama); } diff --git a/lib/pages/player/player_binding.dart b/lib/pages/player/player_binding.dart index 21ae3bc..256e822 100644 --- a/lib/pages/player/player_binding.dart +++ b/lib/pages/player/player_binding.dart @@ -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, ); } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 4bb67af..2505d26 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -53,10 +53,12 @@ class PlayerLogic extends ChangeNotifier { PlaybackPositionCache? positionCache, Future 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 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 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); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 0e9c110..74198bf 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -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 positionCache: context.read(), wallet: widget.wallet, downloads: widget.downloads, + isSignedIn: () => context.read().isSignedIn, ); logic.onEpisodeStatsUpdated = _applyEpisodeStats; logic.attach(widget, isMounted: () => mounted); @@ -590,7 +592,7 @@ class _PlayerPageState extends State Episode episode, ) async { final comments = context.read(); - await openCommentsWithAuth( + await openComments( context, drama, show: (context, drama) => @@ -617,6 +619,13 @@ class _PlayerPageState extends State Widget _buildPlayer(BuildContext context) { final interactions = context.watch(); 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 'mayPlay=${logic.session.debugMayPlay}', ); } - final indices = logic.visibleIndices; final activePort = logic.session.active; final activeVideo = activePort is VideoPlayerPort ? activePort : null; final canWaitForVideoFrame = diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 5a19507..2efb5fb 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -65,6 +65,11 @@ extension _PlayerViewActions on _PlayerPageState { ) async { final wallet = logic.wallet; if (wallet == null) return; + if (!context.read().isSignedIn && + !await ensureSignedIn(context)) { + return; + } + if (!context.mounted) return; await showModalBottomSheet( context: context, backgroundColor: AppColors.transparent, @@ -81,7 +86,7 @@ extension _PlayerViewActions on _PlayerPageState { } Future _unlockEpisode(BuildContext context, int index) async { - if (!await ensureSignedIn(context) || !context.mounted) return false; + if (!context.read().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 _toggleSave(BuildContext context, Drama drama) async { final favorites = context.read(); - if (!await ensureSignedIn(context) || !context.mounted) return; unawaited(context.read().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), diff --git a/lib/pages/player/widgets/comment_composer_sheet.dart b/lib/pages/player/widgets/comment_composer_sheet.dart index 9f9ce82..d0a4085 100644 --- a/lib/pages/player/widgets/comment_composer_sheet.dart +++ b/lib/pages/player/widgets/comment_composer_sheet.dart @@ -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 { 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 _authenticate() async { - await ensureSignedIn(context); - if (!mounted || !context.read().isSignedIn) return; - _inputFocus.requestFocus(); - } - @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final signedIn = context.watch().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 { 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 { ), 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 { enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, ), - onTap: signedIn ? null : _authenticate, + onTap: null, ), ), const SizedBox(height: 12), diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index 90d0b89..4e7204e 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -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 { } Future _openComposer({DramaComment? replyTo}) async { - await ensureSignedIn(context); - if (!mounted || !context.read().isSignedIn) return; + if (!mounted) return; _removeComposerOverlay(); final entry = OverlayEntry( builder: (_) => CommentComposerSheet( @@ -222,7 +219,6 @@ class _DramaCommentsSheetState extends State { Widget _content(BuildContext context) { final l10n = AppLocalizations.of(context)!; final count = _serverCount ?? _controller.exactCount; - final signedIn = context.watch().isSignedIn; final composerReserve = _composerHeight + _composerTopGap; return Column( key: const Key('comment-sheet-content'), @@ -269,7 +265,7 @@ class _DramaCommentsSheetState extends State { 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 { 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 { 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, diff --git a/lib/pages/player/widgets/player_unlock_sheet.dart b/lib/pages/player/widgets/player_unlock_sheet.dart index a47579e..8b2a9c1 100644 --- a/lib/pages/player/widgets/player_unlock_sheet.dart +++ b/lib/pages/player/widgets/player_unlock_sheet.dart @@ -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) { diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 8de463c..d410df6 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -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', ( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index eb58e01..9cf0f33 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -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); }); } diff --git a/test/player/player_locked_auth_flow_test.dart b/test/player/player_locked_auth_flow_test.dart new file mode 100644 index 0000000..c3e99ac --- /dev/null +++ b/test/player/player_locked_auth_flow_test.dart @@ -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 _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 initialize() async {} + + @override + Future play() async {} + + @override + Future pause() async {} + + @override + Future dispose() async {} +} + +class _UnlockCommerceRepository implements CommerceRepository { + @override + Future fetchWallet() async => const WalletSnapshot( + wallet: CoinWallet( + coinBalance: 100, + totalRechargedCoin: 0, + totalConsumedCoin: 0, + totalRefundedCoin: 0, + ), + ); + + @override + Future unlock(String episodeId) async => const UnlockResult( + unlocked: true, + accessType: 'ENTITLED', + coinCost: 30, + coinBalance: 70, + ); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index b8c4d3d..ef71478 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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 {