403 lines
12 KiB
Dart
403 lines
12 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/formatting/error_text.dart';
|
|
import '../../../core/platform/haptics.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});
|
|
|
|
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 (_input.text.trim().isEmpty) return;
|
|
if (!await ensureSignedIn(context) || !mounted) 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);
|
|
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;
|
|
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: SizedBox(
|
|
height: available.clamp(_minSheetHeight, _maxSheetHeight),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
|
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);
|
|
}
|
|
}
|