254 lines
7.5 KiB
Dart
254 lines
7.5 KiB
Dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../models/comment_item.dart';
|
|
import '../services/comment_service.dart';
|
|
import 'auth_provider.dart';
|
|
|
|
/// 回复目标(null 表示直接评论视频)
|
|
class ReplyTarget {
|
|
const ReplyTarget({
|
|
required this.parentId,
|
|
required this.replyToUserId,
|
|
required this.nickname,
|
|
});
|
|
|
|
/// 一级评论 ID(楼中楼统一挂一级楼下)
|
|
final int parentId;
|
|
|
|
/// 被回复人 ID(回复一级评论时等于其作者)
|
|
final int replyToUserId;
|
|
|
|
/// 被回复人昵称(输入框提示用)
|
|
final String nickname;
|
|
}
|
|
|
|
/// 评论区状态
|
|
class CommentState {
|
|
const CommentState({
|
|
this.items = const [],
|
|
this.total = 0,
|
|
this.isLoading = false,
|
|
this.isLoadingMore = false,
|
|
this.loadFailed = false,
|
|
this.page = 1,
|
|
this.replies = const {},
|
|
this.repliesLoading = const {},
|
|
this.replyTarget,
|
|
this.posting = false,
|
|
});
|
|
|
|
/// 一级评论列表
|
|
final List<CommentItem> items;
|
|
final int total;
|
|
final bool isLoading;
|
|
final bool isLoadingMore;
|
|
|
|
/// 首屏加载失败
|
|
final bool loadFailed;
|
|
final int page;
|
|
|
|
/// 已展开的一级评论的二级回复列表
|
|
final Map<int, List<CommentItem>> replies;
|
|
|
|
/// 回复加载中的一级评论 ID
|
|
final Set<int> repliesLoading;
|
|
|
|
/// 当前回复目标(null = 评论视频本身)
|
|
final ReplyTarget? replyTarget;
|
|
|
|
/// 发表中(防重复提交)
|
|
final bool posting;
|
|
|
|
static const int pageSize = 20;
|
|
|
|
bool get hasMore => items.length < total;
|
|
|
|
CommentState copyWith({
|
|
List<CommentItem>? items,
|
|
int? total,
|
|
bool? isLoading,
|
|
bool? isLoadingMore,
|
|
bool? loadFailed,
|
|
int? page,
|
|
Map<int, List<CommentItem>>? replies,
|
|
Set<int>? repliesLoading,
|
|
ReplyTarget? Function()? replyTarget,
|
|
bool? posting,
|
|
}) =>
|
|
CommentState(
|
|
items: items ?? this.items,
|
|
total: total ?? this.total,
|
|
isLoading: isLoading ?? this.isLoading,
|
|
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
|
loadFailed: loadFailed ?? this.loadFailed,
|
|
page: page ?? this.page,
|
|
replies: replies ?? this.replies,
|
|
repliesLoading: repliesLoading ?? this.repliesLoading,
|
|
replyTarget: replyTarget != null ? replyTarget() : this.replyTarget,
|
|
posting: posting ?? this.posting,
|
|
);
|
|
}
|
|
|
|
/// 评论区 Notifier(按 videoId 家族隔离)
|
|
class CommentNotifier extends StateNotifier<CommentState> {
|
|
CommentNotifier(this._ref, this.videoId) : super(const CommentState()) {
|
|
load();
|
|
}
|
|
|
|
final Ref _ref;
|
|
final int videoId;
|
|
|
|
/// 首屏加载
|
|
Future<void> load() async {
|
|
state = state.copyWith(isLoading: true, loadFailed: false);
|
|
try {
|
|
final page = await CommentService.list(videoId);
|
|
state = state.copyWith(
|
|
items: page.items,
|
|
total: page.total,
|
|
isLoading: false,
|
|
page: 1,
|
|
);
|
|
} catch (_) {
|
|
state = state.copyWith(isLoading: false, loadFailed: true);
|
|
}
|
|
}
|
|
|
|
/// 触底加载更多一级评论
|
|
Future<void> loadMore() async {
|
|
if (!state.hasMore || state.isLoadingMore || state.isLoading) return;
|
|
state = state.copyWith(isLoadingMore: true);
|
|
try {
|
|
final page = await CommentService.list(videoId, page: state.page + 1);
|
|
state = state.copyWith(
|
|
items: [...state.items, ...page.items],
|
|
total: page.total,
|
|
isLoadingMore: false,
|
|
page: state.page + 1,
|
|
);
|
|
} catch (_) {
|
|
state = state.copyWith(isLoadingMore: false);
|
|
}
|
|
}
|
|
|
|
/// 展开/收起二级回复;首次展开时加载(一次拉 50 条,超出部分显示总数)
|
|
Future<void> toggleReplies(CommentItem comment) async {
|
|
final replies = Map<int, List<CommentItem>>.from(state.replies);
|
|
if (replies.containsKey(comment.commentId)) {
|
|
replies.remove(comment.commentId);
|
|
state = state.copyWith(replies: replies);
|
|
return;
|
|
}
|
|
state = state.copyWith(
|
|
repliesLoading: {...state.repliesLoading, comment.commentId},
|
|
);
|
|
try {
|
|
final page = await CommentService.list(
|
|
videoId,
|
|
parentId: comment.commentId,
|
|
pageSize: 50,
|
|
);
|
|
state = state.copyWith(
|
|
replies: {...state.replies, comment.commentId: page.items},
|
|
repliesLoading: {...state.repliesLoading}..remove(comment.commentId),
|
|
);
|
|
} catch (_) {
|
|
state = state.copyWith(
|
|
repliesLoading: {...state.repliesLoading}..remove(comment.commentId),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 设置/清除回复目标
|
|
void setReplyTarget(ReplyTarget? target) =>
|
|
state = state.copyWith(replyTarget: () => target);
|
|
|
|
/// 发表评论/回复。成功返回结果(含奖励进度),失败抛异常由 UI 提示
|
|
Future<CommentCreateResult> post(String content) async {
|
|
final user = _ref.read(authProvider).user;
|
|
final target = state.replyTarget;
|
|
state = state.copyWith(posting: true);
|
|
try {
|
|
final result = await CommentService.create(
|
|
videoId,
|
|
content,
|
|
parentId: target?.parentId,
|
|
replyToUserId: target?.replyToUserId,
|
|
);
|
|
final item = CommentItem(
|
|
commentId: result.commentId,
|
|
userId: user?.userId ?? 0,
|
|
nickname: user?.nickname ?? '我',
|
|
avatarUrl: user?.avatarUrl ?? '',
|
|
content: content,
|
|
createdAt: '刚刚',
|
|
);
|
|
if (target == null) {
|
|
// 一级评论:插入顶部
|
|
state = state.copyWith(
|
|
items: [item, ...state.items],
|
|
total: state.total + 1,
|
|
posting: false,
|
|
);
|
|
} else {
|
|
// 回复:插入已展开列表,父评论 replyCount+1
|
|
final replies = Map<int, List<CommentItem>>.from(state.replies);
|
|
if (replies.containsKey(target.parentId)) {
|
|
replies[target.parentId] = [item, ...replies[target.parentId]!];
|
|
}
|
|
state = state.copyWith(
|
|
replies: replies,
|
|
items: [
|
|
for (final c in state.items)
|
|
c.commentId == target.parentId
|
|
? c.copyWith(replyCount: c.replyCount + 1)
|
|
: c,
|
|
],
|
|
total: state.total + 1,
|
|
posting: false,
|
|
replyTarget: () => null,
|
|
);
|
|
}
|
|
return result;
|
|
} catch (_) {
|
|
state = state.copyWith(posting: false);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// 删除评论:parentId=0 为一级评论,否则为某一级楼下的回复
|
|
Future<void> remove(CommentItem comment, {int parentId = 0}) async {
|
|
await CommentService.delete(comment.commentId);
|
|
if (parentId == 0) {
|
|
final replies = Map<int, List<CommentItem>>.from(state.replies)
|
|
..remove(comment.commentId);
|
|
state = state.copyWith(
|
|
items: state.items.where((c) => c.commentId != comment.commentId).toList(),
|
|
total: (state.total - 1).clamp(0, 1 << 31),
|
|
replies: replies,
|
|
);
|
|
} else {
|
|
final replies = Map<int, List<CommentItem>>.from(state.replies);
|
|
replies[parentId] = (replies[parentId] ?? [])
|
|
.where((c) => c.commentId != comment.commentId)
|
|
.toList();
|
|
state = state.copyWith(
|
|
replies: replies,
|
|
items: [
|
|
for (final c in state.items)
|
|
c.commentId == parentId
|
|
? c.copyWith(replyCount: (c.replyCount - 1).clamp(0, 1 << 31))
|
|
: c,
|
|
],
|
|
total: (state.total - 1).clamp(0, 1 << 31),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 评论区 Provider(按视频 ID 家族)
|
|
final commentProvider =
|
|
StateNotifierProvider.family<CommentNotifier, CommentState, int>(
|
|
(ref, videoId) => CommentNotifier(ref, videoId),
|
|
);
|