Files
xianyan/lib/features/home/presentation/widgets/new_features_dialog.dart
Developer f7520b17b2 win提交
2026-06-22 03:50:59 +08:00

637 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 — 新版本功能弹窗(图文卡片轮播)
/// 创建时间: 2026-06-19
/// 更新时间: 2026-06-19
/// 作用: 引导页开启"了解新功能"后,进入主页弹出当前版本更新日志
/// 上次更新: 重构为图文卡片轮播,支持图标匹配+动态主题色+手势滑动
/// ============================================================
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'
show Material, MaterialType, PageView, Colors;
import '../../../../core/constants/app_constants.dart';
import '../../../../core/storage/kv_storage.dart';
import '../../../../core/theme/app_spacing.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/theme/app_typography.dart';
import '../../../../core/theme/app_radius.dart';
import '../../../../core/utils/logger.dart';
import '../../../../l10n/types/t.dart';
import '../../../profile/presentation/about_shared_widgets.dart';
/// 新版本功能弹窗
/// 在主页首次进入时调用 [maybeShow],若用户在引导页开启了"了解新功能"
/// 且当前版本未查看过,则弹出更新日志(图文卡片轮播)。
class NewFeaturesDialog {
NewFeaturesDialog._();
/// 检查并显示新功能弹窗(仅显示一次 per version
/// [t] 当前翻译实例,[ext] 当前主题扩展
/// 返回 true 表示已弹出
static Future<bool> maybeShow(
BuildContext context,
T t,
AppThemeExtension ext,
) async {
try {
// 检查开关是否开启
if (!KvStorage.knowNewFeatures) return false;
final currentVersion = AppVersion.version;
final lastSeen = KvStorage.lastSeenVersion;
// 已查看过此版本,不再弹出
if (lastSeen == currentVersion) return false;
if (!context.mounted) return false;
// 查找当前版本的更新日志
final entry = _findEntryForVersion(currentVersion);
if (entry == null) {
// 无对应日志,仍标记为已查看避免重复检查
await KvStorage.setLastSeenVersion(currentVersion);
return false;
}
await _showDialog(context, entry, t, ext);
await KvStorage.setLastSeenVersion(currentVersion);
return true;
} catch (e, st) {
Log.e('NewFeaturesDialog: maybeShow error', e, st);
return false;
}
}
/// 查找当前版本的更新日志条目
/// 优先精确匹配,其次匹配 major.minor最后返回最新条目
static UpdateLogEntry? _findEntryForVersion(String version) {
if (AppUpdateLog.entries.isEmpty) return null;
// 1. 精确匹配(去掉 v 前缀比较)
final cleanVer = version.startsWith('v') ? version.substring(1) : version;
for (final e in AppUpdateLog.entries) {
final entryVer = e.version.startsWith('v')
? e.version.substring(1)
: e.version;
if (entryVer == cleanVer) return e;
}
// 2. major.minor 匹配(如 6.6.20 匹配 v6.6.x
final parts = cleanVer.split('.');
if (parts.length >= 2) {
final majorMinor = '${parts[0]}.${parts[1]}';
for (final e in AppUpdateLog.entries) {
final entryVer = e.version.startsWith('v')
? e.version.substring(1)
: e.version;
if (entryVer.startsWith('$majorMinor.')) return e;
}
}
// 3. 返回最新条目
return AppUpdateLog.entries.first;
}
/// 显示弹窗(图文卡片轮播)
static Future<void> _showDialog(
BuildContext context,
UpdateLogEntry entry,
T t,
AppThemeExtension ext,
) async {
await showCupertinoModalPopup<void>(
context: context,
builder: (ctx) {
return _NewFeaturesCarousel(entry: entry, t: t, ext: ext);
},
);
}
}
/// ============================================================
/// 图文卡片轮播组件
/// ============================================================
class _NewFeaturesCarousel extends StatefulWidget {
const _NewFeaturesCarousel({
required this.entry,
required this.t,
required this.ext,
});
final UpdateLogEntry entry;
final T t;
final AppThemeExtension ext;
@override
State<_NewFeaturesCarousel> createState() => _NewFeaturesCarouselState();
}
class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
late final PageController _pageController;
int _currentPage = 0;
@override
void initState() {
super.initState();
_pageController = PageController();
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
/// 解析变更条目,生成卡片数据列表
List<_FeatureCardData> _buildCards() {
final cards = <_FeatureCardData>[];
// 第一张卡片:版本概览
cards.add(
_FeatureCardData(
icon: CupertinoIcons.sparkles,
iconColor: widget.ext.accent,
title: '${widget.entry.version} 更新',
subtitle: widget.entry.date,
description: widget.t.onboarding.knowNewFeatures.replaceAll(
'{0}',
AppVersion.version,
),
isOverview: true,
),
);
// 后续卡片:每个变更点
for (final change in widget.entry.changes) {
final parsed = _parseChange(change);
cards.add(
_FeatureCardData(
icon: parsed.icon,
iconColor: parsed.color,
title: parsed.title,
description: parsed.description,
),
);
}
return cards;
}
/// 解析单条变更文本,提取图标、标题、描述
_ParsedChange _parseChange(String change) {
final trimmed = change.trim();
// 提取开头 emoji如果有
String? emoji;
String rest = trimmed;
if (trimmed.isNotEmpty) {
// 使用 code point 检测 emoji 前缀(避免正则 Unicode 转义兼容性问题)
final runes = trimmed.runes.toList();
int emojiEnd = 0;
while (emojiEnd < runes.length) {
final cp = runes[emojiEnd];
final isEmoji =
(cp >= 0x1F300 && cp <= 0x1FAFF) || // Emoji 主范围
(cp >= 0x2600 && cp <= 0x27BF) || // 杂项符号(☀➿等)
(cp >= 0x2B00 && cp <= 0x2BFF) || // 其他符号
'✨🔧🆕🎨⚡🌐📱🔒🎯✅🐛🚀💡📦🔥⭐💕🎉'.runes.contains(cp) ||
(cp >= 0xFE00 && cp <= 0xFE0F) || // Variation Selector
(cp >= 0x1F900 && cp <= 0x1F9FF); // 补充符号
if (!isEmoji) break;
emojiEnd++;
}
if (emojiEnd > 0) {
emoji = String.fromCharCodes(runes.sublist(0, emojiEnd));
rest = trimmed
.substring(String.fromCharCodes(runes.sublist(0, emojiEnd)).length)
.trim();
}
}
// 根据 emoji 或关键词匹配图标和颜色
final match = _matchIcon(emoji, rest);
return _ParsedChange(
icon: match.icon,
color: match.color,
title: _extractTitle(rest),
description: _extractDescription(rest),
);
}
/// 根据 emoji 或关键词匹配图标和颜色
_IconMatch _matchIcon(String? emoji, String text) {
final ext = widget.ext;
// 按 emoji 匹配
if (emoji != null) {
if (emoji.contains('🆕') || emoji.contains('🎉')) {
return _IconMatch(CupertinoIcons.plus_circle_fill, ext.successColor);
}
if (emoji.contains('🔧') || emoji.contains('🐛')) {
return _IconMatch(CupertinoIcons.wrench_fill, ext.warningColor);
}
if (emoji.contains('🎨')) {
return _IconMatch(CupertinoIcons.paintbrush_fill, ext.iconTintPurple);
}
if (emoji.contains('')) {
return _IconMatch(CupertinoIcons.bolt_fill, ext.iconTintYellow);
}
if (emoji.contains('🌐')) {
return _IconMatch(CupertinoIcons.globe, ext.iconTintBlue);
}
if (emoji.contains('🔒') || emoji.contains('🛡')) {
return _IconMatch(CupertinoIcons.lock_shield_fill, ext.errorColor);
}
if (emoji.contains('📱')) {
return _IconMatch(
CupertinoIcons.device_phone_portrait,
ext.iconTintCyan,
);
}
if (emoji.contains('🚀') || emoji.contains('')) {
return _IconMatch(CupertinoIcons.sparkles, ext.accent);
}
if (emoji.contains('💡')) {
return _IconMatch(CupertinoIcons.lightbulb_fill, ext.iconTintYellow);
}
if (emoji.contains('🔥')) {
return _IconMatch(CupertinoIcons.flame_fill, ext.errorColor);
}
if (emoji.contains('') || emoji.contains('💕')) {
return _IconMatch(CupertinoIcons.heart_fill, ext.iconTintViolet);
}
if (emoji.contains('📦')) {
return _IconMatch(CupertinoIcons.cube_box_fill, ext.iconTintMint);
}
if (emoji.contains('🎯') || emoji.contains('')) {
return _IconMatch(CupertinoIcons.checkmark_seal_fill, ext.successColor);
}
}
// 按关键词匹配
final lower = text.toLowerCase();
if (text.contains('新增') || text.contains('增加') || lower.contains('add')) {
return _IconMatch(CupertinoIcons.plus_circle_fill, ext.successColor);
}
if (text.contains('修复') || lower.contains('fix') || lower.contains('bug')) {
return _IconMatch(CupertinoIcons.wrench_fill, ext.warningColor);
}
if (text.contains('优化') ||
text.contains('改进') ||
text.contains('提升') ||
lower.contains('optim')) {
return _IconMatch(CupertinoIcons.paintbrush_fill, ext.iconTintPurple);
}
if (text.contains('性能') || lower.contains('performance')) {
return _IconMatch(CupertinoIcons.bolt_fill, ext.iconTintYellow);
}
if (text.contains('多语言') ||
text.contains('翻译') ||
lower.contains('language')) {
return _IconMatch(CupertinoIcons.globe, ext.iconTintBlue);
}
if (text.contains('安全') ||
text.contains('密保') ||
lower.contains('security')) {
return _IconMatch(CupertinoIcons.lock_shield_fill, ext.errorColor);
}
if (text.contains('框架') ||
text.contains('架构') ||
lower.contains('framework')) {
return _IconMatch(CupertinoIcons.cube_box_fill, ext.iconTintMint);
}
// 默认
return _IconMatch(CupertinoIcons.sparkles, ext.accent);
}
/// 提取标题取第一行或前20字
String _extractTitle(String text) {
final lines = text.split('\n');
final firstLine = lines.first.trim();
if (firstLine.length <= 30) return firstLine;
return '${firstLine.substring(0, 30)}...';
}
/// 提取描述(剩余内容)
String _extractDescription(String text) {
final lines = text.split('\n');
if (lines.length > 1) {
return lines.skip(1).join('\n').trim();
}
final firstLine = lines.first.trim();
if (firstLine.length > 30) {
return firstLine.substring(30).trim();
}
return '';
}
void _onPageChanged(int page) {
setState(() {
_currentPage = page;
});
}
@override
Widget build(BuildContext context) {
final cards = _buildCards();
final ext = widget.ext;
final screenHeight = MediaQuery.of(context).size.height;
final maxWidth = MediaQuery.of(context).size.width * 0.92;
return Material(
type: MaterialType.transparency,
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Container(
constraints: BoxConstraints(
maxWidth: maxWidth,
maxHeight: screenHeight * 0.75,
),
decoration: BoxDecoration(
color: ext.bgCard,
borderRadius: BorderRadius.circular(AppRadius.xl),
boxShadow: [
BoxShadow(
color: ext.overlayStrong,
blurRadius: 40,
offset: const Offset(0, 20),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.xl),
child: Column(
// mainAxisSize.max 让 Column 填充 maxHeight 约束
// 配合 Expanded 让 PageView 占据剩余空间,避免内容溢出
children: [
// 顶部拖拽指示器
_buildDragHandle(ext),
// 卡片轮播区域Expanded 占据剩余空间)
Expanded(
child: PageView.builder(
controller: _pageController,
onPageChanged: _onPageChanged,
itemCount: cards.length,
itemBuilder: (ctx, index) {
return _buildCard(cards[index], ext, index == 0);
},
),
),
// 底部页码指示器 + 按钮
_buildBottomBar(cards.length, ext),
],
),
),
),
),
);
}
/// 构建拖拽指示器
Widget _buildDragHandle(AppThemeExtension ext) {
return Padding(
padding: const EdgeInsets.only(top: AppSpacing.sm, bottom: AppSpacing.xs),
child: Container(
width: 36,
height: 5,
decoration: BoxDecoration(
color: ext.overlayMedium,
borderRadius: BorderRadius.circular(2.5),
),
),
);
}
/// 构建单张卡片
Widget _buildCard(
_FeatureCardData card,
AppThemeExtension ext,
bool isOverview,
) {
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.md,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 图标区域(带光晕效果)
_buildIconWithGlow(card, ext, isOverview),
const SizedBox(height: AppSpacing.lg),
// 标题
Text(
card.title,
style: AppTypography.headline.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
// 副标题(日期)
if (card.subtitle != null) ...[
const SizedBox(height: AppSpacing.xs),
Text(
card.subtitle!,
style: AppTypography.caption1.copyWith(color: ext.textHint),
textAlign: TextAlign.center,
),
],
// 描述
if (card.description.isNotEmpty) ...[
const SizedBox(height: AppSpacing.md),
Text(
card.description,
style: AppTypography.body.copyWith(
color: ext.textSecondary,
height: 1.5,
),
textAlign: TextAlign.center,
maxLines: 6,
overflow: TextOverflow.ellipsis,
),
],
],
),
);
}
/// 构建带光晕效果的图标
Widget _buildIconWithGlow(
_FeatureCardData card,
AppThemeExtension ext,
bool isOverview,
) {
final iconSize = isOverview ? 64.0 : 56.0;
final containerSize = isOverview ? 120.0 : 100.0;
return Container(
width: containerSize,
height: containerSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
card.iconColor.withValues(alpha: 0.25),
card.iconColor.withValues(alpha: 0.08),
Colors.transparent,
],
stops: const [0.0, 0.6, 1.0],
),
),
child: Center(
child: Container(
width: containerSize * 0.6,
height: containerSize * 0.6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: card.iconColor.withValues(alpha: 0.15),
border: Border.all(
color: card.iconColor.withValues(alpha: 0.3),
width: 1.5,
),
),
child: Icon(card.icon, size: iconSize, color: card.iconColor),
),
),
);
}
/// 构建底部栏(页码指示器 + 按钮)
Widget _buildBottomBar(int totalPages, AppThemeExtension ext) {
final isLastPage = _currentPage == totalPages - 1;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.md,
),
decoration: BoxDecoration(
color: ext.bgCard,
border: Border(top: BorderSide(color: ext.overlaySubtle, width: 0.5)),
),
child: SafeArea(
top: false,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// 跳过按钮
CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => Navigator.of(context).pop(),
child: Text(
widget.t.common.cancel,
style: AppTypography.body.copyWith(color: ext.textHint),
),
),
// 页码指示器Flexible 包裹,避免卡片多时挤压两侧按钮导致溢出)
Flexible(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: List.generate(totalPages, (index) {
final isActive = index == _currentPage;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: isActive ? 20 : 6,
height: 6,
decoration: BoxDecoration(
color: isActive ? ext.accent : ext.overlayMedium,
borderRadius: BorderRadius.circular(3),
),
);
}),
),
),
),
// 下一步/知道了按钮
CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () {
if (isLastPage) {
Navigator.of(context).pop();
} else {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: ext.accent,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Text(
isLastPage ? widget.t.common.gotIt : widget.t.common.confirm,
style: AppTypography.body.copyWith(
color: ext.textOnAccent,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
);
}
}
/// ============================================================
/// 数据模型
/// ============================================================
class _FeatureCardData {
const _FeatureCardData({
required this.icon,
required this.iconColor,
required this.title,
required this.description,
this.subtitle,
this.isOverview = false,
});
final IconData icon;
final Color iconColor;
final String title;
final String description;
final String? subtitle;
final bool isOverview;
}
class _ParsedChange {
const _ParsedChange({
required this.icon,
required this.color,
required this.title,
required this.description,
});
final IconData icon;
final Color color;
final String title;
final String description;
}
class _IconMatch {
const _IconMatch(this.icon, this.color);
final IconData icon;
final Color color;
}