Files
ReelShort/lib/pages/player/widgets/drama_comments_sheet.dart
T

499 lines
16 KiB
Dart

// 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/repositories/comment_repository.dart';
import '../../../core/data/repositories/interaction_repository.dart';
import '../../../core/data/services/comment_controller.dart';
import '../../../core/data/services/auth_controller.dart';
import '../../../core/formatting/error_text.dart';
import '../../../core/platform/haptics.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 '../../interaction/widgets/report_dialog.dart';
import 'comment_tile.dart';
const double _minSheetHeight = 280;
const double _maxSheetHeight = 520;
const int _maxCommentLength = 300;
const double _composerRadius = 12;
const double _composerHeight = 52;
const double _sendButtonSize = 40;
const double _sheetRadius = 20;
/// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。
class DramaCommentsSheet extends StatefulWidget {
const DramaCommentsSheet({super.key, required this.drama});
final Drama drama;
@override
State<DramaCommentsSheet> createState() => _DramaCommentsSheetState();
}
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();
}
Future<void> _post() async {
if (_controller.submitting) return;
if (!await ensureSignedIn(context) || !mounted) return;
if (_input.text.trim().isEmpty) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await _controller.publish(_input.text, replyTo: _replyTo);
if (!mounted) return;
if (result.isSuccess) {
_input.clear();
setState(() => _replyTo = null);
FocusScope.of(context).unfocus();
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> _authenticateForComment() async {
_inputFocus.unfocus();
await ensureSignedIn(context);
if (!mounted || !context.read<AuthController>().isSignedIn) return;
_inputFocus.requestFocus();
}
Future<void> _like(DramaComment comment) async {
if (!await ensureSignedIn(context) || !mounted) return;
unawaited(context.read<Haptics>().lightImpact());
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;
unawaited(context.read<Haptics>().lightImpact());
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 available =
MediaQuery.sizeOf(context).height -
MediaQuery.viewInsetsOf(context).bottom -
24;
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: Container(
key: const Key('player-comments-sheet'),
decoration: const BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.vertical(
top: Radius.circular(_sheetRadius),
),
),
child: SizedBox(
height: available.clamp(_minSheetHeight, _maxSheetHeight),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
child: ListenableBuilder(
listenable: _controller,
builder: (context, _) => _content(context),
),
),
),
),
),
);
}
Widget _content(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final count = _controller.exactCount;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
key: const Key('player-comments-drag-handle'),
width: 36,
height: 4,
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: BorderRadius.circular(999),
),
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
count == null ? l10n.comments : l10n.commentsWithCount(count),
key: count == null ? null : const Key('comment-count-label'),
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
IconButton(
key: const Key('player-comments-close'),
tooltip: l10n.close,
onPressed: () => Navigator.pop(context),
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close_rounded, size: 20),
),
],
),
Text(
widget.drama.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
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 EmptyState(
key: const Key('comment-list-error'),
icon: Icons.cloud_off_rounded,
title: errorText(l10n, _controller.error, l10n.commentsLoadFailed),
actionLabel: l10n.retry,
actionKey: const Key('comment-list-retry'),
onAction: _controller.load,
compact: true,
);
}
if (_controller.comments.isEmpty) {
return EmptyState(
key: const Key('comment-list-empty'),
icon: Icons.chat_bubble_outline_rounded,
title: l10n.noComments,
compact: true,
);
}
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.muted, 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),
),
],
),
);
InputDecoration _composerDecoration(String hint) {
const radius = BorderRadius.all(Radius.circular(_composerRadius));
const clear = OutlineInputBorder(
borderRadius: radius,
borderSide: BorderSide.none,
);
return InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14),
counterText: '',
filled: true,
fillColor: AppColors.commentInput,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
isDense: true,
border: clear,
enabledBorder: clear,
focusedBorder: clear,
);
}
Widget _composer(AppLocalizations l10n) {
final signedIn = context.watch<AuthController>().isSignedIn;
return Row(
children: [
Expanded(
child: SizedBox(
height: _composerHeight,
child: TextField(
key: const Key('comment-input'),
controller: _input,
focusNode: _inputFocus,
readOnly: !signedIn,
canRequestFocus: signedIn,
maxLength: _maxCommentLength,
maxLines: 1,
textAlignVertical: TextAlignVertical.center,
style: const TextStyle(color: AppColors.cream, fontSize: 14),
cursorColor: AppColors.blue,
decoration: _composerDecoration(
signedIn ? l10n.writeComment : l10n.commentLoginHint,
),
onTap: signedIn ? null : _authenticateForComment,
onSubmitted: (_) => _post(),
),
),
),
const SizedBox(width: 8),
SizedBox.square(
dimension: _sendButtonSize,
child: IconButton.filled(
key: const Key('comment-post'),
tooltip: l10n.postComment,
padding: EdgeInsets.zero,
iconSize: 18,
onPressed: _controller.submitting ? null : _post,
icon: _controller.submitting
? const SizedBox.square(
dimension: 16,
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: 42, bottom: 4),
child: TextButton(
key: Key('comment-replies-${comment.id}'),
onPressed: () => _toggleReplies(context),
style: TextButton.styleFrom(
foregroundColor: AppColors.blue,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
padding: const EdgeInsets.symmetric(horizontal: 4),
minimumSize: const Size(0, 28),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
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 Padding(
padding: EdgeInsets.only(left: 42),
child: Divider(height: 1, color: AppColors.border),
),
],
);
}
Future<void> _toggleReplies(BuildContext context) async {
if (controller.repliesExpanded(comment.id)) {
controller.hideReplies(comment.id);
return;
}
await controller.loadReplies(comment.id);
}
}