feat(flutter): 接入互动评论点赞举报
This commit is contained in:
1 parent
45f5f25640
commit
d361034226
39 files changed
+3298
-462
No files matched your search
@@ -8,17 +8,21 @@ import 'package:provider/provider.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../../core/data/services/interaction_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/formatting/localized_content.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
import '../../core/widgets/download_action.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../interaction/widgets/report_dialog.dart';
|
||||
import 'drama_detail_binding.dart';
|
||||
import 'drama_detail_logic.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
|
||||
const String _reportMenuValue = 'report';
|
||||
|
||||
class DramaDetailPage extends StatefulWidget {
|
||||
const DramaDetailPage({super.key, required this.drama});
|
||||
final Drama drama;
|
||||
@@ -138,6 +142,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
backgroundColor: const Color(0xFF111111),
|
||||
foregroundColor: Colors.white,
|
||||
actions: [
|
||||
_reportAction(context, l10n),
|
||||
_downloadAction(context, l10n),
|
||||
_favoriteAction(context, l10n),
|
||||
],
|
||||
@@ -291,6 +296,42 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 详情页更多菜单:本批只提供举报短剧。
|
||||
Widget _reportAction(BuildContext context, AppLocalizations l10n) {
|
||||
return PopupMenuButton<String>(
|
||||
key: const Key('detail-more'),
|
||||
tooltip: l10n.more,
|
||||
onSelected: (_) => unawaited(_reportDrama(l10n)),
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(
|
||||
value: _reportMenuValue,
|
||||
child: Text(l10n.reportDrama),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _reportDrama(AppLocalizations l10n) async {
|
||||
if (!await ensureSignedIn(context) || !mounted) return;
|
||||
final draft = await showReportDialog(context, title: l10n.reportDrama);
|
||||
if (draft == null || !mounted) return;
|
||||
final result = await context.read<InteractionController>().reportDrama(
|
||||
logic.drama.id,
|
||||
reason: draft.reason,
|
||||
description: draft.description,
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
result.isSuccess
|
||||
? l10n.reportSubmitted
|
||||
: result.errorMessage ?? l10n.reportFailed,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 收藏按钮:状态取自服务端(乐观更新,失败回滚并提示)。
|
||||
Widget _favoriteAction(BuildContext context, AppLocalizations l10n) {
|
||||
final favorites = context.watch<FavoriteController>();
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/interaction.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const int _maxDescriptionLength = 300;
|
||||
|
||||
/// 举报弹窗收集的结果。
|
||||
class ReportDialogResult {
|
||||
const ReportDialogResult({
|
||||
required this.reason,
|
||||
required this.description,
|
||||
});
|
||||
|
||||
final ReportReason reason;
|
||||
final String description;
|
||||
}
|
||||
|
||||
/// 打开举报原因单选弹窗;取消时返回 null。
|
||||
Future<ReportDialogResult?> showReportDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
}) => showDialog<ReportDialogResult>(
|
||||
context: context,
|
||||
builder: (_) => _ReportDialog(title: title),
|
||||
);
|
||||
|
||||
class _ReportDialog extends StatefulWidget {
|
||||
const _ReportDialog({required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<_ReportDialog> createState() => _ReportDialogState();
|
||||
}
|
||||
|
||||
class _ReportDialogState extends State<_ReportDialog> {
|
||||
final _description = TextEditingController();
|
||||
ReportReason _reason = ReportReason.spam;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_description.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
Navigator.of(context).pop(
|
||||
ReportDialogResult(
|
||||
reason: _reason,
|
||||
description: _description.text.trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _reasonLabel(AppLocalizations l10n, ReportReason reason) =>
|
||||
switch (reason) {
|
||||
ReportReason.spam => l10n.reportReasonSpam,
|
||||
ReportReason.abuse => l10n.reportReasonAbuse,
|
||||
ReportReason.porn => l10n.reportReasonPorn,
|
||||
ReportReason.ad => l10n.reportReasonAd,
|
||||
ReportReason.other => l10n.reportReasonOther,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.reportReason),
|
||||
RadioGroup<ReportReason>(
|
||||
groupValue: _reason,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() => _reason = value);
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
for (final reason in ReportReason.values)
|
||||
RadioListTile<ReportReason>(
|
||||
key: Key('report-reason-${reason.apiValue}'),
|
||||
value: reason,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: Text(_reasonLabel(l10n, reason)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
key: const Key('report-description'),
|
||||
controller: _description,
|
||||
maxLength: _maxDescriptionLength,
|
||||
maxLines: 3,
|
||||
minLines: 2,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.reportDescriptionHint,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
key: const Key('report-submit'),
|
||||
onPressed: _submit,
|
||||
child: Text(l10n.reportSubmit),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import '../../core/formatting/localized_content.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/data/services/engagement_controller.dart';
|
||||
import '../../core/data/services/interaction_controller.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
@@ -474,14 +474,14 @@ class _MyPageState extends State<MyPage> {
|
||||
logic.filter == 0 ||
|
||||
(logic.filter == 1 && fraction < 0.99) ||
|
||||
(logic.filter == 2 && fraction >= 0.99);
|
||||
final engagement = context.watch<EngagementController>();
|
||||
// 收藏以服务端为准(收藏列表接口返回卡片结构);点赞仍是本地状态(K3 接互动)。
|
||||
final interactions = context.watch<InteractionController>();
|
||||
// 收藏以服务端为准;剧集点赞暂按当前会话状态过滤 feed。
|
||||
final favorites = context.watch<FavoriteController>();
|
||||
final isWatchlist = logic.section == 1;
|
||||
final markedDramas = isWatchlist
|
||||
? favorites.cards
|
||||
: (widget.dramas ?? [widget.drama])
|
||||
.where((drama) => engagement.isLiked(drama.id))
|
||||
.where((drama) => interactions.isLiked(drama.id))
|
||||
.toList();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -9,8 +9,8 @@ import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/play_ticket.dart';
|
||||
import '../../core/data/services/engagement_controller.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../../core/data/services/interaction_controller.dart';
|
||||
import '../../core/data/services/playback_session.dart';
|
||||
import '../../core/data/services/video_player_port.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
@@ -108,7 +108,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
Widget _buildPlayer(BuildContext context) {
|
||||
final engagement = context.watch<EngagementController>();
|
||||
final interactions = context.watch<InteractionController>();
|
||||
final indices = logic.visibleIndices;
|
||||
final pages = logic.pages;
|
||||
final content = AnimatedBuilder(
|
||||
@@ -124,7 +124,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
unawaited(logic.selectEpisode(index));
|
||||
},
|
||||
itemBuilder: (context, position) =>
|
||||
_buildEpisode(context, indices[position], engagement),
|
||||
_buildEpisode(context, indices[position], interactions),
|
||||
),
|
||||
);
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
@@ -141,7 +141,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
Widget _buildEpisode(
|
||||
BuildContext context,
|
||||
int index,
|
||||
EngagementController engagement,
|
||||
InteractionController interactions,
|
||||
) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final entry = logic.queue.entryAt(index);
|
||||
@@ -336,7 +336,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
drama: drama,
|
||||
episode: episode,
|
||||
controller: ready ? videoPort!.controller : null,
|
||||
liked: engagement.isLiked(drama.id),
|
||||
liked: interactions.isLiked(drama.id),
|
||||
// 收藏以服务端为准(FavoriteController 由 app 注入,登录后自动加载)
|
||||
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
||||
onBack: widget.embedded
|
||||
@@ -348,7 +348,17 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
// 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览)
|
||||
onLike: () async {
|
||||
if (await ensureSignedIn(context)) {
|
||||
unawaited(engagement.toggleLiked(drama.id));
|
||||
final result = await interactions.toggleDramaLike(drama.id);
|
||||
if (!result.isSuccess && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
result.errorMessage ??
|
||||
AppLocalizations.of(context)!.likeFailed,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSave: () async {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/comment.dart';
|
||||
import '../../../core/data/services/comment_controller.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const String _deleteAction = 'delete';
|
||||
const String _reportAction = 'report';
|
||||
|
||||
/// 评论条目的交互回调集合。
|
||||
class CommentInteractionHandlers {
|
||||
const CommentInteractionHandlers({
|
||||
required this.onReply,
|
||||
required this.onLike,
|
||||
required this.onDelete,
|
||||
required this.onReport,
|
||||
});
|
||||
|
||||
final ValueChanged<DramaComment> onReply;
|
||||
final Future<void> Function(DramaComment) onLike;
|
||||
final Future<void> Function(DramaComment) onDelete;
|
||||
final Future<void> Function(DramaComment) onReport;
|
||||
}
|
||||
|
||||
/// 单条评论或回复的展示与操作行。
|
||||
class CommentTile extends StatelessWidget {
|
||||
const CommentTile({
|
||||
super.key,
|
||||
required this.comment,
|
||||
required this.controller,
|
||||
required this.handlers,
|
||||
this.isReply = false,
|
||||
});
|
||||
|
||||
final DramaComment comment;
|
||||
final CommentController controller;
|
||||
final CommentInteractionHandlers handlers;
|
||||
final bool isReply;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final name = comment.nickname.isEmpty ? l10n.anonymousUser : comment.nickname;
|
||||
return Padding(
|
||||
key: Key('comment-item-${comment.id}'),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: isReply ? 12 : 16,
|
||||
backgroundColor: AppColors.blue.withValues(alpha: 0.2),
|
||||
child: Icon(
|
||||
Icons.person_rounded,
|
||||
size: isReply ? 14 : 18,
|
||||
color: AppColors.blueSoft,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
key: Key('comment-menu-${comment.id}'),
|
||||
tooltip: l10n.more,
|
||||
onSelected: (value) {
|
||||
if (value == _deleteAction) {
|
||||
handlers.onDelete(comment);
|
||||
} else if (value == _reportAction) {
|
||||
handlers.onReport(comment);
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
if (comment.mine)
|
||||
PopupMenuItem(
|
||||
value: _deleteAction,
|
||||
child: Text(l10n.deleteComment),
|
||||
)
|
||||
else
|
||||
PopupMenuItem(
|
||||
value: _reportAction,
|
||||
child: Text(l10n.reportComment),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
comment.content,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||
),
|
||||
if (comment.isPending)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
l10n.underReview,
|
||||
key: Key('comment-pending-${comment.id}'),
|
||||
style: const TextStyle(
|
||||
color: AppColors.blueSoft,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
key: Key('comment-like-${comment.id}'),
|
||||
onPressed: controller.likingComment(comment.id)
|
||||
? null
|
||||
: () => handlers.onLike(comment),
|
||||
icon: Icon(
|
||||
comment.liked
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
size: 16,
|
||||
),
|
||||
label: Text('${comment.likeCount}'),
|
||||
),
|
||||
if (!isReply)
|
||||
TextButton(
|
||||
key: Key('comment-reply-${comment.id}'),
|
||||
onPressed: () => handlers.onReply(comment),
|
||||
child: Text(l10n.reply),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,27 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../core/data/models/comment.dart';
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/services/engagement_controller.dart';
|
||||
import '../../../core/data/repositories/comment_repository.dart';
|
||||
import '../../../core/data/repositories/interaction_repository.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 '../../../l10n/app_localizations.dart';
|
||||
import '../../interaction/widgets/report_dialog.dart';
|
||||
import 'comment_tile.dart';
|
||||
|
||||
const double _minSheetHeight = 240;
|
||||
const double _maxSheetHeight = 460;
|
||||
const int _maxCommentLength = 300;
|
||||
|
||||
/// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。
|
||||
class DramaCommentsSheet extends StatefulWidget {
|
||||
const DramaCommentsSheet({super.key, required this.drama});
|
||||
|
||||
@@ -18,26 +33,120 @@ class DramaCommentsSheet extends StatefulWidget {
|
||||
|
||||
class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
||||
final _input = TextEditingController();
|
||||
final _inputFocus = FocusNode();
|
||||
late final CommentController _controller;
|
||||
DramaComment? _replyTo;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = CommentController(
|
||||
comments: context.read<CommentRepository>(),
|
||||
interactions: context.read<InteractionRepository>(),
|
||||
dramaId: widget.drama.id,
|
||||
);
|
||||
unawaited(_controller.load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_input.dispose();
|
||||
_inputFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _post() {
|
||||
final text = _input.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
context.read<EngagementController>().addComment(widget.drama.id, text);
|
||||
_input.clear();
|
||||
Future<void> _post() async {
|
||||
if (_controller.submitting) return;
|
||||
if (_input.text.trim().isEmpty) return;
|
||||
if (!await ensureSignedIn(context) || !mounted) return;
|
||||
final result = await _controller.publish(_input.text, replyTo: _replyTo);
|
||||
if (!mounted) return;
|
||||
if (result.isSuccess) {
|
||||
_input.clear();
|
||||
setState(() => _replyTo = null);
|
||||
return;
|
||||
}
|
||||
_showMessage(
|
||||
result.errorMessage ?? AppLocalizations.of(context)!.commentPostFailed,
|
||||
);
|
||||
}
|
||||
|
||||
void _beginReply(DramaComment comment) {
|
||||
setState(() => _replyTo = comment);
|
||||
_inputFocus.requestFocus();
|
||||
}
|
||||
|
||||
void _cancelReply() {
|
||||
if (_replyTo == null) return;
|
||||
setState(() => _replyTo = null);
|
||||
}
|
||||
|
||||
Future<void> _like(DramaComment comment) async {
|
||||
if (!await ensureSignedIn(context) || !mounted) return;
|
||||
final result = await _controller.toggleLike(comment);
|
||||
if (!result.isSuccess && mounted) {
|
||||
_showMessage(
|
||||
result.errorMessage ?? AppLocalizations.of(context)!.commentPostFailed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(DramaComment comment) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(l10n.deleteCommentTitle),
|
||||
content: Text(l10n.deleteCommentMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
key: const Key('comment-delete-confirm'),
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(l10n.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
final result = await _controller.delete(comment);
|
||||
if (!result.isSuccess && mounted) {
|
||||
_showMessage(
|
||||
result.errorMessage ?? AppLocalizations.of(context)!.commentDeleteFailed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _report(DramaComment comment) async {
|
||||
if (!await ensureSignedIn(context) || !mounted) return;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final draft = await showReportDialog(context, title: l10n.reportComment);
|
||||
if (draft == null || !mounted) return;
|
||||
final result = await _controller.report(
|
||||
comment,
|
||||
reason: draft.reason,
|
||||
description: draft.description,
|
||||
);
|
||||
if (!mounted) return;
|
||||
_showMessage(
|
||||
result.isSuccess
|
||||
? l10n.reportSubmitted
|
||||
: result.errorMessage ?? l10n.reportFailed,
|
||||
);
|
||||
}
|
||||
|
||||
void _showMessage(String message) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final comments = context.watch<EngagementController>().commentsFor(
|
||||
widget.drama.id,
|
||||
);
|
||||
final available =
|
||||
MediaQuery.sizeOf(context).height -
|
||||
MediaQuery.viewInsetsOf(context).bottom -
|
||||
@@ -45,83 +154,244 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
||||
child: SizedBox(
|
||||
height: available.clamp(240.0, 460.0),
|
||||
height: available.clamp(_minSheetHeight, _maxSheetHeight),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.comments,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
l10n.localOnlyComments,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Expanded(
|
||||
child: comments.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
l10n.noComments,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: comments.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) => ListTile(
|
||||
leading: const CircleAvatar(
|
||||
radius: 16,
|
||||
child: Icon(Icons.person_rounded, size: 18),
|
||||
),
|
||||
title: Text(comments[index]),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: const Key('comment-input'),
|
||||
controller: _input,
|
||||
maxLength: 300,
|
||||
maxLines: 1,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.writeComment,
|
||||
counterText: '',
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF35353A),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _post(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
key: const Key('comment-post'),
|
||||
tooltip: l10n.postComment,
|
||||
onPressed: _post,
|
||||
icon: const Icon(Icons.send_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
child: ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (context, _) => _content(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _content(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.comments,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(child: _list(l10n)),
|
||||
if (_replyTo != null) _replyBanner(l10n, _replyTo!),
|
||||
_composer(l10n),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _list(AppLocalizations l10n) {
|
||||
if (_controller.loading && _controller.comments.isEmpty) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(key: Key('comment-list-loading')),
|
||||
);
|
||||
}
|
||||
if (_controller.error != null && _controller.comments.isEmpty) {
|
||||
return Center(
|
||||
key: const Key('comment-list-error'),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
errorText(l10n, _controller.error, l10n.commentsLoadFailed),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.muted),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
key: const Key('comment-list-retry'),
|
||||
onPressed: _controller.load,
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_controller.comments.isEmpty) {
|
||||
return Center(
|
||||
key: const Key('comment-list-empty'),
|
||||
child: Text(
|
||||
l10n.noComments,
|
||||
style: const TextStyle(color: AppColors.muted),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
key: const Key('comment-list'),
|
||||
itemCount: _controller.comments.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _controller.comments.length) return _footer(l10n);
|
||||
return _CommentThread(
|
||||
comment: _controller.comments[index],
|
||||
controller: _controller,
|
||||
handlers: CommentInteractionHandlers(
|
||||
onReply: _beginReply,
|
||||
onLike: _like,
|
||||
onDelete: _confirmDelete,
|
||||
onReport: _report,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _footer(AppLocalizations l10n) {
|
||||
if (_controller.loadingMore) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (_controller.hasMore) {
|
||||
return TextButton(
|
||||
key: const Key('comment-list-load-more'),
|
||||
onPressed: _controller.loadMore,
|
||||
child: Text(l10n.loadMore),
|
||||
);
|
||||
}
|
||||
return const SizedBox(height: 8);
|
||||
}
|
||||
|
||||
Widget _replyBanner(AppLocalizations l10n, DramaComment replyTo) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.replyingTo(
|
||||
replyTo.nickname.isEmpty ? l10n.anonymousUser : replyTo.nickname,
|
||||
),
|
||||
style: const TextStyle(color: AppColors.blueSoft, fontSize: 12),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('comment-cancel-reply'),
|
||||
tooltip: l10n.cancelReply,
|
||||
onPressed: _cancelReply,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.close_rounded, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _composer(AppLocalizations l10n) => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: const Key('comment-input'),
|
||||
controller: _input,
|
||||
focusNode: _inputFocus,
|
||||
maxLength: _maxCommentLength,
|
||||
maxLines: 1,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.writeComment,
|
||||
counterText: '',
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF35353A),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _post(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
key: const Key('comment-post'),
|
||||
tooltip: l10n.postComment,
|
||||
onPressed: _controller.submitting ? null : _post,
|
||||
icon: _controller.submitting
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send_rounded),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _CommentThread extends StatelessWidget {
|
||||
const _CommentThread({
|
||||
required this.comment,
|
||||
required this.controller,
|
||||
required this.handlers,
|
||||
});
|
||||
|
||||
final DramaComment comment;
|
||||
final CommentController controller;
|
||||
final CommentInteractionHandlers handlers;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final replies = controller.repliesFor(comment.id);
|
||||
final expanded = controller.repliesExpanded(comment.id);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CommentTile(
|
||||
comment: comment,
|
||||
controller: controller,
|
||||
handlers: handlers,
|
||||
),
|
||||
if (comment.replyCount > 0 || replies.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 44, bottom: 8),
|
||||
child: TextButton(
|
||||
key: Key('comment-replies-${comment.id}'),
|
||||
onPressed: () => _toggleReplies(context),
|
||||
child: Text(
|
||||
expanded
|
||||
? AppLocalizations.of(context)!.hideReplies
|
||||
: AppLocalizations.of(
|
||||
context,
|
||||
)!.viewReplies(comment.replyCount),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (expanded)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 28),
|
||||
child: Column(
|
||||
children: [
|
||||
if (controller.repliesLoading(comment.id))
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
for (final reply in replies)
|
||||
CommentTile(
|
||||
comment: reply,
|
||||
controller: controller,
|
||||
handlers: handlers,
|
||||
isReply: true,
|
||||
),
|
||||
if (controller.repliesHasMore(comment.id))
|
||||
TextButton(
|
||||
onPressed: () => controller.loadMoreReplies(comment.id),
|
||||
child: Text(AppLocalizations.of(context)!.loadMore),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleReplies(BuildContext context) async {
|
||||
if (controller.repliesExpanded(comment.id)) {
|
||||
controller.hideReplies(comment.id);
|
||||
return;
|
||||
}
|
||||
await controller.loadReplies(comment.id);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user