iOS 提交

This commit is contained in:
Developer
2026-06-27 04:57:00 +08:00
parent 10a917adf6
commit b8d0bd39b5
20 changed files with 5403 additions and 247 deletions

View File

@@ -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;
}

View File

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

View File

@@ -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 winsOR 合并)
/// - 服务端 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);

View File

@@ -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
// 修复: 原仅翻转 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)
@@ -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 同类问题,原仅翻转 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,
);
@@ -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);
}

View File

@@ -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
);
}