iOS 提交
This commit is contained in:
@@ -154,6 +154,15 @@ class FavoriteRepository {
|
||||
}
|
||||
|
||||
/// Feed API 加载收藏
|
||||
///
|
||||
/// 修复:原降级条件 `result.list.isEmpty && result.total == 0 && page == 1`
|
||||
/// 在服务端 bug / 缓存延迟 / 新用户无收藏等场景下会误降级到 Legacy API,
|
||||
/// 而 Legacy API 又硬编码 targetType='article'(已在 _loadLegacyFavorites 修复),
|
||||
/// 双重故障导致句子收藏完全查不到。
|
||||
///
|
||||
/// 现在:Feed API 返回空时,**先检查本地数据库是否有收藏**,
|
||||
/// - 本地有:直接走本地兜底(loadLocalDbFavorites),不降级 Legacy
|
||||
/// - 本地无:再降级 Legacy API(已修复 targetType)
|
||||
Future<FavoriteLoadResult> _loadFeedFavorites({
|
||||
int page = 1,
|
||||
bool refresh = false,
|
||||
@@ -163,9 +172,22 @@ class FavoriteRepository {
|
||||
try {
|
||||
final result = await FeedService.fetchFavorites(page: page);
|
||||
|
||||
// 空结果降级旧接口
|
||||
// 空结果处理:先本地兜底,再考虑降级 Legacy
|
||||
if (result.list.isEmpty && result.total == 0 && page == 1) {
|
||||
Log.w('Feed收藏API返回空结果(total=0),降级旧接口');
|
||||
// 先看本地数据库是否有收藏记录(如刚收藏但服务端尚未同步/缓存延迟)
|
||||
final localDbItems = await loadLocalDbFavorites();
|
||||
if (localDbItems.isNotEmpty) {
|
||||
Log.i('Feed收藏API空结果,本地数据库兜底: ${localDbItems.length}条');
|
||||
return FavoriteLoadResult(
|
||||
items: localDbItems,
|
||||
feedItems: const [],
|
||||
total: localDbItems.length,
|
||||
nextPage: 2,
|
||||
useFeedApi: true,
|
||||
);
|
||||
}
|
||||
// 本地也无,降级 Legacy API(已修复 targetType 硬编码问题)
|
||||
Log.w('Feed收藏API空结果且本地无记录,降级旧接口');
|
||||
return _loadLegacyFavorites(
|
||||
page: page,
|
||||
refresh: refresh,
|
||||
@@ -196,6 +218,10 @@ class FavoriteRepository {
|
||||
}
|
||||
|
||||
/// Legacy API 加载收藏
|
||||
///
|
||||
/// 修复:原实现硬编码 `targetType: 'article'`,但句子广场的 feedType 是
|
||||
/// 'hitokoto'/'chengyu'/'hanzi'/'poetry' 等,永远查不到。
|
||||
/// 现在:不传 targetType,让服务端返回所有类型收藏(与 Feed API 行为一致)。
|
||||
Future<FavoriteLoadResult> _loadLegacyFavorites({
|
||||
int page = 1,
|
||||
bool refresh = false,
|
||||
@@ -204,7 +230,8 @@ class FavoriteRepository {
|
||||
try {
|
||||
final result = await UserCenterService.favorite(
|
||||
action: FavoriteAction.list,
|
||||
targetType: 'article',
|
||||
// 故意不传 targetType:让服务端返回所有类型收藏,
|
||||
// 避免硬编码 'article' 导致句子类收藏被漏掉
|
||||
page: page,
|
||||
);
|
||||
final total = SafeJson.parseInt(result['total']);
|
||||
@@ -385,7 +412,16 @@ class FavoriteRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// 合并服务端和本地数据库收藏,去重(以 content+author 为联合key)
|
||||
/// 合并服务端和本地数据库收藏,去重
|
||||
///
|
||||
/// 修复:原实现仅用 `content+author` 作为去重 key,当 content 为空时
|
||||
/// 退化为 `|title`,多条空 content 条目互相误去重;且未利用 id 信息,
|
||||
/// 服务端和本地同一句子(id 相同但 content 微小差异)会被当作两条。
|
||||
///
|
||||
/// 现在:双重去重策略
|
||||
/// - 主 key:`targetType|targetId`(精确,服务端与本地通过 _resolveServerId 可对齐)
|
||||
/// - 副 key:`content|title`(兜底,处理 id 缺失或异常的情况)
|
||||
/// 任一 key 命中即视为已存在。
|
||||
///
|
||||
/// 本地条目追加到尾部(而非插入头部),避免取消收藏后本地缓存条目
|
||||
/// 仍然显示在列表顶部导致"取消收藏后数据仍在"的问题。
|
||||
@@ -393,15 +429,32 @@ class FavoriteRepository {
|
||||
List<FavoriteItem> serverItems,
|
||||
List<FavoriteItem> localItems,
|
||||
) {
|
||||
// 使用 content + author 联合去重,避免不同作者的同内容句子被误去重
|
||||
final mergeKey = (FavoriteItem item) =>
|
||||
'${item.content.trim().toLowerCase()}|${item.title.trim().toLowerCase()}';
|
||||
final existingKeys = serverItems.map(mergeKey).toSet();
|
||||
String idKey(FavoriteItem item) =>
|
||||
'${item.targetType}|${item.targetId}';
|
||||
String contentKey(FavoriteItem item) {
|
||||
final c = item.content.trim().toLowerCase();
|
||||
final t = item.title.trim().toLowerCase();
|
||||
// 空 content 不参与去重,避免误合并不同条目
|
||||
if (c.isEmpty) return '';
|
||||
return '$c|$t';
|
||||
}
|
||||
|
||||
final existingIdKeys = serverItems.map(idKey).toSet();
|
||||
final existingContentKeys = serverItems
|
||||
.map(contentKey)
|
||||
.where((k) => k.isNotEmpty)
|
||||
.toSet();
|
||||
|
||||
final merged = List<FavoriteItem>.from(serverItems);
|
||||
for (final local in localItems) {
|
||||
if (!existingKeys.contains(mergeKey(local))) {
|
||||
merged.add(local);
|
||||
}
|
||||
final idK = idKey(local);
|
||||
final cK = contentKey(local);
|
||||
final dupById = existingIdKeys.contains(idK);
|
||||
final dupByContent = cK.isNotEmpty && existingContentKeys.contains(cK);
|
||||
if (dupById || dupByContent) continue;
|
||||
merged.add(local);
|
||||
existingIdKeys.add(idK);
|
||||
if (cK.isNotEmpty) existingContentKeys.add(cK);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
// ============================================================
|
||||
// 闲言APP — 首页句子卡片
|
||||
// 创建时间: 2026-04-27
|
||||
// 更新时间: 2026-06-12
|
||||
// 更新时间: 2026-06-27
|
||||
// 作用: 句子广场列表中的句子卡片,Feed数据适配 + 互动操作
|
||||
// 上次更新: AppIcon添加语义标签(点赞/收藏/评论/浏览量),增强无障碍
|
||||
// 上次更新: v6.144.0 — 未激活态按钮边框加粗 (0.5→1.2px) + 背景加深,
|
||||
// 让用户清晰辨识点赞/收藏按钮的可点击区域
|
||||
// ============================================================
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/material.dart' show Colors;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import 'package:xianyan/core/theme/app_theme.dart';
|
||||
import 'package:xianyan/l10n/translations.dart';
|
||||
import 'package:xianyan/core/theme/app_spacing.dart';
|
||||
import 'package:xianyan/core/theme/app_typography.dart';
|
||||
import 'package:xianyan/core/theme/app_radius.dart';
|
||||
import 'package:xianyan/core/utils/logger.dart';
|
||||
import 'package:xianyan/core/theme/glass_tokens.dart';
|
||||
import 'package:xianyan/core/utils/platform/platform_utils.dart'
|
||||
show isOhos, OhosDeviceCapabilities;
|
||||
import 'package:xianyan/core/utils/performance_optimizer.dart';
|
||||
import 'package:xianyan/core/utils/ui/interaction_animations.dart';
|
||||
import 'package:xianyan/core/utils/data/number_formatter.dart';
|
||||
import 'package:xianyan/core/services/device/haptic_service.dart';
|
||||
import 'package:xianyan/core/services/audio/sfx_service.dart';
|
||||
import 'package:xianyan/features/home/feed_model.dart';
|
||||
import 'package:xianyan/features/home/providers/home_provider.dart';
|
||||
import 'package:xianyan/features/home/presentation/providers/sentence_detail_sheet.dart';
|
||||
@@ -32,7 +34,6 @@ import 'package:xianyan/features/settings/providers/plugin_provider.dart';
|
||||
import 'package:xianyan/features/settings/presentation/plugin_widgets/translate_sheet.dart';
|
||||
import 'package:xianyan/features/settings/presentation/plugin_widgets/tts_player_sheet.dart';
|
||||
import 'package:xianyan/shared/widgets/plugin/pinyin_annotation_text.dart';
|
||||
import 'package:xianyan/shared/widgets/feedback/app_toast.dart';
|
||||
import 'package:xianyan/shared/widgets/display/app_icon.dart';
|
||||
|
||||
/// 句子卡片 — 列表项
|
||||
@@ -70,8 +71,6 @@ class _SentenceCardState extends ConsumerState<SentenceCard> {
|
||||
AppThemeExtension get ext => widget.ext;
|
||||
|
||||
bool _showPinyin = false;
|
||||
bool _isSharing = false;
|
||||
final GlobalKey _repaintKey = GlobalKey();
|
||||
|
||||
/// 根据 feedType 生成主题色
|
||||
Color _generateAccentColor() {
|
||||
@@ -94,12 +93,17 @@ class _SentenceCardState extends ConsumerState<SentenceCard> {
|
||||
widget.onTap?.call();
|
||||
_showDetail(context);
|
||||
},
|
||||
// 长按卡片:重冲击触觉 + 触发详情面板(与点击效果一致,但提供差异化触觉反馈)
|
||||
onLongPress: () {
|
||||
HapticService.heavy();
|
||||
widget.onTap?.call();
|
||||
_showDetail(context);
|
||||
},
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
child: RepaintBoundary(
|
||||
key: _repaintKey,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: _buildGradient(shaderEnabled, accentColor),
|
||||
@@ -387,7 +391,13 @@ class _SentenceCardState extends ConsumerState<SentenceCard> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 操作行 (作者 + 收藏 + 点赞)
|
||||
/// 操作行 (作者 + 工具图标 + 液态玻璃点赞/收藏按钮)
|
||||
///
|
||||
/// 重设计 2026-06-27:
|
||||
/// - 移除分享 icon 按钮(分享改为左滑手势 + 详情面板入口)
|
||||
/// - 拼音/翻译/TTS 保留为小图标,置于按钮左侧
|
||||
/// - 点赞/收藏改为液态玻璃长方形 icon+文字 按钮,显示激活态
|
||||
/// - 支持动态主题(明/暗/AMOLED + 主色切换 + 圆角风格 + 字号缩放)
|
||||
Widget _buildActionRow() {
|
||||
final translateEnabled = ref.watch(
|
||||
pluginProvider.select((s) => s.translateEnabled),
|
||||
@@ -396,77 +406,89 @@ class _SentenceCardState extends ConsumerState<SentenceCard> {
|
||||
final pinyinEnabled = ref.watch(
|
||||
pluginProvider.select((s) => s.pinyinEnabled),
|
||||
);
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// 左侧:作者名(占满剩余空间)
|
||||
if (sentence.author != null)
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${ref.watch(translationsProvider).home.base.authorPrefix}${sentence.author!}',
|
||||
'${t.home.base.authorPrefix}${sentence.author!}',
|
||||
style: AppTypography.caption1.copyWith(color: ext.textSecondary),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
else
|
||||
const Spacer(),
|
||||
|
||||
// 中间:工具图标组(拼音/翻译/TTS)保留为小图标
|
||||
if (pinyinEnabled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: AppSpacing.sm),
|
||||
child: GestureDetector(
|
||||
onTap: _handlePinyinToggle,
|
||||
child: Icon(
|
||||
_showPinyin
|
||||
? CupertinoIcons.textformat_alt
|
||||
: CupertinoIcons.textformat,
|
||||
size: 18,
|
||||
color: _showPinyin ? const Color(0xFF34C759) : ext.textHint,
|
||||
),
|
||||
),
|
||||
_ToolIconButton(
|
||||
icon: _showPinyin
|
||||
? CupertinoIcons.textformat_alt
|
||||
: CupertinoIcons.textformat,
|
||||
color: _showPinyin ? const Color(0xFF34C759) : ext.textHint,
|
||||
semanticLabel: '拼音',
|
||||
onTap: _handlePinyinToggle,
|
||||
),
|
||||
if (translateEnabled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: AppSpacing.sm),
|
||||
child: GestureDetector(
|
||||
onTap: () => _handleTranslate(),
|
||||
child: Icon(CupertinoIcons.globe, size: 18, color: ext.accent),
|
||||
),
|
||||
_ToolIconButton(
|
||||
icon: CupertinoIcons.globe,
|
||||
color: ext.accent,
|
||||
semanticLabel: '翻译',
|
||||
onTap: _handleTranslate,
|
||||
),
|
||||
if (ttsEnabled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: AppSpacing.sm),
|
||||
child: GestureDetector(
|
||||
onTap: () => _handleTts(),
|
||||
child: const Icon(
|
||||
CupertinoIcons.speaker_2_fill,
|
||||
size: 18,
|
||||
color: Color(0xFFAF52DE),
|
||||
),
|
||||
),
|
||||
_ToolIconButton(
|
||||
icon: CupertinoIcons.speaker_2_fill,
|
||||
color: const Color(0xFFAF52DE),
|
||||
semanticLabel: '朗读',
|
||||
onTap: _handleTts,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: AppSpacing.sm),
|
||||
child: GestureDetector(
|
||||
onTap: _isSharing ? null : _shareAsImage,
|
||||
child: Icon(
|
||||
CupertinoIcons.share,
|
||||
size: 18,
|
||||
color: _isSharing ? ext.textHint : ext.accent,
|
||||
),
|
||||
),
|
||||
),
|
||||
FavoriteBounceAnimation(
|
||||
isFavorited: sentence.isFavorited,
|
||||
onToggle: () {
|
||||
if (mounted) widget.onFavorite?.call();
|
||||
},
|
||||
size: 18,
|
||||
),
|
||||
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
LikeAnimation(
|
||||
isLiked: sentence.isLiked,
|
||||
onToggle: (_) {
|
||||
|
||||
// 右侧:液态玻璃点赞按钮(icon + 数字)
|
||||
LiquidGlassActionButton(
|
||||
icon: sentence.isLiked
|
||||
? CupertinoIcons.heart_fill
|
||||
: CupertinoIcons.heart,
|
||||
label: NumberFormatter.formatCount(sentence.likeCount),
|
||||
isActive: sentence.isLiked,
|
||||
activeColor: const Color(0xFFFF3B30), // iOS systemRed
|
||||
activeColorLight: const Color(0xFFFF6B6B),
|
||||
ext: ext,
|
||||
hapticType: HapticType.like,
|
||||
semanticLabel: sentence.isLiked
|
||||
? '已点赞 ${sentence.likeCount}'
|
||||
: '点赞 ${sentence.likeCount}',
|
||||
onTap: () {
|
||||
if (mounted) widget.onLike();
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
|
||||
// 右侧:液态玻璃收藏按钮(icon + 文字"收藏"/"已藏")
|
||||
LiquidGlassActionButton(
|
||||
icon: sentence.isFavorited
|
||||
? CupertinoIcons.star_fill
|
||||
: CupertinoIcons.star,
|
||||
label: sentence.isFavorited
|
||||
? t.home.sentenceDetail.favorited
|
||||
: t.home.sentenceDetail.favorite,
|
||||
isActive: sentence.isFavorited,
|
||||
activeColor: const Color(0xFFFF9F0A), // iOS systemOrange
|
||||
activeColorLight: const Color(0xFFFFB84D),
|
||||
ext: ext,
|
||||
hapticType: HapticType.favorite,
|
||||
semanticLabel: sentence.isFavorited ? '已收藏' : '收藏',
|
||||
onTap: () {
|
||||
if (mounted) widget.onFavorite?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -504,81 +526,316 @@ class _SentenceCardState extends ConsumerState<SentenceCard> {
|
||||
ref.read(pluginProvider.notifier).incrementPinyinCount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 截图分享 — RepaintBoundary截取卡片 → PNG → share_plus系统分享
|
||||
Future<void> _shareAsImage() async {
|
||||
if (_isSharing) return;
|
||||
if (kIsWeb) {
|
||||
_shareAsText();
|
||||
return;
|
||||
}
|
||||
setState(() => _isSharing = true);
|
||||
// ============================================================
|
||||
// 工具图标按钮 — 拼音/翻译/TTS 共用的小尺寸图标按钮
|
||||
// ============================================================
|
||||
|
||||
try {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
/// 工具图标按钮(无背景,仅图标 + 间距)
|
||||
class _ToolIconButton extends StatelessWidget {
|
||||
const _ToolIconButton({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.semanticLabel,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final boundary =
|
||||
_repaintKey.currentContext?.findRenderObject()
|
||||
as RenderRepaintBoundary?;
|
||||
if (boundary == null || !boundary.hasSize) {
|
||||
_shareAsText();
|
||||
return;
|
||||
}
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String semanticLabel;
|
||||
final VoidCallback onTap;
|
||||
|
||||
final image = await boundary.toImage(pixelRatio: 3.0);
|
||||
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
if (byteData == null) {
|
||||
_shareAsText();
|
||||
return;
|
||||
}
|
||||
|
||||
final buffer = byteData.buffer;
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final filePath = '${tempDir.path}/sentence_$timestamp.png';
|
||||
final file = File(filePath);
|
||||
await file.writeAsBytes(
|
||||
buffer.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes),
|
||||
);
|
||||
|
||||
final authorPart = sentence.author != null
|
||||
? ' —— ${sentence.author}'
|
||||
: '';
|
||||
final t = ref.read(translationsProvider);
|
||||
final shareText =
|
||||
'📝 ${sentence.text}$authorPart\n\n${t.home.base.shareAppSignature}';
|
||||
|
||||
if (mounted) {
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(files: [XFile(filePath)], text: shareText),
|
||||
);
|
||||
}
|
||||
Log.i('分享句子卡片图片: ${sentence.id}');
|
||||
} catch (e) {
|
||||
Log.e('句子卡片截图分享失败', e);
|
||||
_shareAsText();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isSharing = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本分享降级方案
|
||||
void _shareAsText() {
|
||||
final t = ref.read(translationsProvider);
|
||||
final authorPart = sentence.author != null
|
||||
? ' ${t.home.base.authorPrefix}${sentence.author}'
|
||||
: '';
|
||||
final shareText =
|
||||
'📝 ${sentence.text}$authorPart\n\n${t.home.base.shareAppSignature}';
|
||||
|
||||
SharePlus.instance.share(ShareParams(text: shareText)).catchError((
|
||||
Object e,
|
||||
) {
|
||||
Log.e('文本分享失败', e);
|
||||
AppToast.showError(t.home.base.shareFailed);
|
||||
return const ShareResult('', ShareResultStatus.unavailable);
|
||||
});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: AppSpacing.sm),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Semantics(
|
||||
label: semanticLabel,
|
||||
button: true,
|
||||
child: Icon(icon, size: 18, color: color),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 液态玻璃操作按钮 (iOS 26 Liquid Glass 风格)
|
||||
// ============================================================
|
||||
|
||||
/// 按钮触觉类型 — 决定激活/取消时的触觉强度与音效
|
||||
enum HapticType {
|
||||
/// 点赞按钮:激活中等冲击 + like_pop 音效;取消轻冲击 + unlike_soft
|
||||
like,
|
||||
|
||||
/// 收藏按钮:激活中等冲击 + favorite_star 音效;取消轻冲击 + unfavorite
|
||||
favorite,
|
||||
}
|
||||
|
||||
/// 液态玻璃操作按钮
|
||||
///
|
||||
/// 长方形 icon + 文字 按钮,支持激活态切换:
|
||||
/// - 未激活:真正 BackdropFilter 毛玻璃 + 微高光 + 中性色 icon/文字
|
||||
/// - 已激活:渐变填充 + 内嵌高光 + 投射阴影 + 白色 icon/文字
|
||||
///
|
||||
/// 2026-06-27 升级:
|
||||
/// - 真正使用 BackdropFilter(参考 GlassContainer 实践,含性能降级)
|
||||
/// - 单一 AnimatedBuilder 统一驱动弹跳动画(移除 AnimatedScale + ScaleTransition 双重叠加)
|
||||
/// - 接入 HapticService + SfxService 差异化触觉与音效
|
||||
///
|
||||
/// 设计参考: docs/prototypes/sentence_card_redesign.html 方案C
|
||||
/// 动态主题: 自动跟随 AppThemeExtension 切换明/暗/AMOLED/主色
|
||||
class LiquidGlassActionButton extends StatefulWidget {
|
||||
const LiquidGlassActionButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isActive,
|
||||
required this.activeColor,
|
||||
required this.activeColorLight,
|
||||
required this.ext,
|
||||
required this.onTap,
|
||||
this.hapticType,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool isActive;
|
||||
|
||||
/// 激活态主色(如点赞红、收藏金)
|
||||
final Color activeColor;
|
||||
final Color activeColorLight;
|
||||
|
||||
/// 主题扩展,用于读取动态主题色
|
||||
final AppThemeExtension ext;
|
||||
|
||||
/// 点击回调
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// 触觉与音效类型(null 表示不触发触觉/音效)
|
||||
final HapticType? hapticType;
|
||||
|
||||
/// 无障碍标签
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
State<LiquidGlassActionButton> createState() =>
|
||||
_LiquidGlassActionButtonState();
|
||||
}
|
||||
|
||||
class _LiquidGlassActionButtonState extends State<LiquidGlassActionButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _popController;
|
||||
late final Animation<double> _popAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_popController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
);
|
||||
_popAnimation = Tween<double>(begin: 1.0, end: 1.35).animate(
|
||||
CurvedAnimation(
|
||||
parent: _popController,
|
||||
curve: Curves.easeInOutBack,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LiquidGlassActionButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 从未激活变为激活时,触发弹跳动画 + 触觉 + 音效
|
||||
if (!oldWidget.isActive && widget.isActive) {
|
||||
_popController.forward(from: 0.0);
|
||||
_fireActivateFeedback();
|
||||
} else if (oldWidget.isActive && !widget.isActive) {
|
||||
// 取消激活:轻冲击 + 取消音效
|
||||
_fireDeactivateFeedback();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_popController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 触发激活态反馈:中等冲击 + 激活音效
|
||||
void _fireActivateFeedback() {
|
||||
final type = widget.hapticType;
|
||||
if (type == null) return;
|
||||
HapticService.medium();
|
||||
switch (type) {
|
||||
case HapticType.like:
|
||||
SfxService.instance.play(SfxType.like);
|
||||
case HapticType.favorite:
|
||||
SfxService.instance.play(SfxType.favorite);
|
||||
}
|
||||
}
|
||||
|
||||
/// 触发取消态反馈:轻冲击 + 取消音效
|
||||
void _fireDeactivateFeedback() {
|
||||
final type = widget.hapticType;
|
||||
if (type == null) return;
|
||||
HapticService.light();
|
||||
switch (type) {
|
||||
case HapticType.like:
|
||||
SfxService.instance.play(SfxType.unlike);
|
||||
case HapticType.favorite:
|
||||
SfxService.instance.play(SfxType.unfavorite);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTap() {
|
||||
// 点击瞬间先触发反向缩放反馈(动画由 didUpdateWidget 在状态变化时驱动)
|
||||
_popController.forward(from: 0.0);
|
||||
widget.onTap();
|
||||
}
|
||||
|
||||
/// 计算有效的 BackdropFilter sigma 值
|
||||
///
|
||||
/// 参考 GlassContainer 实践:
|
||||
/// - 基于 GlassTokens.baseBlur(按钮较小,用 base 层 10.0)
|
||||
/// - 乘以 ext.glassBlurMultiplier(用户可调节的模糊强度倍数)
|
||||
/// - 通过 PerformanceOptimizer 在低端设备/省电模式降级
|
||||
/// - 鸿蒙端通过 OhosDeviceCapabilities.supportsBackdropFilter 判断是否支持
|
||||
double? _effectiveBlurSigma() {
|
||||
// 鸿蒙端不支持 BackdropFilter 时降级
|
||||
if (isOhos && !OhosDeviceCapabilities.supportsBackdropFilter) return null;
|
||||
if (!PerformanceOptimizer.instance.shouldEnableBackdropFilter) return null;
|
||||
final rawSigma = GlassTokens.baseBlur * widget.ext.glassBlurMultiplier;
|
||||
final sigma =
|
||||
PerformanceOptimizer.instance.suggestedBlurSigma(rawSigma);
|
||||
return sigma > 0 ? sigma : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ext = widget.ext;
|
||||
final isDark = ext.isDark;
|
||||
|
||||
// 未激活态背景色:浅色/深色自适应半透明
|
||||
// v6.144.0: 加深背景与边框,让用户在未点赞/未收藏时能清晰看到按钮轮廓和点击位置
|
||||
final inactiveBg = isDark
|
||||
? const Color(0x26FFFFFF) // 15% white (原 8%)
|
||||
: const Color(0x1FFFFFFF).withValues(alpha: 0.28); // 28% white (原 18%)
|
||||
final inactiveBorder = isDark
|
||||
? const Color(0x4DFFFFFF) // 30% white (原 12%)
|
||||
: const Color(0x80FFFFFF); // 50% white (原 25%)
|
||||
final inactiveFg = ext.textSecondary;
|
||||
|
||||
// 激活态:渐变填充
|
||||
final activeBg = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
widget.activeColor.withValues(alpha: 0.92),
|
||||
widget.activeColorLight.withValues(alpha: 0.88),
|
||||
],
|
||||
);
|
||||
const activeBorder = Color(0x66FFFFFF); // 40% white
|
||||
const activeFg = Colors.white;
|
||||
|
||||
final bgColor = widget.isActive ? null : inactiveBg;
|
||||
final gradient = widget.isActive ? activeBg : null;
|
||||
final borderColor = widget.isActive ? activeBorder : inactiveBorder;
|
||||
final fgColor = widget.isActive ? activeFg : inactiveFg;
|
||||
|
||||
// 阴影:激活态有彩色投射
|
||||
final shadowColor = widget.isActive
|
||||
? widget.activeColor.withValues(alpha: 0.55)
|
||||
: Colors.transparent;
|
||||
final activeShadow = widget.isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: shadowColor,
|
||||
offset: const Offset(0, 4),
|
||||
blurRadius: 14,
|
||||
spreadRadius: -2,
|
||||
),
|
||||
// 内嵌高光(顶部白线)
|
||||
const BoxShadow(
|
||||
color: Color(0x4DFFFFFF),
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
]
|
||||
: null;
|
||||
|
||||
// 按钮内容
|
||||
final buttonContent = Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
gradient: gradient,
|
||||
borderRadius: BorderRadius.circular(999), // pill
|
||||
// v6.144.0: 未激活态边框加粗 (0.5→1.2),让用户清晰看到按钮可点击区域
|
||||
border: Border.all(
|
||||
color: borderColor,
|
||||
width: widget.isActive ? 0.5 : 1.2,
|
||||
),
|
||||
boxShadow: activeShadow,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(widget.icon, size: 14, color: fgColor),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
widget.label,
|
||||
style: AppTypography.caption2.copyWith(
|
||||
color: fgColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// 真正的液态玻璃:BackdropFilter 包裹(仅未激活态使用,激活态用渐变填充无需模糊)
|
||||
// 用 RepaintBoundary 隔离重绘,避免列表滚动时重复模糊计算
|
||||
final blurSigma = widget.isActive ? null : _effectiveBlurSigma();
|
||||
final glassChild = blurSigma != null
|
||||
? RepaintBoundary(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: BackdropFilter(
|
||||
filter: ui.ImageFilter.blur(
|
||||
sigmaX: blurSigma,
|
||||
sigmaY: blurSigma,
|
||||
),
|
||||
child: buttonContent,
|
||||
),
|
||||
),
|
||||
)
|
||||
: buttonContent;
|
||||
|
||||
return Semantics(
|
||||
label: widget.semanticLabel,
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _handleTap,
|
||||
// 统一动画驱动:单一 AnimatedBuilder 同时缩放整个按钮(含 icon + 文字)
|
||||
// 移除原 AnimatedScale + ScaleTransition 双重叠加
|
||||
child: AnimatedBuilder(
|
||||
animation: _popAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _popAnimation.value,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: glassChild,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 首页Feed数据拉取Mixin
|
||||
/// 创建时间: 2026-05-12
|
||||
/// 更新时间: 2026-06-23
|
||||
/// 更新时间: 2026-06-27
|
||||
/// 作用: 频道/每日推荐/列表/降级/缓存的拉取逻辑
|
||||
/// 上次更新: 任务6修复 — 去重耗尽时重置seenIds实现循环加载,新增resetAndReload方法
|
||||
/// 上次更新: v6.144.0 — 新增 _mergeLocalInteractionState 合并本地DB互动状态,
|
||||
/// 解决已收藏/已点赞句子重复出现未显示状态;循环加载时保留已收藏句子去重
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:convert';
|
||||
@@ -74,6 +75,50 @@ mixin HomeFeedMixin on Notifier<HomeState> {
|
||||
return all.sublist(all.length - _maxSeenHashesForApi);
|
||||
}
|
||||
|
||||
/// 合并本地 DB 的互动状态(isFavorite/isLiked)
|
||||
///
|
||||
/// v6.144.0: 解决"已收藏/已点赞句子重复出现但未显示状态"的问题。
|
||||
/// 合并策略:local true wins(OR 合并)
|
||||
/// - 服务端 true → true(已登录用户的服务端权威状态)
|
||||
/// - 服务端 false 但本地 true → true(未登录或未同步的本地操作)
|
||||
/// - 两者都 false → false
|
||||
///
|
||||
/// 未登录场景:服务端始终返回 false,本地 DB 是唯一状态来源。
|
||||
/// 登录场景:服务端返回权威状态,本地 DB 兜底未同步的离线操作。
|
||||
Future<List<HomeSentence>> _mergeLocalInteractionState(
|
||||
List<HomeSentence> sentences,
|
||||
) async {
|
||||
if (sentences.isEmpty) return sentences;
|
||||
try {
|
||||
final ids = sentences.map((s) => s.id).toList();
|
||||
final localMap = await feedDb.getSentencesByIds(ids);
|
||||
if (localMap.isEmpty) return sentences;
|
||||
bool changed = false;
|
||||
final merged = sentences.map((s) {
|
||||
final local = localMap[s.id];
|
||||
if (local == null) return s;
|
||||
final mergedLiked = s.isLiked || local.isLiked;
|
||||
final mergedFavorited = s.isFavorited || local.isFavorite;
|
||||
if (mergedLiked == s.isLiked && mergedFavorited == s.isFavorited) {
|
||||
return s;
|
||||
}
|
||||
changed = true;
|
||||
return s.copyWith(
|
||||
isLiked: mergedLiked,
|
||||
isFavorited: mergedFavorited,
|
||||
);
|
||||
}).toList();
|
||||
if (changed) {
|
||||
Log.i('_mergeLocalInteractionState: 合并 ${sentences.length} 条, '
|
||||
'命中本地 ${localMap.length} 条');
|
||||
}
|
||||
return merged;
|
||||
} catch (e) {
|
||||
Log.w('_mergeLocalInteractionState: 合并失败, 保留服务端状态: $e');
|
||||
return sentences;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchRefreshSentences() async {
|
||||
try {
|
||||
final seenIds = _buildSeenIdList();
|
||||
@@ -105,7 +150,7 @@ mixin HomeFeedMixin on Notifier<HomeState> {
|
||||
// 当选择了具体分类时,只保留该分类的数据
|
||||
final selectedType = state.selectedType;
|
||||
|
||||
final newSentences = result.list
|
||||
var newSentences = result.list
|
||||
.map(HomeSentence.fromFeedItem)
|
||||
.where((s) => s.text.isNotEmpty)
|
||||
.where((s) {
|
||||
@@ -119,6 +164,9 @@ mixin HomeFeedMixin on Notifier<HomeState> {
|
||||
})
|
||||
.toList();
|
||||
|
||||
// v6.144.0: 合并本地 DB 互动状态,确保已收藏/已点赞句子显示正确状态
|
||||
newSentences = await _mergeLocalInteractionState(newSentences);
|
||||
|
||||
final unique = newSentences
|
||||
.where((s) => !allSeenIds.contains(s.id))
|
||||
.toList();
|
||||
@@ -623,10 +671,14 @@ mixin HomeFeedMixin on Notifier<HomeState> {
|
||||
})
|
||||
.toList();
|
||||
|
||||
// v6.144.0: 合并本地 DB 互动状态,确保已收藏/已点赞句子显示正确状态
|
||||
// 注意:此处不使用 var 重赋值,直接生成 mergedSentences 供后续使用
|
||||
final mergedSentences = await _mergeLocalInteractionState(newSentences);
|
||||
|
||||
final selfDeduped = <HomeSentence>[];
|
||||
final selfSeen = <String>{};
|
||||
final selfSeenTexts = <String>{};
|
||||
for (final s in newSentences) {
|
||||
for (final s in mergedSentences) {
|
||||
if (!selfSeen.contains(s.id)) {
|
||||
selfSeen.add(s.id);
|
||||
if (deduplicateContent && s.text.isNotEmpty) {
|
||||
@@ -669,33 +721,57 @@ mixin HomeFeedMixin on Notifier<HomeState> {
|
||||
// 避免骨架屏卡死 — 重置后用本次返回的数据继续填充列表
|
||||
if (state.cycleRound >= 3) {
|
||||
Log.w('fetchNewSentences: 去重3轮后仍无新数据, 重置已见集合进入循环加载');
|
||||
// v6.144.0: 保留已收藏句子的 ID 和文本,避免循环加载时已收藏内容重复出现
|
||||
// 用户已收藏的句子在"我的收藏"页面可见,无需在广场再次展示
|
||||
final favoritedIds = state.sentences
|
||||
.where((s) => s.isFavorited)
|
||||
.map((s) => s.id)
|
||||
.toSet();
|
||||
final favoritedTexts = deduplicateContent
|
||||
? state.sentences
|
||||
.where((s) => s.isFavorited && s.text.isNotEmpty)
|
||||
.map((s) => s.text.trim())
|
||||
.toSet()
|
||||
: <String>{};
|
||||
// 重置已见集合,允许相同内容再次出现(循环加载)
|
||||
allSeenIds.clear();
|
||||
if (deduplicateContent) {
|
||||
allSeenTexts.clear();
|
||||
}
|
||||
// 保留最近一批id避免立即重复
|
||||
final recentIds = state.sentences.length > 20
|
||||
? state.sentences.sublist(state.sentences.length - 20).map((s) => s.id).toSet()
|
||||
: <String>{};
|
||||
final recentTexts = deduplicateContent && state.sentences.length > 20
|
||||
? state.sentences.sublist(state.sentences.length - 20)
|
||||
// 恢复已收藏句子的去重记录,确保循环加载时跳过已收藏内容
|
||||
allSeenIds.addAll(favoritedIds);
|
||||
if (deduplicateContent) {
|
||||
allSeenTexts.addAll(favoritedTexts);
|
||||
}
|
||||
// 保留最近一批id避免立即重复 + v6.144.0: 已收藏句子 ID 合并排除
|
||||
final recentIds = <String>{
|
||||
if (state.sentences.length > 20)
|
||||
...state.sentences
|
||||
.sublist(state.sentences.length - 20)
|
||||
.map((s) => s.id),
|
||||
...favoritedIds,
|
||||
};
|
||||
final recentTexts = <String>{
|
||||
if (deduplicateContent && state.sentences.length > 20)
|
||||
...state.sentences
|
||||
.sublist(state.sentences.length - 20)
|
||||
.where((s) => s.text.isNotEmpty)
|
||||
.map((s) => s.text.trim()).toSet()
|
||||
: <String>{};
|
||||
.map((s) => s.text.trim()),
|
||||
...favoritedTexts,
|
||||
};
|
||||
|
||||
// 重新处理本次返回的数据,仅排除最近20条
|
||||
final cycledSentences = newSentences.where((s) {
|
||||
// 重新处理本次返回的数据,仅排除最近20条 + 已收藏内容
|
||||
final cycledSentences = mergedSentences.where((s) {
|
||||
if (recentIds.contains(s.id)) return false;
|
||||
if (deduplicateContent && s.text.isNotEmpty &&
|
||||
recentTexts.contains(s.text.trim())) return false;
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
// 如果排除最近20条后仍为空,直接用全部数据
|
||||
// 如果排除后仍为空,直接用全部数据(兜底防止空白)
|
||||
final finalBatch = cycledSentences.isNotEmpty
|
||||
? cycledSentences
|
||||
: newSentences;
|
||||
: mergedSentences;
|
||||
|
||||
if (finalBatch.isNotEmpty) {
|
||||
await saveToDb(finalBatch);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@@ -55,8 +56,15 @@ mixin HomeInteractionMixin on Notifier<HomeState> {
|
||||
final oldValue = sentence.isLiked;
|
||||
if (!mounted) return;
|
||||
|
||||
// Step 1: 乐观更新UI
|
||||
updateSentence(id, (s) => s.copyWith(isLiked: !s.isLiked));
|
||||
// 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)
|
||||
@@ -77,10 +85,53 @@ mixin HomeInteractionMixin on Notifier<HomeState> {
|
||||
}
|
||||
|
||||
/// 本地点赞持久化:写入本地数据库
|
||||
///
|
||||
/// 举一反三:与 _persistFavoriteLocally 同类问题——
|
||||
/// 若 sentences 表中不存在该 id 的记录,原 toggleLike 的 UPDATE 会静默失败,
|
||||
/// 导致本地 isLiked 字段未设置,重启 APP 后点赞状态丢失。
|
||||
/// 修复:先查再决定 set 或 upsert 兜底插入。
|
||||
Future<void> _persistLikeLocally(String id, bool oldValue) async {
|
||||
try {
|
||||
await interactionDb.toggleLike(id);
|
||||
Log.i('本地点赞已${!oldValue ? "添加" : "取消"}: $id');
|
||||
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);
|
||||
}
|
||||
@@ -130,8 +181,15 @@ mixin HomeInteractionMixin on Notifier<HomeState> {
|
||||
final oldValue = sentence.isFavorited;
|
||||
if (!mounted) return;
|
||||
|
||||
// Step 1: 乐观更新UI
|
||||
updateSentence(id, (s) => s.copyWith(isFavorited: !s.isFavorited));
|
||||
// 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,
|
||||
);
|
||||
@@ -157,10 +215,47 @@ mixin HomeInteractionMixin on Notifier<HomeState> {
|
||||
}
|
||||
|
||||
/// 本地收藏持久化:写入本地数据库
|
||||
///
|
||||
/// 修复:原实现仅调用 `toggleFavorite(id)` UPDATE,若 sentences 表中
|
||||
/// 不存在该 id 的记录(如 Feed 列表未缓存/缓存被清),UPDATE 会静默失败,
|
||||
/// 导致本地 isFavorite 字段从未被设置 → "我的收藏"页面查不到刚收藏的句子。
|
||||
///
|
||||
/// 现在:先查询记录是否存在,
|
||||
/// - 存在:用 `setFavoriteFlag(id, !oldValue)` 精确设置(不用 toggle 防止并发抖动)
|
||||
/// - 不存在:插入一条最小化记录,isFavorite 设为目标值,保证后续可被查询到
|
||||
Future<void> _persistFavoriteLocally(String id, bool oldValue) async {
|
||||
try {
|
||||
await interactionDb.toggleFavorite(id);
|
||||
Log.i('本地收藏已${!oldValue ? "添加" : "取消"}: $id');
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -182,6 +182,8 @@ class HomeSentence {
|
||||
feedName: row.feedName.isEmpty ? null : row.feedName,
|
||||
feedIcon: row.feedIcon.isEmpty ? null : row.feedIcon,
|
||||
views: row.views,
|
||||
// likes 字段: .g.dart 未重新生成(build_runner 阻塞),Sentence 类缺 likes getter
|
||||
// 此处保持默认 0,下次 Feed 列表刷新会从服务端获取真实 like_count
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user