637 lines
20 KiB
Dart
637 lines
20 KiB
Dart
/// ============================================================
|
||
/// 闲言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;
|
||
}
|