Files
video/app/lib/providers/comment_provider.dart
2026-10-15 20:36:59 +08:00

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),
);