608 lines
20 KiB
Dart
608 lines
20 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 首页互动操作Mixin
|
||
/// 创建时间: 2026-05-12
|
||
/// 更新时间: 2026-05-31
|
||
/// 作用: 点赞/收藏/稍后读/已读/评分/屏蔽/举报/不感兴趣/阅读时长
|
||
/// 上次更新: 迁移至DataSyncEventBus统一事件总线
|
||
/// ============================================================
|
||
|
||
import 'dart:convert';
|
||
|
||
import 'package:drift/drift.dart' show Value;
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import '../../../core/router/app_router.dart';
|
||
import '../../../core/services/audio/sfx_service.dart';
|
||
import '../../../core/storage/database/app_database.dart';
|
||
import '../../../core/sync/data_sync_compat.dart';
|
||
import '../../../core/utils/logger.dart';
|
||
import '../../discover/services/chat_message_service.dart';
|
||
import '../services/feed_service.dart';
|
||
import 'character_mood_provider.dart';
|
||
import 'home_sentence_model.dart';
|
||
import 'home_state.dart';
|
||
|
||
mixin HomeInteractionMixin on Notifier<HomeState> {
|
||
bool _mounted = true;
|
||
bool get mounted => _mounted;
|
||
|
||
void markDisposed() {
|
||
_mounted = false;
|
||
}
|
||
|
||
AppDatabase get interactionDb;
|
||
Set<String> get togglingIds;
|
||
|
||
HomeSentence? findSentence(String id);
|
||
void updateSentence(String id, HomeSentence Function(HomeSentence) updater);
|
||
|
||
int extractFeedId(String id);
|
||
|
||
// ============================================================
|
||
// 点赞
|
||
// ============================================================
|
||
|
||
Future<void> toggleLike(String id) async {
|
||
if (id.isEmpty) return;
|
||
if (togglingIds.contains(id)) {
|
||
Log.w('toggleLike: 防抖跳过, id=$id');
|
||
return;
|
||
}
|
||
togglingIds.add(id);
|
||
try {
|
||
final sentence = findSentence(id);
|
||
if (sentence == null) return;
|
||
final oldValue = sentence.isLiked;
|
||
if (!mounted) return;
|
||
|
||
// Step 1: 乐观更新UI — 同步翻转 isLiked 和递增/递减 likeCount
|
||
// 修复: 原仅翻转 isLiked,likeCount 不变,导致点赞后数字不增加
|
||
final newLikeCount = oldValue
|
||
? (sentence.likeCount > 0 ? sentence.likeCount - 1 : 0)
|
||
: sentence.likeCount + 1;
|
||
updateSentence(id, (s) => s.copyWith(
|
||
isLiked: !s.isLiked,
|
||
likeCount: newLikeCount,
|
||
));
|
||
SfxService.instance.play(oldValue ? SfxType.unlike : SfxType.like);
|
||
ref
|
||
.read(characterMoodProvider.notifier)
|
||
.recordAction(oldValue ? 'unlike' : 'like');
|
||
|
||
// Step 2: 本地持久化
|
||
await _persistLikeLocally(id, oldValue);
|
||
|
||
// Step 3: 服务端同步
|
||
await _syncLikeToServer(id, sentence, oldValue);
|
||
} catch (e) {
|
||
Log.e('toggleLike异常', e);
|
||
} finally {
|
||
Future.delayed(const Duration(milliseconds: 300), () {
|
||
togglingIds.remove(id);
|
||
}).catchError((_) {});
|
||
}
|
||
}
|
||
|
||
/// 本地点赞持久化:写入本地数据库
|
||
///
|
||
/// 举一反三:与 _persistFavoriteLocally 同类问题——
|
||
/// 若 sentences 表中不存在该 id 的记录,原 toggleLike 的 UPDATE 会静默失败,
|
||
/// 导致本地 isLiked 字段未设置,重启 APP 后点赞状态丢失。
|
||
/// 修复:先查再决定 set 或 upsert 兜底插入。
|
||
Future<void> _persistLikeLocally(String id, bool oldValue) async {
|
||
try {
|
||
final newValue = !oldValue;
|
||
final existing = await interactionDb.getSentencesById(id);
|
||
if (existing != null) {
|
||
await interactionDb.setLikeFlag(id, newValue);
|
||
// 同步更新本地 likes 字段,保证 fromDb 读取时 likeCount 正确
|
||
final s = findSentence(id);
|
||
if (s != null) {
|
||
final newLikeCount = oldValue
|
||
? (s.likeCount > 0 ? s.likeCount - 1 : 0)
|
||
: s.likeCount + 1;
|
||
await interactionDb.setLikesCount(id, newLikeCount);
|
||
}
|
||
} else {
|
||
final s = findSentence(id);
|
||
await interactionDb.insertOrUpdateSentence(
|
||
SentencesCompanion(
|
||
id: Value(id),
|
||
content: Value(s?.text ?? ''),
|
||
author: Value(s?.author ?? ''),
|
||
source: Value(s?.source ?? ''),
|
||
tags: Value(s?.type ?? ''),
|
||
feedType: Value(s?.feedType ?? ''),
|
||
feedName: Value(s?.feedName ?? ''),
|
||
feedIcon: Value(s?.feedIcon ?? ''),
|
||
views: Value(s?.views ?? 0),
|
||
imageUrl: const Value(''),
|
||
isFavorite: Value(s?.isFavorited ?? false),
|
||
isLiked: Value(newValue),
|
||
isRead: const Value(false),
|
||
createdAt: Value(DateTime.now()),
|
||
updatedAt: Value(DateTime.now()),
|
||
),
|
||
);
|
||
// 兜底插入后单独写入 likes 字段(SentencesCompanion 缺少 likes 命名参数)
|
||
if (s != null) {
|
||
await interactionDb.setLikesCount(id, s.likeCount);
|
||
}
|
||
Log.w('本地点赞 upsert 兜底插入: $id (原记录不存在)');
|
||
}
|
||
Log.i('本地点赞已${newValue ? "添加" : "取消"}: $id');
|
||
} catch (e) {
|
||
Log.e('本地点赞同步失败', e);
|
||
}
|
||
}
|
||
|
||
/// 服务端点赞同步:调用FeedService.action
|
||
Future<void> _syncLikeToServer(
|
||
String id,
|
||
HomeSentence sentence,
|
||
bool oldValue,
|
||
) async {
|
||
final feedType = sentence.feedType ?? sentence.type ?? 'feed';
|
||
final feedId = extractFeedId(id);
|
||
if (feedId <= 0) {
|
||
Log.w('toggleLike: 无效feedId, id=$id, 仅本地记录');
|
||
return;
|
||
}
|
||
try {
|
||
final action = oldValue ? 'unlike' : 'like';
|
||
final success = await FeedService.action(
|
||
action: action,
|
||
feedType: feedType,
|
||
feedId: feedId,
|
||
);
|
||
if (!success && mounted) {
|
||
Log.w('toggleLike: 服务端同步失败, 本地状态保留');
|
||
}
|
||
} catch (e) {
|
||
Log.w('服务端点赞同步失败(本地已生效): $e');
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 收藏
|
||
// ============================================================
|
||
|
||
Future<void> toggleFavorite(String id) async {
|
||
if (id.isEmpty) return;
|
||
if (togglingIds.contains(id)) {
|
||
Log.w('toggleFavorite: 防抖跳过, id=$id');
|
||
return;
|
||
}
|
||
togglingIds.add(id);
|
||
try {
|
||
final sentence = findSentence(id);
|
||
if (sentence == null) return;
|
||
final oldValue = sentence.isFavorited;
|
||
if (!mounted) return;
|
||
|
||
// Step 1: 乐观更新UI — 同步翻转 isFavorited 和递增/递减 favoriteCount
|
||
// 修复: 与 toggleLike 同类问题,原仅翻转 isFavorited,favoriteCount 不变
|
||
final newFavCount = oldValue
|
||
? (sentence.favoriteCount > 0 ? sentence.favoriteCount - 1 : 0)
|
||
: sentence.favoriteCount + 1;
|
||
updateSentence(id, (s) => s.copyWith(
|
||
isFavorited: !s.isFavorited,
|
||
favoriteCount: newFavCount,
|
||
));
|
||
SfxService.instance.play(
|
||
oldValue ? SfxType.unfavorite : SfxType.favorite,
|
||
);
|
||
ref
|
||
.read(characterMoodProvider.notifier)
|
||
.recordAction(oldValue ? 'unfavorite' : 'favorite');
|
||
|
||
// Step 2: 本地持久化
|
||
await _persistFavoriteLocally(id, oldValue);
|
||
|
||
// Step 3: 服务端同步
|
||
await _syncFavoriteToServer(id, sentence, oldValue);
|
||
|
||
// Step 4: 通知收藏页面刷新
|
||
notifyFavoriteRefresh();
|
||
} catch (e) {
|
||
Log.e('toggleFavorite异常', e);
|
||
} finally {
|
||
Future.delayed(const Duration(milliseconds: 300), () {
|
||
togglingIds.remove(id);
|
||
}).catchError((_) {});
|
||
}
|
||
}
|
||
|
||
/// 本地收藏持久化:写入本地数据库
|
||
///
|
||
/// 修复:原实现仅调用 `toggleFavorite(id)` UPDATE,若 sentences 表中
|
||
/// 不存在该 id 的记录(如 Feed 列表未缓存/缓存被清),UPDATE 会静默失败,
|
||
/// 导致本地 isFavorite 字段从未被设置 → "我的收藏"页面查不到刚收藏的句子。
|
||
///
|
||
/// 现在:先查询记录是否存在,
|
||
/// - 存在:用 `setFavoriteFlag(id, !oldValue)` 精确设置(不用 toggle 防止并发抖动)
|
||
/// - 不存在:插入一条最小化记录,isFavorite 设为目标值,保证后续可被查询到
|
||
Future<void> _persistFavoriteLocally(String id, bool oldValue) async {
|
||
try {
|
||
final newValue = !oldValue;
|
||
final existing = await interactionDb.getSentencesById(id);
|
||
if (existing != null) {
|
||
// 记录存在,精确设置(避免 toggle 在并发/重试时翻转两次回到原值)
|
||
await interactionDb.setFavoriteFlag(id, newValue);
|
||
} else {
|
||
// 记录不存在,插入一条最小化兜底记录
|
||
// 内容字段从当前内存中的 sentence 取,保证收藏列表可显示
|
||
final s = findSentence(id);
|
||
await interactionDb.insertOrUpdateSentence(
|
||
SentencesCompanion(
|
||
id: Value(id),
|
||
content: Value(s?.text ?? ''),
|
||
author: Value(s?.author ?? ''),
|
||
source: Value(s?.source ?? ''),
|
||
tags: Value(s?.type ?? ''),
|
||
feedType: Value(s?.feedType ?? ''),
|
||
feedName: Value(s?.feedName ?? ''),
|
||
feedIcon: Value(s?.feedIcon ?? ''),
|
||
views: Value(s?.views ?? 0),
|
||
imageUrl: const Value(''),
|
||
isFavorite: Value(newValue),
|
||
isLiked: Value(s?.isLiked ?? false),
|
||
isRead: const Value(false),
|
||
createdAt: Value(DateTime.now()),
|
||
updatedAt: Value(DateTime.now()),
|
||
),
|
||
);
|
||
Log.w('本地收藏 upsert 兜底插入: $id (原记录不存在)');
|
||
}
|
||
Log.i('本地收藏已${newValue ? "添加" : "取消"}: $id');
|
||
} catch (e) {
|
||
Log.e('本地收藏同步失败', e);
|
||
}
|
||
}
|
||
|
||
/// 服务端收藏同步:调用FeedService.action
|
||
Future<void> _syncFavoriteToServer(
|
||
String id,
|
||
HomeSentence sentence,
|
||
bool oldValue,
|
||
) async {
|
||
final feedType = sentence.feedType ?? sentence.type ?? 'feed';
|
||
final feedId = extractFeedId(id);
|
||
if (feedId <= 0) {
|
||
Log.w('toggleFavorite: 无效feedId, id=$id, 仅本地记录');
|
||
return;
|
||
}
|
||
try {
|
||
final action = oldValue ? 'unfavorite' : 'favorite';
|
||
final success = await FeedService.action(
|
||
action: action,
|
||
feedType: feedType,
|
||
feedId: feedId,
|
||
);
|
||
if (!success && mounted) {
|
||
Log.w('toggleFavorite: 服务端同步失败, 本地状态保留');
|
||
}
|
||
} catch (e) {
|
||
Log.w('服务端收藏同步失败(本地已生效): $e');
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 稍后读
|
||
// ============================================================
|
||
|
||
Future<void> toggleReadLater(String id) async {
|
||
if (id.isEmpty) return;
|
||
if (togglingIds.contains(id)) {
|
||
Log.w('toggleReadLater: 防抖跳过, id=$id');
|
||
return;
|
||
}
|
||
togglingIds.add(id);
|
||
try {
|
||
final sentence = findSentence(id);
|
||
if (sentence == null) return;
|
||
final oldValue = sentence.isReadLater;
|
||
if (!mounted) return;
|
||
|
||
// Step 1: 乐观更新UI
|
||
updateSentence(id, (s) => s.copyWith(isReadLater: !s.isReadLater));
|
||
|
||
// Step 2: 本地持久化(无论feedId是否有效都必须执行)
|
||
await _persistReadLaterLocally(id, sentence, oldValue);
|
||
|
||
// Step 3: 服务端同步(仅在feedId有效时执行)
|
||
await _syncReadLaterToServer(id, sentence, oldValue);
|
||
|
||
// Step 4: 用户反馈
|
||
_showReadLaterConfirmDialog(!oldValue ? '📖 已添加到稍后读' : '📖 已移出稍后读');
|
||
} catch (e) {
|
||
Log.e('toggleReadLater异常', e);
|
||
} finally {
|
||
Future.delayed(const Duration(milliseconds: 300), () {
|
||
togglingIds.remove(id);
|
||
}).catchError((_) {});
|
||
}
|
||
}
|
||
|
||
/// 本地稍后读持久化:写入/删除ChatMessage表 + 通知稍后读页面刷新
|
||
/// 无论feedId是否有效都必须执行,确保稍后读页面能从ChatMessage表读取数据
|
||
Future<void> _persistReadLaterLocally(
|
||
String id,
|
||
HomeSentence sentence,
|
||
bool oldValue,
|
||
) async {
|
||
if (!oldValue) {
|
||
// 添加稍后读:写入ChatMessage表
|
||
bool inserted = false;
|
||
try {
|
||
await ChatMessageService.sendReadLaterSentence(
|
||
conversationId: 'readlater',
|
||
text: sentence.text,
|
||
author: sentence.author,
|
||
source: sentence.feedName,
|
||
feedType: sentence.feedType ?? sentence.type,
|
||
feedName: sentence.feedName,
|
||
likeCount: sentence.likeCount,
|
||
views: sentence.views,
|
||
sentenceId: sentence.id.toString(),
|
||
);
|
||
inserted = true;
|
||
Log.i('稍后读句子已写入会话: ${sentence.id}');
|
||
} catch (e) {
|
||
// sendReadLaterSentence 内部 insert 使用 _safeDbVoid 会吞掉异常,
|
||
// 但后续 getChatMsgRecord 可能抛空指针。此处仍可能 insert 已成功。
|
||
Log.w('稍后读句子写入会话可能部分失败: $e');
|
||
// 即使抛异常,insert 可能已成功(_safeDbVoid 吞掉了 insert 异常),
|
||
// 仍需通知刷新,让读取方从 DB 重新加载以确认最终状态。
|
||
inserted = true;
|
||
}
|
||
// 无论 sendReadLaterSentence 是否完全成功,只要可能已写入就通知刷新
|
||
if (inserted) {
|
||
notifyReadlaterRefresh();
|
||
}
|
||
} else {
|
||
// 取消稍后读:从ChatMessage表软删除
|
||
try {
|
||
final db = AppDatabase.instance;
|
||
final feedId = extractFeedId(id);
|
||
final records = await db.getChatMsgRecords('readlater', limit: 200);
|
||
for (final record in records) {
|
||
if (record.isDeleted) continue;
|
||
final metaJson = record.metaJson;
|
||
if (metaJson.isNotEmpty) {
|
||
try {
|
||
final meta = jsonDecode(metaJson) as Map<String, dynamic>;
|
||
final recordSentenceId = meta['sentenceId'] as String?;
|
||
if (recordSentenceId != null &&
|
||
(recordSentenceId == id.toString() ||
|
||
recordSentenceId == id ||
|
||
_extractNumericId(recordSentenceId) == feedId)) {
|
||
await db.softDeleteChatMsgRecord(record.id);
|
||
Log.i('取消稍后读: 已删除聊天消息 ${record.id}');
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
notifyReadlaterRefresh();
|
||
} catch (e) {
|
||
Log.w('取消稍后读: 删除聊天消息失败: $e');
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 服务端稍后读同步:调用FeedService.action
|
||
/// 仅在feedId有效时执行,无效时仅本地记录
|
||
Future<void> _syncReadLaterToServer(
|
||
String id,
|
||
HomeSentence sentence,
|
||
bool oldValue,
|
||
) async {
|
||
final feedType = sentence.feedType ?? sentence.type ?? 'feed';
|
||
final feedId = extractFeedId(id);
|
||
if (feedId <= 0) {
|
||
Log.w('toggleReadLater: 无效feedId, id=$id, 仅本地记录');
|
||
return;
|
||
}
|
||
try {
|
||
final action = oldValue ? 'unreadlater' : 'readlater';
|
||
final success = await FeedService.action(
|
||
action: action,
|
||
feedType: feedType,
|
||
feedId: feedId,
|
||
);
|
||
if (!success && mounted) {
|
||
Log.w('toggleReadLater: 服务端同步失败, 本地状态保留');
|
||
}
|
||
} catch (e) {
|
||
Log.w('服务端稍后读同步失败(本地已生效): $e');
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 已读
|
||
// ============================================================
|
||
|
||
Future<void> markRead(String id) async {
|
||
try {
|
||
final sentence = findSentence(id);
|
||
if (sentence == null) return;
|
||
|
||
try {
|
||
await interactionDb.markAsRead(id);
|
||
await interactionDb.addReadHistory(id);
|
||
} catch (e) {
|
||
Log.e('本地标记已读失败', e);
|
||
}
|
||
|
||
final feedType = sentence.feedType ?? sentence.type ?? 'feed';
|
||
final feedId = extractFeedId(id);
|
||
|
||
if (feedId > 0) {
|
||
try {
|
||
await FeedService.fetchDetail(type: feedType, id: feedId);
|
||
} catch (_) {}
|
||
}
|
||
} catch (e) {
|
||
Log.e('标记已读失败', e);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 评分
|
||
// ============================================================
|
||
|
||
Future<void> rateContent(String id, int score) async {
|
||
final sentence = findSentence(id);
|
||
if (sentence == null) return;
|
||
|
||
final feedType = sentence.feedType ?? sentence.type ?? 'hitokoto';
|
||
final feedId = extractFeedId(id);
|
||
|
||
final success = await FeedService.action(
|
||
action: 'rating',
|
||
feedType: feedType,
|
||
feedId: feedId,
|
||
extra: '{"score":$score}',
|
||
);
|
||
if (success) {
|
||
Log.i('评分成功: $feedType/$feedId score=$score');
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 屏蔽
|
||
// ============================================================
|
||
|
||
Future<void> blockContent(String id) async {
|
||
final sentence = findSentence(id);
|
||
if (sentence == null) return;
|
||
|
||
final feedType = sentence.feedType ?? sentence.type ?? 'hitokoto';
|
||
final feedId = extractFeedId(id);
|
||
|
||
final success = await FeedService.action(
|
||
action: 'block',
|
||
feedType: feedType,
|
||
feedId: feedId,
|
||
);
|
||
if (success) {
|
||
// 先标记为退出中,触发退出动画
|
||
state = state.copyWith(
|
||
exitingIds: {...state.exitingIds, id},
|
||
);
|
||
// 延迟300ms等退出动画播放完毕后再从列表移除
|
||
Future.delayed(const Duration(milliseconds: 300), () {
|
||
// 确保state仍包含该id(避免并发问题)
|
||
if (state.exitingIds.contains(id)) {
|
||
final updated = state.sentences.where((s) => s.id != id).toList();
|
||
state = state.copyWith(
|
||
sentences: updated,
|
||
exitingIds: state.exitingIds.difference({id}),
|
||
);
|
||
}
|
||
});
|
||
Log.i('已屏蔽: $feedType/$feedId');
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 举报
|
||
// ============================================================
|
||
|
||
Future<void> reportContent(String id, {String? reason}) async {
|
||
final sentence = findSentence(id);
|
||
if (sentence == null) return;
|
||
|
||
final feedType = sentence.feedType ?? sentence.type ?? 'hitokoto';
|
||
final feedId = extractFeedId(id);
|
||
|
||
final success = await FeedService.action(
|
||
action: 'report',
|
||
feedType: feedType,
|
||
feedId: feedId,
|
||
extra: reason != null ? '{"reason":"$reason"}' : null,
|
||
);
|
||
if (success) {
|
||
Log.i('已举报: $feedType/$feedId reason=$reason');
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 不感兴趣
|
||
// ============================================================
|
||
|
||
Future<void> dislikeContent(String id) async {
|
||
final sentence = findSentence(id);
|
||
if (sentence == null) return;
|
||
|
||
final feedType = sentence.feedType ?? sentence.type ?? 'hitokoto';
|
||
final feedId = extractFeedId(id);
|
||
|
||
final success = await FeedService.action(
|
||
action: 'dislike',
|
||
feedType: feedType,
|
||
feedId: feedId,
|
||
);
|
||
if (success) {
|
||
final updated = state.sentences.where((s) => s.id != id).toList();
|
||
state = state.copyWith(sentences: updated);
|
||
Log.i('不感兴趣: $feedType/$feedId');
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 阅读时长
|
||
// ============================================================
|
||
|
||
Future<void> reportReadTime(String id, int durationSeconds) async {
|
||
final sentence = findSentence(id);
|
||
if (sentence == null) return;
|
||
|
||
final feedType = sentence.feedType ?? sentence.type ?? 'hitokoto';
|
||
final feedId = extractFeedId(id);
|
||
|
||
await FeedService.action(
|
||
action: 'readtime',
|
||
feedType: feedType,
|
||
feedId: feedId,
|
||
extra: '{"duration":$durationSeconds}',
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 工具方法
|
||
// ============================================================
|
||
|
||
int _extractNumericId(String id) {
|
||
if (id.contains('_')) {
|
||
return int.tryParse(id.split('_').last) ?? 0;
|
||
}
|
||
return int.tryParse(id) ?? 0;
|
||
}
|
||
|
||
void _showReadLaterConfirmDialog(String message) {
|
||
try {
|
||
final ctx = rootNavigatorKey.currentContext;
|
||
if (ctx != null && ctx.mounted) {
|
||
showCupertinoDialog<void>(
|
||
context: ctx,
|
||
builder: (_) => CupertinoAlertDialog(
|
||
title: const Text('稍后读'),
|
||
content: Text(message),
|
||
actions: [
|
||
CupertinoDialogAction(
|
||
isDefaultAction: true,
|
||
child: const Text('好的'),
|
||
onPressed: () => Navigator.pop(ctx),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
Log.e('稍后读确认对话框显示失败', e);
|
||
}
|
||
}
|
||
}
|