Files
xianyan/lib/features/home/providers/home_interaction_mixin.dart
2026-06-27 04:57:00 +08:00

608 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 闲言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
// 修复: 原仅翻转 isLikedlikeCount 不变,导致点赞后数字不增加
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 同类问题,原仅翻转 isFavoritedfavoriteCount 不变
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);
}
}
}