feat(interaction): 支持游客点赞收藏评论
This commit is contained in:
1 parent
188811fde6
commit
a3d50eb935
18 files changed
+364
-121
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', (
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user