feat: 新增口味偏好服务和菜谱分享功能

- 新增 TastePreferenceService 用于管理用户口味偏好设置
- 实现菜谱分享功能,包括 RecipeShareService 和分享页面
- 更新平台工具类以支持鸿蒙系统检测
- 优化收藏页和农场商店页面的UI交互
- 添加新的参考文献和关于页面内容
- 更新API文档至v3.3.0版本
This commit is contained in:
Developer
2026-04-18 08:29:31 +08:00
parent e347b3ca73
commit ceb11d9aac
27 changed files with 6063 additions and 816 deletions

View File

@@ -27,6 +27,7 @@ import 'package:mom_kitchen/src/services/core/app_service.dart';
import 'package:mom_kitchen/src/services/ui/theme_service.dart';
import 'package:mom_kitchen/src/services/data/offline_service.dart';
import 'package:mom_kitchen/src/services/data/data_export_service.dart';
import 'package:mom_kitchen/src/services/user/taste_preference_service.dart';
/// 全局Binding - 应用启动时注册所有全局控制器和服务
/// 所有 permanent:true 的控制器在此统一管理路由级Binding禁止重复注册
@@ -55,6 +56,9 @@ class AppBinding extends Bindings {
// --- 数据导出服务 ---
Get.put(DataExportService(), permanent: true);
// --- 口味偏好服务 ---
Get.put(TastePreferenceService(), permanent: true);
// --- 主题与个性化(首屏必需) ---
if (!Get.isRegistered<ThemeService>()) {
Get.put(ThemeService.instance, permanent: true);

View File

@@ -51,6 +51,7 @@ import 'package:mom_kitchen/src/pages/tools/cooking_tips_list_page.dart';
import 'package:mom_kitchen/src/pages/profile/references_page.dart';
import 'package:mom_kitchen/src/pages/profile/privacy_policy_page.dart';
import 'package:mom_kitchen/src/pages/profile/guide_page.dart';
import 'package:mom_kitchen/src/pages/profile/learn_us_page.dart';
import 'package:mom_kitchen/src/pages/profile/social/email_history_page.dart';
import 'package:mom_kitchen/src/pages/tools/cooking/date_calculator_page.dart';
import 'package:mom_kitchen/src/pages/tools/cooking/food_copy_generator_page.dart';
@@ -117,6 +118,7 @@ class AppRoutes {
static const String about = '/about';
static const String references = '/references';
static const String privacyPolicy = '/privacy-policy';
static const String learnUs = '/learn-us';
static const String guide = '/guide';
static const String nutritionRecipeList = '/nutrition-recipe-list';
static const String miniCard = '/mini-card';
@@ -184,6 +186,11 @@ class AppRoutes {
page: () => const PrivacyPolicyPage(),
middlewares: [PageStandardsMiddleware()],
),
GetPage(
name: learnUs,
page: () => const LearnUsPage(),
middlewares: [PageStandardsMiddleware()],
),
GetPage(
name: dataExport,
page: () => const DataExportPage(),
@@ -407,7 +414,7 @@ class AppRoutes {
: null);
return CategoryBrowsePage(
category: category,
title: args?['title'] ?? '<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>',
title: args?['title'] ?? '分类浏览',
loadRecipesDirectly: args?['loadRecipesDirectly'] ?? false,
isIngredient: args?['isIngredient'] ?? false,
);
@@ -646,6 +653,42 @@ class AppRoutes {
],
builder: () => const FeedbackPage(),
),
PageInfo(
route: about,
name: 'About Page',
description: '关于页面',
requiredStandards: const [
StandardCheck.themeColors,
StandardCheck.textColors,
StandardCheck.fontSize,
StandardCheck.darkMode,
],
builder: () => const AboutPage(),
),
PageInfo(
route: privacyPolicy,
name: 'Privacy Policy Page',
description: '隐私政策页面',
requiredStandards: const [
StandardCheck.themeColors,
StandardCheck.textColors,
StandardCheck.fontSize,
StandardCheck.darkMode,
],
builder: () => const PrivacyPolicyPage(),
),
PageInfo(
route: guide,
name: 'Guide Page',
description: '使用指南页面',
requiredStandards: const [
StandardCheck.themeColors,
StandardCheck.textColors,
StandardCheck.fontSize,
StandardCheck.darkMode,
],
builder: () => const GuidePage(),
),
PageInfo(
route: main,
name: 'Main Tab View',
@@ -1248,6 +1291,18 @@ class AppRoutes {
],
builder: () => const FarmAchievementPage(),
),
PageInfo(
route: learnUs,
name: 'Learn Us Page',
description: '了解我们页面',
requiredStandards: const [
StandardCheck.themeColors,
StandardCheck.textColors,
StandardCheck.fontSize,
StandardCheck.darkMode,
],
builder: () => const LearnUsPage(),
),
]);
}
}

View File

@@ -1,7 +1,9 @@
// 农场游戏核心控制器
// 管理游戏逻辑:种植、生长、浇水、收获、升级、成就
// 2026-04-18 | 优化生长计时器间隔改为30秒添加应用前后台生命周期管理
// 2026-04-18 | 修复消息限流5秒2次/10秒3次/去重ActionSheet操作后消费对话框
import 'dart:async';
import 'dart:collection';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
@@ -13,6 +15,7 @@ import 'package:mom_kitchen/src/models/farm/achievement_registry.dart';
import 'package:mom_kitchen/src/services/data/hive_service.dart';
import 'package:mom_kitchen/src/config/farm_config.dart';
import 'package:mom_kitchen/src/services/log/logger_service.dart';
import 'package:mom_kitchen/src/services/ui/toast_service.dart';
class FarmGameController extends GetxController with WidgetsBindingObserver {
final Rx<FarmPlayer> player = FarmPlayer.createDefault('').obs;
@@ -23,6 +26,12 @@ class FarmGameController extends GetxController with WidgetsBindingObserver {
bool _isInitialized = false;
DateTime? _pausedTime;
final Queue<DateTime> _toastTimestamps = Queue<DateTime>();
String? _lastToastMessage;
static const int _maxIn5s = 2;
static const int _maxIn10s = 3;
@override
void onInit() {
super.onInit();
@@ -62,6 +71,29 @@ class FarmGameController extends GetxController with WidgetsBindingObserver {
}
}
// ==================== 消息限流 ====================
void _showToast(String message, {ToastType type = ToastType.info}) {
final now = DateTime.now();
_toastTimestamps.removeWhere((t) => now.difference(t).inSeconds > 10);
final countIn5s = _toastTimestamps
.where((t) => now.difference(t).inSeconds <= 5)
.length;
final countIn10s = _toastTimestamps.length;
if (message == _lastToastMessage) return;
if (countIn5s >= _maxIn5s || countIn10s >= _maxIn10s) return;
_toastTimestamps.add(now);
_lastToastMessage = message;
Future.delayed(const Duration(seconds: 3), () {
if (_lastToastMessage == message) _lastToastMessage = null;
});
ToastService.show(message: message, type: type);
}
// ==================== 数据加载 ====================
void _loadData() {
@@ -161,16 +193,16 @@ class FarmGameController extends GetxController with WidgetsBindingObserver {
Future<void> plantCrop({required int landId, required String cropId}) async {
final land = lands.firstWhereOrNull((l) => l.landId == landId);
if (land == null) {
Get.snackbar('错误', '土地不存在');
_showToast('土地不存在', type: ToastType.error);
return;
}
if (land.cropId != null) {
Get.snackbar('提示', '这块土地已经种植了作物');
_showToast('这块土地已经种植了作物', type: ToastType.warning);
return;
}
if (!land.isUnlocked) {
Get.snackbar('提示', '这块土地还未解锁');
_showToast('这块土地还未解锁', type: ToastType.warning);
return;
}
@@ -180,11 +212,10 @@ class FarmGameController extends GetxController with WidgetsBindingObserver {
.firstOrNull;
if (seedItem == null || seedItem.quantity <= 0) {
Get.snackbar('提示', '种子数量不足,请前往商店购买');
_showToast('种子数量不足,请前往商店购买 🛒', type: ToastType.warning);
return;
}
// 消耗种子
seedItem.quantity--;
if (seedItem.quantity == 0) {
inventory.remove(seedItem);
@@ -193,7 +224,6 @@ class FarmGameController extends GetxController with WidgetsBindingObserver {
_saveInventoryItem(seedItem);
}
// 种植
land.cropId = cropId;
land.plantTime = DateTime.now();
land.growthStage = 0;
@@ -208,7 +238,7 @@ class FarmGameController extends GetxController with WidgetsBindingObserver {
lands.refresh();
final crop = CropRegistry.getById(cropId);
Get.snackbar('🌱 种植成功', '已开始种植${crop?.name}');
_showToast('🌱 已种植${crop?.name}', type: ToastType.success);
LoggerService().info('Planted ${crop?.name} on land $landId');
}
@@ -216,11 +246,11 @@ class FarmGameController extends GetxController with WidgetsBindingObserver {
Future<void> waterLand(int landId) async {
final land = lands.firstWhereOrNull((l) => l.landId == landId);
if (land == null) {
Get.snackbar('错误', '土地不存在');
_showToast('土地不存在', type: ToastType.error);
return;
}
if (!land.needWater || land.isWithered || land.isReady) {
Get.snackbar('提示', '这块土地不需要浇水');
_showToast('这块土地不需要浇水', type: ToastType.warning);
return;
}
@@ -229,7 +259,7 @@ class FarmGameController extends GetxController with WidgetsBindingObserver {
await _saveLand(land);
lands.refresh();
Get.snackbar('💧 浇水成功', '作物生长速度已提升');
_showToast('💧 浇水成功,生长速度提升', type: ToastType.success);
LoggerService().info('Watered land $landId');
}
@@ -241,7 +271,7 @@ class FarmGameController extends GetxController with WidgetsBindingObserver {
return;
}
if (!land.isReady) {
Get.snackbar('提示', '作物还未成熟');
_showToast('作物还未成熟', type: ToastType.warning);
return;
}
@@ -293,9 +323,9 @@ class FarmGameController extends GetxController with WidgetsBindingObserver {
// 检查成就
_checkAchievements('totalHarvest', player.value.totalHarvest);
Get.snackbar(
'🎉 收获成功',
'获得 ${crop.harvestPrice} 金币,+${crop.harvestExp} 经验',
_showToast(
'🎉 收获 ${crop.harvestPrice}💰 +${crop.harvestExp}EXP',
type: ToastType.success,
);
LoggerService().info('Harvested ${crop.name} from land $landId');
}
@@ -319,22 +349,23 @@ class FarmGameController extends GetxController with WidgetsBindingObserver {
await _saveLand(land);
lands.refresh();
Get.snackbar('🧹 清理完成', '土地已恢复');
_showToast('🧹 土地已恢复', type: ToastType.success);
}
// ==================== 升级和成就 ====================
void _checkLevelUp() {
while (player.value.experience >= player.value.expToNextLevel) {
int maxLevelsPerCheck = 5;
while (player.value.experience >= player.value.expToNextLevel &&
maxLevelsPerCheck > 0) {
player.value.experience -= player.value.expToNextLevel;
player.value.level++;
maxLevelsPerCheck--;
// 升级奖励
final goldReward = player.value.level * 50;
player.value.gold += goldReward;
player.value.diamond += 5;
// 解锁新作物
final newCrops = CropRegistry.getAvailableForLevel(player.value.level);
for (final crop in newCrops) {
if (!player.value.unlockedCrops.contains(crop.id)) {
@@ -344,14 +375,13 @@ class FarmGameController extends GetxController with WidgetsBindingObserver {
savePlayer();
Get.snackbar(
'🎊 升级',
'当前等级Lv.${player.value.level}\n奖励:$goldReward 金币 + 5 钻石',
duration: const Duration(seconds: 3),
_showToast(
'🎊 升级 Lv.${player.value.level}+$goldReward💰+5💎',
type: ToastType.success,
);
// 检查解锁作物的成就
_checkAchievements('unlockedCrops', player.value.unlockedCrops.length);
_checkAchievements('level', player.value.level);
LoggerService().info('Player leveled up to ${player.value.level}');
}
@@ -373,15 +403,18 @@ class FarmGameController extends GetxController with WidgetsBindingObserver {
savePlayer();
Get.snackbar(
'🏆 成就解锁!',
'${achievement.emoji} ${achievement.name}\n${achievement.description}',
duration: const Duration(seconds: 3),
_showToast(
'🏆 ${achievement.emoji} ${achievement.name}',
type: ToastType.success,
);
LoggerService().info('Achievement unlocked: ${achievement.name}');
}
}
if (newOnes.any((a) => a.rewardExp > 0)) {
_checkLevelUp();
}
}
// ==================== 解锁土地 ====================
@@ -389,13 +422,16 @@ class FarmGameController extends GetxController with WidgetsBindingObserver {
Future<void> unlockLand(int landId) async {
final land = lands.firstWhereOrNull((l) => l.landId == landId);
if (land == null) {
Get.snackbar('错误', '土地不存在');
_showToast('土地不存在', type: ToastType.error);
return;
}
if (land.isUnlocked) return;
if (player.value.gold < FarmConfig.unlockLandCost) {
Get.snackbar('金币不足', '解锁土地需要 ${FarmConfig.unlockLandCost} 金币');
_showToast(
'金币不足,需要 ${FarmConfig.unlockLandCost} 💰',
type: ToastType.warning,
);
return;
}
@@ -406,7 +442,7 @@ class FarmGameController extends GetxController with WidgetsBindingObserver {
await _saveLand(land);
lands.refresh();
Get.snackbar('🔓 解锁成功', '土地 ${landId + 1} 已解锁');
_showToast('🔓 土地 ${landId + 1} 已解锁', type: ToastType.success);
}
// ==================== 数据保存 ====================
@@ -436,7 +472,7 @@ class FarmGameController extends GetxController with WidgetsBindingObserver {
Future<void> debugAddGold() async {
player.value.gold += 1000;
savePlayer();
Get.snackbar('🐛 调试', '已添加 1000 金币');
_showToast('🐛 已添加 1000 金币', type: ToastType.info);
}
Future<void> debugSpeedUp() async {
@@ -449,7 +485,7 @@ class FarmGameController extends GetxController with WidgetsBindingObserver {
}
}
lands.refresh();
Get.snackbar('🐛 调试', '所有作物已加速成熟');
_showToast('🐛 所有作物已加速成熟', type: ToastType.info);
}
Future<void> debugUnlockAll() async {
@@ -462,7 +498,7 @@ class FarmGameController extends GetxController with WidgetsBindingObserver {
player.value.unlockedCrops.addAll(CropRegistry.getAll().map((c) => c.id));
savePlayer();
lands.refresh();
Get.snackbar('🐛 调试', '已解锁所有内容');
_showToast('🐛 已解锁所有内容', type: ToastType.info);
}
Future<void> debugReset() async {
@@ -471,6 +507,6 @@ class FarmGameController extends GetxController with WidgetsBindingObserver {
await hiveService.farmLands?.clear();
await hiveService.farmInventory?.clear();
_loadData();
Get.snackbar('🐛 调试', '游戏数据已重置');
_showToast('🐛 游戏数据已重置', type: ToastType.info);
}
}

View File

@@ -7,6 +7,7 @@ import 'package:mom_kitchen/src/models/farm/inventory_item.dart';
import 'package:mom_kitchen/src/services/data/hive_service.dart';
import 'package:mom_kitchen/src/controllers/farm/farm_game_controller.dart';
import 'package:mom_kitchen/src/services/log/logger_service.dart';
import 'package:mom_kitchen/src/services/ui/toast_service.dart';
class FarmShopController extends GetxController {
final _gameController = Get.find<FarmGameController>();
@@ -30,7 +31,7 @@ class FarmShopController extends GetxController {
final player = _gameController.player.value;
if (player.gold < crop.seedPrice) {
Get.snackbar('金币不足', '需要 ${crop.seedPrice} 金币,当前 ${player.gold} 金币');
ToastService.warning('金币不足,需要 ${crop.seedPrice} 💰');
return;
}
@@ -61,7 +62,7 @@ class FarmShopController extends GetxController {
await _saveInventoryItem(seedItem);
}
Get.snackbar('🛒 购买成功', '已购买 ${crop.name}种子');
ToastService.success('🛒 已购买 ${crop.name}种子');
LoggerService().info('Bought ${crop.name} seed for ${crop.seedPrice} gold');
}

View File

@@ -410,7 +410,7 @@ class _ToolsPanelWidgetState extends State<ToolsPanelWidget> {
mainAxisSize: MainAxisSize.min,
children: [
Text(
'更多',
'工具中心 旧版UI',
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: DesignTokens.dynamicPrimary,

View File

@@ -27,6 +27,7 @@ import 'package:mom_kitchen/src/widgets/recipe_detail/header/recipe_time_info.da
import 'package:mom_kitchen/src/widgets/recipe_detail/header/recipe_title_section.dart';
import 'package:mom_kitchen/src/widgets/recipe_detail/info/recipe_similar_section.dart';
import 'package:mom_kitchen/src/widgets/recipe_detail/interaction/recipe_email_button.dart';
import 'package:mom_kitchen/src/widgets/recipe_detail/info/recipe_taste_preference.dart';
class RecipeDetailPage extends StatelessWidget {
final String recipeId;
@@ -171,6 +172,7 @@ class RecipeDetailPage extends StatelessWidget {
RecipeTagsSection(recipe: recipe),
RecipeIngredientsSection(recipe: recipe),
RecipeAllergenWarning(recipe: recipe),
RecipeTastePreference(recipe: recipe),
RecipeStepsSection(recipe: recipe),
RecipeNutritionSection(recipe: recipe),
RecipeIngredientDetails(recipe: recipe),
@@ -182,9 +184,11 @@ class RecipeDetailPage extends StatelessWidget {
? controller.rateRemaining.value
: null,
recipeCode: recipe.code,
recipeId: recipe.id,
recipeTitle: recipe.title,
categoryName: recipe.categoryName,
ratingScore: recipe.rating?.score,
recipe: recipe,
onRate: (score) async {
await controller.rateRecipe(score: score);
},

View File

@@ -6,15 +6,21 @@
* 更新: 2026-04-13 新增关于页面,包含用户反馈入口
* 更新: 2026-04-13 新增开发者文档入口API文档、App接入指南
* 更新: 2026-04-17 新增软件权限页面入口
* 更新: 2026-04-18 了解我们入口改为跳转新页面
* 更新: 2026-04-18 软件信息入口改为跳转AppInfoPage
* 更新: 2026-04-18 头部改用图片+长方形布局,对齐下方卡片边距
*/
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart' show Divider;
import 'package:get/get.dart';
import 'package:mom_kitchen/src/config/design_tokens.dart';
import 'package:mom_kitchen/src/pages/profile/app_info_page.dart';
import 'package:mom_kitchen/src/pages/profile/learn_us_page.dart';
import 'package:mom_kitchen/src/pages/profile/privacy_policy_page.dart';
import 'package:mom_kitchen/src/pages/profile/permission_page.dart';
import 'package:mom_kitchen/src/pages/profile/references_page.dart';
import 'package:mom_kitchen/src/services/core/app_info_service.dart';
import 'package:url_launcher/url_launcher.dart';
class AboutPage extends StatelessWidget {
@@ -68,65 +74,145 @@ class AboutPage extends StatelessWidget {
}
Widget _buildAppHeader(bool isDark) {
return Container(
padding: const EdgeInsets.all(DesignTokens.space5),
decoration: BoxDecoration(
color: isDark ? DarkDesignTokens.card : DesignTokens.card,
borderRadius: DesignTokens.borderRadiusLg,
boxShadow: DesignTokens.shadowsSm,
),
child: Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
DesignTokens.dynamicPrimary,
DesignTokens.dynamicPrimary.withValues(alpha: 0.7),
return GestureDetector(
onTap: () => Get.to(() => const AppInfoPage()),
behavior: HitTestBehavior.opaque,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4,
vertical: DesignTokens.space4,
),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
DesignTokens.dynamicPrimary.withValues(alpha: 0.85),
DesignTokens.dynamicPrimary,
DesignTokens.dynamicPrimary.withValues(alpha: 0.7),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: DesignTokens.borderRadiusLg,
boxShadow: [
BoxShadow(
color: DesignTokens.dynamicPrimary.withValues(alpha: 0.18),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
borderRadius: DesignTokens.borderRadiusMd,
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.35),
width: 2,
),
),
child: ClipRRect(
borderRadius: DesignTokens.borderRadiusSm,
child: Image.asset(
'assets/icons/icon_128x128.png',
width: 58,
height: 58,
fit: BoxFit.cover,
errorBuilder: (ctx, err, st) => Container(
width: 58,
height: 58,
decoration: BoxDecoration(
color: CupertinoColors.white.withValues(alpha: 0.15),
borderRadius: DesignTokens.borderRadiusSm,
),
child: const Center(
child: Text('🍳', style: TextStyle(fontSize: 28)),
),
),
),
),
),
const SizedBox(width: DesignTokens.space4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'小妈厨房',
style: TextStyle(
fontSize: DesignTokens.fontXl,
fontWeight: FontWeight.bold,
color: CupertinoColors.white,
),
),
const SizedBox(height: DesignTokens.space1),
Text(
'用心烹饪,用爱生活',
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: CupertinoColors.white.withValues(alpha: 0.85),
),
),
const SizedBox(height: DesignTokens.space2),
Container(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space2,
vertical: DesignTokens.space1,
),
decoration: BoxDecoration(
color: CupertinoColors.white.withValues(alpha: 0.18),
borderRadius: DesignTokens.borderRadiusFull,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
CupertinoIcons.tag,
size: 12,
color: CupertinoColors.white.withValues(alpha: 0.9),
),
const SizedBox(width: DesignTokens.space1),
Text(
'Version ${AppInfoService().version}',
style: TextStyle(
fontSize: DesignTokens.fontXs,
fontWeight: FontWeight.w500,
color: CupertinoColors.white.withValues(
alpha: 0.95,
),
),
),
],
),
),
],
),
borderRadius: DesignTokens.borderRadiusLg,
),
child: const Center(
child: Text('🍳', style: TextStyle(fontSize: 40)),
Icon(
CupertinoIcons.chevron_right,
size: 16,
color: CupertinoColors.white.withValues(alpha: 0.5),
),
),
const SizedBox(height: DesignTokens.space3),
Text(
'小妈厨房',
style: TextStyle(
fontSize: DesignTokens.fontXl,
fontWeight: FontWeight.bold,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
const SizedBox(height: DesignTokens.space1),
Text(
'Version 0.92.4',
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
),
),
],
],
),
),
);
}
Widget _buildAppInfoSection(bool isDark) {
final appInfo = AppInfoService();
return _buildSection(
title: '📱 应用信息',
isDark: isDark,
children: [
_buildInfoTile('应用版本', '0.92.4', isDark),
_buildInfoTile('应用版本', appInfo.version, isDark),
_buildDivider(isDark),
_buildInfoTile('更新日期', '2026-04', isDark),
_buildInfoTile('更新日期', '2026-04-18', isDark),
_buildDivider(isDark),
_buildInfoTile('构建版本', '92', isDark),
_buildInfoTile('构建版本', appInfo.buildNumber, isDark),
],
);
}
@@ -141,7 +227,7 @@ class AboutPage extends StatelessWidget {
title: '软件信息',
subtitle: '查看软件功能',
isDark: isDark,
onTap: () => _openUrl('https://eat.wktyl.com/api/doc/API_DOC.md'),
onTap: () => Get.to(() => const AppInfoPage()),
),
_buildDivider(isDark),
_buildActionTile(
@@ -149,7 +235,7 @@ class AboutPage extends StatelessWidget {
title: '了解我们',
subtitle: '查看关于我们',
isDark: isDark,
onTap: () => _openUrl('https://eat.wktyl.com/api/doc/APP_GUIDE.md'),
onTap: () => Get.to(() => const LearnUsPage()),
),
_buildDivider(isDark),
_buildActionTile(

File diff suppressed because it is too large Load Diff

View File

@@ -422,7 +422,7 @@ class _DataExportPageState extends State<DataExportPage> {
title: const Text('📥 分享导入说明'),
content: const Text(
'1. 在文件管理器或其他应用中找到导出的 JSON 文件\n'
'2. 点击分享按钮,选择"妈厨房"\n'
'2. 点击分享按钮,选择"妈厨房"\n'
'3. 应用会自动识别并预览导入数据\n'
'4. 确认后即可完成导入',
),

View File

@@ -0,0 +1,904 @@
/*
* 文件: learn_us_page.dart
* 名称: 了解我们页面
* 作用: 展示开发者信息、团队信息、官网和备案号
* 创建: 2026-04-18
* 更新: 2026-04-18 新增了解我们页面
* 更新: 2026-04-18 头部emoji改用图片版本号同步更新
*/
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart' show Divider;
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:mom_kitchen/src/config/design_tokens.dart';
import 'package:mom_kitchen/src/services/core/app_info_service.dart';
import 'package:url_launcher/url_launcher.dart';
class LearnUsPage extends StatelessWidget {
const LearnUsPage({super.key});
@override
Widget build(BuildContext context) {
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
return CupertinoPageScaffold(
backgroundColor: isDark
? DarkDesignTokens.background
: DesignTokens.background,
navigationBar: CupertinoNavigationBar(
middle: Text(
'了解我们',
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w600,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
backgroundColor: isDark
? DarkDesignTokens.glass.withValues(alpha: 0.8)
: DesignTokens.glass.withValues(alpha: 0.8),
border: null,
),
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(DesignTokens.space4),
child: Column(
children: [
_buildHeaderCard(isDark),
const SizedBox(height: DesignTokens.space4),
_buildOfficialSiteSection(isDark),
const SizedBox(height: DesignTokens.space4),
_buildDeveloperSection(isDark),
const SizedBox(height: DesignTokens.space4),
_buildTeamSection(isDark),
const SizedBox(height: DesignTokens.space4),
_buildIcpSection(isDark),
const SizedBox(height: DesignTokens.space6),
_buildBottomIndicator(isDark),
],
),
),
),
);
}
/// 顶部头部卡片
Widget _buildHeaderCard(bool isDark) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4,
vertical: DesignTokens.space4,
),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
DesignTokens.dynamicPrimary.withValues(alpha: 0.85),
DesignTokens.dynamicPrimary,
DesignTokens.dynamicPrimary.withValues(alpha: 0.65),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: DesignTokens.borderRadiusLg,
boxShadow: [
BoxShadow(
color: DesignTokens.dynamicPrimary.withValues(alpha: 0.18),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
borderRadius: DesignTokens.borderRadiusMd,
border: Border.all(
color: CupertinoColors.white.withValues(alpha: 0.35),
width: 2,
),
),
child: ClipRRect(
borderRadius: DesignTokens.borderRadiusSm,
child: Image.asset(
'assets/icons/icon_128x128.png',
width: 58,
height: 58,
fit: BoxFit.cover,
errorBuilder: (ctx, err, st) => Container(
width: 58,
height: 58,
decoration: BoxDecoration(
color: CupertinoColors.white.withValues(alpha: 0.15),
borderRadius: DesignTokens.borderRadiusSm,
),
child: const Center(
child: Text('🍳', style: TextStyle(fontSize: 28)),
),
),
),
),
),
const SizedBox(width: DesignTokens.space5),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'小妈厨房',
style: TextStyle(
fontSize: DesignTokens.fontXxl,
fontWeight: FontWeight.bold,
color: CupertinoColors.white,
),
),
const SizedBox(height: DesignTokens.space1),
Text(
'用心烹饪,用爱生活',
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: CupertinoColors.white.withValues(alpha: 0.85),
),
),
const SizedBox(height: DesignTokens.space3),
Container(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space3,
vertical: DesignTokens.space1,
),
decoration: BoxDecoration(
color: CupertinoColors.white.withValues(alpha: 0.2),
borderRadius: DesignTokens.borderRadiusFull,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
CupertinoIcons.tag,
size: 14,
color: CupertinoColors.white.withValues(alpha: 0.9),
),
const SizedBox(width: DesignTokens.space2),
Text(
'Version ${AppInfoService().version}',
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: CupertinoColors.white.withValues(alpha: 0.95),
),
),
],
),
),
],
),
),
],
),
);
}
/// 官方网站区域
Widget _buildOfficialSiteSection(bool isDark) {
return _buildSection(
title: '🌐 官方网站',
isDark: isDark,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
DesignTokens.space4,
0,
DesignTokens.space4,
DesignTokens.space3,
),
child: Text(
'访问我们的官方网站了解更多信息',
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
),
),
_buildLinkTile(
icon: CupertinoIcons.globe,
label: 'API 服务',
url: 'https://eat.wktyl.com/api/',
isDark: isDark,
),
_buildDivider(isDark),
_buildLinkTile(
icon: CupertinoIcons.doc_text,
label: 'App 接入指南',
url: 'https://eat.wktyl.com/api/doc/APP_GUIDE.md',
isDark: isDark,
),
_buildDivider(isDark),
_buildLinkTile(
icon: CupertinoIcons.book,
label: 'API 文档',
url: 'https://eat.wktyl.com/api/doc/API_DOC.md',
isDark: isDark,
),
],
);
}
/// 开发者区域
Widget _buildDeveloperSection(bool isDark) {
return _buildSection(
title: '🏢 开发者',
isDark: isDark,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4,
vertical: DesignTokens.space3,
),
child: Row(
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
DesignTokens.dynamicPrimaryLight,
DesignTokens.dynamicPrimary.withValues(alpha: 0.05),
],
),
borderRadius: DesignTokens.borderRadiusMd,
),
child: Icon(
CupertinoIcons.building_2_fill,
size: 26,
color: DesignTokens.dynamicPrimary,
),
),
const SizedBox(width: DesignTokens.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'微风暴网络科技工作室',
style: TextStyle(
fontSize: DesignTokens.fontLg,
fontWeight: FontWeight.bold,
color: isDark
? DarkDesignTokens.text1
: DesignTokens.text1,
),
),
const SizedBox(height: 2),
Text(
'专注美食与生活领域',
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark
? DarkDesignTokens.text3
: DesignTokens.text3,
),
),
],
),
),
],
),
),
_buildDivider(isDark),
_buildCopyTile(
icon: CupertinoIcons.mail,
title: '商务合作 & 联系我们',
value: 'support@momkitchen.app',
isDark: isDark,
),
_buildDivider(isDark),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4,
vertical: DesignTokens.space3,
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFF07C160).withValues(alpha: 0.1),
borderRadius: DesignTokens.borderRadiusSm,
),
child: Icon(
CupertinoIcons.chat_bubble_2_fill,
size: 22,
color: const Color(0xFF07C160),
),
),
const SizedBox(width: DesignTokens.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'微信公众号',
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w600,
color: isDark
? DarkDesignTokens.text1
: DesignTokens.text1,
),
),
const SizedBox(height: 4),
GestureDetector(
onTap: () {
Clipboard.setData(const ClipboardData(text: '微风暴'));
Get.snackbar(
'复制成功',
'已复制到剪贴板',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2),
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space3,
vertical: DesignTokens.space1,
),
decoration: BoxDecoration(
color: const Color(0xFF07C160).withValues(alpha: 0.1),
borderRadius: DesignTokens.borderRadiusFull,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
CupertinoIcons.search,
size: 14,
color: Color(0xFF07C160),
),
const SizedBox(width: DesignTokens.space2),
const Text(
'微风暴',
style: TextStyle(
fontSize: DesignTokens.fontSm,
fontWeight: FontWeight.w600,
color: Color(0xFF07C160),
),
),
const SizedBox(width: DesignTokens.space2),
Icon(
CupertinoIcons.doc_on_clipboard,
size: 14,
color: const Color(
0xFF07C160,
).withValues(alpha: 0.8),
),
],
),
),
),
],
),
),
],
),
),
],
);
}
/// 团队区域
Widget _buildTeamSection(bool isDark) {
return _buildSection(
title: '👥 团队信息',
isDark: isDark,
children: [
_buildTeamMember('💻', '程序设计', '纯情小妈', '喜欢发呆', isDark),
_buildDivider(isDark),
_buildTeamMember('🎨', 'UI/UX/Testing', 'Freetime', '关于你的风景。', isDark),
_buildDivider(isDark),
_buildTeamMember('⚙️', '后端开发', '伯乐不相马', '还是做不到吗?', isDark),
_buildDivider(isDark),
_buildTeamMember('🔧', '技术支持', 'Ayk', '吾友随贱,其寿似龟', isDark),
],
);
}
/// ICP备案区域
Widget _buildIcpSection(bool isDark) {
return _buildSection(
title: '📋 ICP备案信息',
isDark: isDark,
children: [
GestureDetector(
onTap: () {
Clipboard.setData(
const ClipboardData(text: '滇ICP备2022000863号-15A'),
);
Get.snackbar(
'复制成功',
'备案号已复制到剪贴板',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 1),
);
},
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4,
vertical: DesignTokens.space3,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'滇ICP备2022000863号-15A',
style: TextStyle(
fontSize: DesignTokens.fontMd,
color: isDark
? DarkDesignTokens.text2
: DesignTokens.text2,
decoration: TextDecoration.underline,
decorationColor: isDark
? DarkDesignTokens.text3
: DesignTokens.text3,
),
),
const SizedBox(height: 2),
Text(
'APP核准备案号',
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: isDark
? DarkDesignTokens.text3
: DesignTokens.text3,
),
),
],
),
),
Icon(
CupertinoIcons.doc_on_clipboard,
size: 18,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
],
),
),
),
],
);
}
/// 通用区域容器
Widget _buildSection({
required String title,
required bool isDark,
required List<Widget> children,
}) {
return Container(
decoration: BoxDecoration(
color: isDark ? DarkDesignTokens.card : DesignTokens.card,
borderRadius: DesignTokens.borderRadiusLg,
boxShadow: DesignTokens.shadowsSm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
DesignTokens.space4,
DesignTokens.space3,
DesignTokens.space4,
DesignTokens.space1,
),
child: Text(
title,
style: TextStyle(
fontSize: DesignTokens.fontXs,
fontWeight: FontWeight.w600,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
),
),
...children,
],
),
);
}
/// 链接项
Widget _buildLinkTile({
required IconData icon,
required String label,
required String url,
required bool isDark,
}) {
return GestureDetector(
onTap: () => _showLaunchDialog(url, label, isDark),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4,
vertical: DesignTokens.space3,
),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: DesignTokens.dynamicPrimary.withValues(alpha: 0.12),
borderRadius: DesignTokens.borderRadiusSm,
),
child: Icon(icon, size: 20, color: DesignTokens.dynamicPrimary),
),
const SizedBox(width: DesignTokens.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w500,
color: isDark
? DarkDesignTokens.text1
: DesignTokens.text1,
),
),
const SizedBox(height: 2),
Text(
url,
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: DesignTokens.dynamicPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: url));
Get.snackbar(
'复制成功',
'链接已复制到剪贴板',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2),
);
},
child: Container(
padding: const EdgeInsets.all(DesignTokens.space1),
decoration: BoxDecoration(
color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1),
borderRadius: DesignTokens.borderRadiusSm,
),
child: Icon(
CupertinoIcons.doc_on_clipboard,
size: 16,
color: DesignTokens.dynamicPrimary,
),
),
),
const SizedBox(width: DesignTokens.space2),
Icon(
CupertinoIcons.arrow_up_right_square,
size: 18,
color: DesignTokens.dynamicPrimary,
),
],
),
],
),
),
);
}
/// 可复制的文本项
Widget _buildCopyTile({
required IconData icon,
required String title,
required String value,
required bool isDark,
}) {
return GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: value));
Get.snackbar(
'复制成功',
'已复制到剪贴板',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2),
);
},
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4,
vertical: DesignTokens.space3,
),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: DesignTokens.blue.withValues(alpha: 0.12),
borderRadius: DesignTokens.borderRadiusSm,
),
child: Icon(icon, size: 20, color: DesignTokens.blue),
),
const SizedBox(width: DesignTokens.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w500,
color: isDark
? DarkDesignTokens.text1
: DesignTokens.text1,
),
),
const SizedBox(height: 2),
Text(
value,
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: DesignTokens.blue,
),
),
],
),
),
Icon(
CupertinoIcons.doc_on_clipboard,
size: 18,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
],
),
),
);
}
/// 团队成员项
Widget _buildTeamMember(
String emoji,
String role,
String name,
String signature,
bool isDark,
) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4,
vertical: DesignTokens.space3,
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: isDark
? DarkDesignTokens.card.withValues(alpha: 0.5)
: DesignTokens.background,
borderRadius: DesignTokens.borderRadiusSm,
),
child: Center(
child: Text(emoji, style: const TextStyle(fontSize: 20)),
),
),
const SizedBox(width: DesignTokens.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
role,
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w600,
color: isDark
? DarkDesignTokens.text1
: DesignTokens.text1,
),
),
const SizedBox(width: DesignTokens.space2),
Container(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space2,
vertical: 2,
),
decoration: BoxDecoration(
color: DesignTokens.dynamicPrimary.withValues(
alpha: 0.1,
),
borderRadius: DesignTokens.borderRadiusFull,
),
child: Text(
name,
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: DesignTokens.dynamicPrimary,
),
),
),
],
),
const SizedBox(height: 2),
Text(
signature,
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
),
],
),
),
],
),
);
}
/// 分割线
Widget _buildDivider(bool isDark) {
return Padding(
padding: const EdgeInsets.only(
left: DesignTokens.space4 + 36 + DesignTokens.space3,
),
child: Divider(
height: 1,
color: isDark
? DarkDesignTokens.text3.withValues(alpha: 0.1)
: DesignTokens.text3.withValues(alpha: 0.1),
),
);
}
/// 底部指示器
Widget _buildBottomIndicator(bool isDark) {
return Container(
padding: const EdgeInsets.symmetric(vertical: DesignTokens.space5),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 40,
height: 1,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4,
),
child: Text(
'到底了',
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
),
),
Container(
width: 40,
height: 1,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
],
),
);
}
/// 打开链接确认弹窗
void _showLaunchDialog(String url, String label, bool isDark) {
Get.dialog(
CupertinoAlertDialog(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
CupertinoIcons.arrow_up_right_square,
color: DesignTokens.dynamicPrimary,
size: 20,
),
const SizedBox(width: DesignTokens.space2),
const Text('打开链接'),
],
),
content: Column(
children: [
const SizedBox(height: DesignTokens.space3),
Text(
'即将离开应用,在浏览器中打开:',
style: TextStyle(
fontSize: DesignTokens.fontMd,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
height: 1.5,
),
),
const SizedBox(height: DesignTokens.space2),
Text(
label,
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w500,
color: DesignTokens.dynamicPrimary,
),
),
const SizedBox(height: DesignTokens.space1),
Text(
url,
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
),
],
),
actions: [
CupertinoDialogAction(
onPressed: () {
Clipboard.setData(ClipboardData(text: url));
Get.back();
Get.snackbar(
'复制成功',
'链接已复制到剪贴板',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2),
);
},
child: const Text('复制链接'),
),
CupertinoDialogAction(
isDestructiveAction: true,
onPressed: () => Get.back(),
child: const Text('取消'),
),
CupertinoDialogAction(
isDefaultAction: true,
onPressed: () {
Get.back();
_launchUrl(url);
},
child: const Text('前往'),
),
],
),
);
}
/// 启动URL
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
Get.snackbar(
'打开失败',
'无法打开链接,请手动复制后在浏览器中访问',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2),
);
}
}
}

View File

@@ -280,13 +280,13 @@ class ProfileHomeTab extends StatelessWidget {
),
_FeatureItem(
CupertinoIcons.cart,
'购物清单',
'分享记录',
DesignTokens.green,
AppRoutes.shoppingList,
),
_FeatureItem(
CupertinoIcons.bookmark,
'收藏',
'评分记录',
DesignTokens.secondary,
AppRoutes.favorites,
),

View File

@@ -3,7 +3,7 @@
* 名称: 参考文献页面
* 作用: 展示健康饮食相关的权威参考文献链接
* 创建时间: 2026-04-13
* 更新时间: 2026-04-13
* 更新时间: 2026-04-18 根据软件功能增加参考文献,替换无法访问的链接
*/
import 'package:flutter/cupertino.dart';
@@ -35,58 +35,135 @@ class ReferencesPage extends StatelessWidget {
ReferenceItem(
title: '减盐',
description: '世界卫生组织关于减少钠摄入的事实说明,介绍钠摄入过量的健康风险及减盐策略。',
url: 'https://www.who.int/zh/news-room/fact-sheets/detail/sodium-reduction',
url:
'https://www.who.int/zh/news-room/fact-sheets/detail/sodium-reduction',
source: '世界卫生组织 WHO',
category: '营养健康',
category: '🥗 营养健康',
),
ReferenceItem(
title: '健康饮食',
description: '世界卫生组织健康饮食指南,涵盖均衡膳食、营养素摄入建议及饮食原则。',
url: 'https://www.who.int/zh/news-room/fact-sheets/detail/healthy-diet',
source: '世界卫生组织 WHO',
category: '营养健康',
category: '🥗 营养健康',
),
ReferenceItem(
title: '成人和儿童糖摄入量指南',
description: 'WHO关于成人和儿童糖摄入量的官方指南建议游离糖摄入量控制在总能量的10%以下。',
url: 'https://www.who.int/publications/i/item/9789241549028',
source: '世界卫生组织 WHO',
category: '营养健康',
category: '🥗 营养健康',
),
ReferenceItem(
title: '微量营养素缺乏',
description: 'WHO关于维生素和矿物质缺乏的信息介绍碘、维生素A、铁等微量营养素缺乏的影响。',
url:
'https://www.who.int/zh/news-room/fact-sheets/detail/micronutrient-deficiencies',
source: '世界卫生组织 WHO',
category: '🥗 营养健康',
),
ReferenceItem(
title: '身体活动和久坐行为指南',
description: 'WHO关于身体活动的建议介绍不同年龄段人群的运动量推荐及久坐的健康风险。',
url: 'https://www.who.int/publications/i/item/9789240015128',
source: '世界卫生组织 WHO',
category: '生活方式',
),
ReferenceItem(
title: '中国居民膳食指南(2022)',
description: '中国营养学会发布的官方膳食指南,针对中国居民的饮食特点和营养需求提供建议。',
url: 'https://www.cnsoc.org/',
source: '中国营养学会',
category: '膳食指南',
category: '🏃 生活方式',
),
ReferenceItem(
title: '食品安全五大要点',
description: 'WHO发布的食品安全基本准则包括保持清洁、生熟分开、烧熟煮透等关键要点。',
url: 'https://www.who.int/zh/news-room/fact-sheets/detail/food-safety',
source: '世界卫生组织 WHO',
category: '食品安全',
category: '🛡️ 食品安全',
),
ReferenceItem(
title: '微量营养素缺乏',
description: 'WHO关于维生素和矿物质缺乏的信息介绍碘、维生素A、铁等微量营养素缺乏的影响',
url: 'https://www.who.int/zh/news-room/fact-sheets/detail/micronutrient-deficiencies',
source: '世界卫生组织 WHO',
category: '营养健康',
title: '中国居民膳食指南(2022)',
description: '中国营养学会发布的官方膳食指南,针对中国居民的饮食特点和营养需求提供建议',
url: 'https://www.cnsoc.org/',
source: '中国营养学会',
category: '📋 膳食指南',
),
ReferenceItem(
title: '中国食物成分表',
description: '中国疾病预防控制中心营养与健康所发布的食物营养成分数据库,提供权威的食物营养数据。',
url: 'https://cdc.chinacdc.cn/',
source: '中国疾控中心',
category: '数据来源',
category: '📊 数据来源',
),
ReferenceItem(
title: 'BMI分类标准',
description: 'WHO关于成人体重指数(BMI)的分类标准,用于评估体重是否在健康范围内。',
url:
'https://www.who.int/zh/news-room/fact-sheets/detail/obesity-and-overweight',
source: '世界卫生组织 WHO',
category: '⚖️ 体重管理',
),
ReferenceItem(
title: '食物过敏与不耐受',
description: '世界过敏组织关于食物过敏的指南,介绍常见过敏原、症状识别及应对措施。',
url:
'https://www.worldallergy.org/education-and-programs/education/allergic-disease-resource-center/professional/food-allergy',
source: '世界过敏组织 WAO',
category: '⚠️ 过敏原',
),
ReferenceItem(
title: '中国食物过敏指南',
description: '中华医学会发布的食物过敏相关诊疗指南,涵盖常见致敏食物及临床管理建议。',
url: 'https://www.cma.org.cn/',
source: '中华医学会',
category: '⚠️ 过敏原',
),
ReferenceItem(
title: '孕期和哺乳期膳食指南',
description: '中国营养学会针对孕产妇的营养建议,包括叶酸、铁、钙等关键营养素的补充指导。',
url: 'https://www.cnsoc.org/',
source: '中国营养学会',
category: '🤰 特殊人群',
),
ReferenceItem(
title: '婴幼儿喂养指南',
description: 'WHO关于婴幼儿辅食添加和喂养的全球策略推荐纯母乳喂养6个月后逐步添加辅食。',
url:
'https://www.who.int/zh/news-room/fact-sheets/detail/infant-and-young-child-feeding',
source: '世界卫生组织 WHO',
category: '👶 特殊人群',
),
ReferenceItem(
title: '老年人营养指南',
description: 'WHO关于老年人营养的建议关注蛋白质、维生素D、钙等营养素的充足摄入。',
url:
'https://www.who.int/zh/news-room/fact-sheets/detail/ageing-and-health',
source: '世界卫生组织 WHO',
category: '👴 特殊人群',
),
ReferenceItem(
title: '烹饪温度与食品安全',
description: '美国FDA关于安全烹饪温度的指南不同食材需达到的最低内部温度以杀灭有害微生物。',
url:
'https://www.fda.gov/food/people-risk-foodborne-illness/meat-poultry-seafood-food-safety-moms-be',
source: '美国FDA',
category: '🍳 烹饪安全',
),
ReferenceItem(
title: '中国居民膳食营养素参考摄入量',
description: '中国营养学会发布的DRIs涵盖能量、蛋白质、维生素、矿物质等各类营养素的推荐摄入量。',
url: 'https://www.cnsoc.org/',
source: '中国营养学会',
category: '📊 数据来源',
),
ReferenceItem(
title: '食物升糖指数(GI)数据库',
description: '悉尼大学国际GI数据库提供各类食物的血糖生成指数辅助糖尿病患者和健康人群选择食物。',
url: 'https://glycemicindex.com/',
source: '悉尼大学',
category: '📊 数据来源',
),
ReferenceItem(
title: '中国慢性病防控指南',
description: '中国疾控中心关于高血压、糖尿病等慢性病的膳食防控建议,强调减盐、控油、限糖。',
url: 'https://www.chinacdc.cn/',
source: '中国疾控中心',
category: '🏥 慢病防控',
),
];
@@ -95,7 +172,9 @@ class ReferencesPage extends StatelessWidget {
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
return CupertinoPageScaffold(
backgroundColor: isDark ? DarkDesignTokens.background : DesignTokens.background,
backgroundColor: isDark
? DarkDesignTokens.background
: DesignTokens.background,
navigationBar: CupertinoNavigationBar(
middle: Text(
'参考文献',

View File

@@ -1,62 +1,91 @@
/*
* 文件: preference_page.dart
* 说明: 用户偏好设置页面。管理口味偏好分类、标签和过敏原屏蔽。
* 作用: iOS风格设置页面支持分类/标签/过敏原的开关切换。
* 作者: 前端工程师
* 更新时间: 2026-04-09
* 上次更新: 新建偏好设置页面
* 名称: 偏好设置页面
* 作用: 管理用户口味偏好、饮食类型、烹饪水平、健康目标、过敏原屏蔽等
* 创建时间: 2026-04-09
* 更新时间: 2026-04-18 接入TastePreferenceService持久化偏好值写入SharedPreferences
*/
import 'package:flutter/cupertino.dart';
import 'package:get/get.dart';
import 'package:mom_kitchen/src/controllers/user/preference_controller.dart';
import 'package:mom_kitchen/src/models/user/user_preference_model.dart';
import 'package:mom_kitchen/src/services/ui/theme_service.dart';
import 'package:mom_kitchen/src/config/design_tokens.dart';
import 'package:mom_kitchen/src/services/user/taste_preference_service.dart';
class PreferencePage extends StatelessWidget {
class PreferencePage extends StatefulWidget {
const PreferencePage({super.key});
@override
State<PreferencePage> createState() => _PreferencePageState();
}
class _PreferencePageState extends State<PreferencePage> {
final PreferenceController _controller = Get.find<PreferenceController>();
final TastePreferenceService _tasteService = Get.find<TastePreferenceService>();
@override
Widget build(BuildContext context) {
final themeService = Get.find<ThemeService>();
final prefController = Get.find<PreferenceController>();
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
return CupertinoPageScaffold(
backgroundColor: isDark
? DarkDesignTokens.background
: DesignTokens.background,
navigationBar: CupertinoNavigationBar(
middle: Text('口味偏好 🍽️'),
middle: Text(
'口味偏好 🍽️',
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w600,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
backgroundColor: isDark
? DarkDesignTokens.glass.withValues(alpha: 0.8)
: DesignTokens.glass.withValues(alpha: 0.8),
border: null,
trailing: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => _showClearConfirm(context, prefController),
onPressed: () => _showClearConfirm(context, isDark),
child: Text(
'重置',
style: TextStyle(
fontSize: 14,
color: themeService.primaryColor.value,
fontSize: DesignTokens.fontSm,
color: DesignTokens.dynamicPrimary,
),
),
),
),
child: SafeArea(
child: Obx(() {
if (prefController.isLoading.value &&
prefController.preference.value == null) {
if (_controller.isLoading.value &&
_controller.preference.value == null) {
return const Center(child: CupertinoActivityIndicator(radius: 20));
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 20),
padding: const EdgeInsets.all(DesignTokens.space4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('📂 偏好分类', '选择你喜欢的菜系分类', themeService),
_buildCategorySection(prefController, themeService),
const SizedBox(height: 24),
_buildSectionHeader('🏷️ 偏好标签', '选择你感兴趣的标签', themeService),
_buildTagSection(prefController, themeService),
const SizedBox(height: 24),
_buildSectionHeader('⚠️ 过敏原屏蔽', '屏蔽含这些食材的菜谱', themeService),
_buildAllergenSection(prefController, themeService),
const SizedBox(height: 40),
_buildDietTypeCard(isDark),
const SizedBox(height: DesignTokens.space4),
_buildSpiceCard(isDark),
const SizedBox(height: DesignTokens.space4),
_buildTasteCard(isDark),
const SizedBox(height: DesignTokens.space4),
_buildCookingLevelCard(isDark),
const SizedBox(height: DesignTokens.space4),
_buildServingSizeCard(isDark),
const SizedBox(height: DesignTokens.space4),
_buildHealthGoalCard(isDark),
const SizedBox(height: DesignTokens.space4),
_buildCategoryCard(isDark),
const SizedBox(height: DesignTokens.space4),
_buildAllergenCard(isDark),
const SizedBox(height: DesignTokens.space5),
_buildSummaryCard(isDark),
const SizedBox(height: DesignTokens.space5),
],
),
);
@@ -65,198 +94,478 @@ class PreferencePage extends StatelessWidget {
);
}
Widget _buildSectionHeader(
String title,
String subtitle,
ThemeService themeService,
) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
Widget _buildCard({
required bool isDark,
required String title,
required String subtitle,
required String emoji,
required Widget child,
}) {
return Container(
padding: const EdgeInsets.all(DesignTokens.space4),
decoration: BoxDecoration(
color: isDark ? DarkDesignTokens.card : DesignTokens.card,
borderRadius: DesignTokens.borderRadiusLg,
boxShadow: DesignTokens.shadowsSm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: themeService.textColor.value,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 13,
color: themeService.textColor.value.withValues(alpha: 0.5),
),
Row(
children: [
Text(emoji, style: const TextStyle(fontSize: 20)),
const SizedBox(width: DesignTokens.space2),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w600,
color: isDark
? DarkDesignTokens.text1
: DesignTokens.text1,
),
),
Text(
subtitle,
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: isDark
? DarkDesignTokens.text3
: DesignTokens.text3,
),
),
],
),
),
],
),
const SizedBox(height: DesignTokens.space3),
child,
],
),
);
}
Widget _buildCategorySection(
PreferenceController prefController,
ThemeService themeService,
) {
return Obx(() {
final categories = prefController.availableCategories;
if (categories.isEmpty) {
return const Padding(
padding: EdgeInsets.all(20),
child: Text('暂无分类数据', style: TextStyle(fontSize: 14)),
);
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: categories.map((cat) {
return _buildCategoryGroup(cat, prefController, themeService);
}).toList(),
),
);
});
Widget _buildDietTypeCard(bool isDark) {
return _buildCard(
isDark: isDark,
title: '饮食类型',
subtitle: '选择你的饮食方式',
emoji: '🥗',
child: Obx(() => Wrap(
spacing: DesignTokens.space2,
runSpacing: DesignTokens.space2,
children: DietType.values.map((type) {
final isSelected = _tasteService.dietType.value == type;
return _buildSelectionChip(
label: '${type.emoji} ${type.label}',
isSelected: isSelected,
isDark: isDark,
onTap: () => _tasteService.saveDietType(type),
);
}).toList(),
)),
);
}
Widget _buildCategoryGroup(
PreferenceCategory cat,
PreferenceController prefController,
ThemeService themeService,
Widget _buildSpiceCard(bool isDark) {
return _buildCard(
isDark: isDark,
title: '辣度偏好',
subtitle: '选择你能接受的辣度',
emoji: '🌶️',
child: Obx(() => Wrap(
spacing: DesignTokens.space2,
runSpacing: DesignTokens.space2,
children: SpiceLevel.values.map((level) {
final isSelected = _tasteService.spiceLevel.value == level;
return _buildSelectionChip(
label: '${level.emoji} ${level.label}',
isSelected: isSelected,
isDark: isDark,
onTap: () => _tasteService.saveSpiceLevel(level),
);
}).toList(),
)),
);
}
Widget _buildTasteCard(bool isDark) {
return _buildCard(
isDark: isDark,
title: '口味偏好',
subtitle: '调节你偏好的口味程度',
emoji: '👅',
child: Obx(() => Column(
children: [
_buildTasteSlider(
'甜度',
'🍬',
_tasteService.sweetness.value,
(v) => _tasteService.saveSweetness(v),
isDark,
),
const SizedBox(height: DesignTokens.space3),
_buildTasteSlider(
'咸度',
'🧂',
_tasteService.saltiness.value,
(v) => _tasteService.saveSaltiness(v),
isDark,
),
const SizedBox(height: DesignTokens.space3),
_buildTasteSlider(
'酸度',
'🍋',
_tasteService.sourness.value,
(v) => _tasteService.saveSourness(v),
isDark,
),
],
)),
);
}
Widget _buildTasteSlider(
String label,
String emoji,
double value,
ValueChanged<double> onChanged,
bool isDark,
) {
final isSelected = prefController.isCategoryPreferred(cat.id);
return Row(
children: [
Text(emoji, style: const TextStyle(fontSize: 16)),
const SizedBox(width: DesignTokens.space2),
SizedBox(
width: 36,
child: Text(
label,
style: TextStyle(
fontSize: DesignTokens.fontXs,
fontWeight: FontWeight.w500,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
),
),
),
Expanded(
child: CupertinoSlider(
value: value,
onChanged: onChanged,
activeColor: DesignTokens.dynamicPrimary,
min: 0,
max: 1,
divisions: 10,
),
),
SizedBox(
width: 32,
child: Text(
'${(value * 100).round()}%',
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
textAlign: TextAlign.end,
),
),
],
);
}
Widget _buildCookingLevelCard(bool isDark) {
return _buildCard(
isDark: isDark,
title: '烹饪水平',
subtitle: '帮助我们推荐适合的菜谱难度',
emoji: '👨‍🍳',
child: Obx(() => Wrap(
spacing: DesignTokens.space2,
runSpacing: DesignTokens.space2,
children: CookingLevel.values.map((level) {
final isSelected = _tasteService.cookingLevel.value == level;
return _buildSelectionChip(
label: '${level.emoji} ${level.label}',
isSelected: isSelected,
isDark: isDark,
onTap: () => _tasteService.saveCookingLevel(level),
);
}).toList(),
)),
);
}
Widget _buildServingSizeCard(bool isDark) {
return _buildCard(
isDark: isDark,
title: '每餐人数',
subtitle: '默认份量参考',
emoji: '🍚',
child: Obx(() => Wrap(
spacing: DesignTokens.space2,
runSpacing: DesignTokens.space2,
children: ServingSize.values.map((size) {
final isSelected = _tasteService.servingSize.value == size;
return _buildSelectionChip(
label: '${size.emoji} ${size.label}',
isSelected: isSelected,
isDark: isDark,
onTap: () => _tasteService.saveServingSize(size),
);
}).toList(),
)),
);
}
Widget _buildHealthGoalCard(bool isDark) {
return _buildCard(
isDark: isDark,
title: '健康目标',
subtitle: '可多选,影响营养推荐',
emoji: '🎯',
child: Obx(() => Wrap(
spacing: DesignTokens.space2,
runSpacing: DesignTokens.space2,
children: HealthGoal.values.map((goal) {
final isSelected = _tasteService.healthGoals.contains(goal);
return _buildSelectionChip(
label: '${goal.emoji} ${goal.label}',
isSelected: isSelected,
isDark: isDark,
isMultiSelect: true,
onTap: () {
final goals = Set<HealthGoal>.from(_tasteService.healthGoals);
if (isSelected) {
goals.remove(goal);
} else {
goals.add(goal);
}
_tasteService.saveHealthGoals(goals);
},
);
}).toList(),
)),
);
}
Widget _buildCategoryCard(bool isDark) {
return _buildCard(
isDark: isDark,
title: '偏好菜系',
subtitle: '选择你喜欢的菜系分类',
emoji: '📂',
child: Obx(() {
final categories = _controller.availableCategories;
if (categories.isEmpty) {
return Text(
'暂无分类数据',
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: categories.map((cat) {
return _buildCategoryGroup(cat, isDark);
}).toList(),
);
}),
);
}
Widget _buildCategoryGroup(PreferenceCategory cat, bool isDark) {
final isSelected = _controller.isCategoryPreferred(cat.id);
final hasChildren = cat.children.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: _buildChip(
label: '${cat.displayIcon} ${cat.name}',
icon: '',
isSelected: isSelected,
themeService: themeService,
onTap: () => prefController.toggleCategory(cat.id),
),
_buildSelectionChip(
label: '${cat.displayIcon} ${cat.name}',
isSelected: isSelected,
isDark: isDark,
onTap: () => _controller.toggleCategory(cat.id),
),
if (hasChildren)
Padding(
padding: const EdgeInsets.only(left: 20),
padding: const EdgeInsets.only(
left: DesignTokens.space5,
top: DesignTokens.space2,
),
child: Wrap(
spacing: 8,
runSpacing: 8,
spacing: DesignTokens.space2,
runSpacing: DesignTokens.space2,
children: cat.children.map((subCat) {
final isSubSelected = prefController.isCategoryPreferred(
final isSubSelected = _controller.isCategoryPreferred(
subCat.id,
);
return _buildChip(
return _buildSelectionChip(
label: '${subCat.displayIcon} ${subCat.name}',
icon: '',
isSelected: isSubSelected,
themeService: themeService,
onTap: () => prefController.toggleCategory(subCat.id),
isDark: isDark,
onTap: () => _controller.toggleCategory(subCat.id),
);
}).toList(),
),
),
const SizedBox(height: 8),
const SizedBox(height: DesignTokens.space2),
],
);
}
Widget _buildTagSection(
PreferenceController prefController,
ThemeService themeService,
) {
return Obx(() {
final availableTags = prefController.availableTags;
if (availableTags.isEmpty) {
return Padding(
padding: const EdgeInsets.all(20),
child: Text(
'暂无标签数据',
style: TextStyle(
fontSize: 14,
color: themeService.textColor.value.withValues(alpha: 0.5),
),
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: availableTags.map((tag) {
final isSelected = prefController.isTagPreferred(tag.id);
return _buildChip(
label: '🏷️ ${tag.name}',
icon: '',
isSelected: isSelected,
themeService: themeService,
onTap: () => prefController.toggleTag(tag.id),
);
}).toList(),
),
);
});
}
Widget _buildAllergenSection(
PreferenceController prefController,
ThemeService themeService,
) {
return Obx(() {
final allergens = prefController.availableAllergens;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Wrap(
spacing: 8,
runSpacing: 8,
Widget _buildAllergenCard(bool isDark) {
return _buildCard(
isDark: isDark,
title: '过敏原屏蔽',
subtitle: '屏蔽含这些食材的菜谱',
emoji: '⚠️',
child: Obx(() {
final allergens = _controller.availableAllergens;
return Wrap(
spacing: DesignTokens.space2,
runSpacing: DesignTokens.space2,
children: allergens.map((allergen) {
final isBlocked = prefController.isAllergenBlocked(allergen.type);
return _buildChip(
final isBlocked = _controller.isAllergenBlocked(allergen.type);
return _buildSelectionChip(
label: '${allergen.icon ?? '⚠️'} ${allergen.name}',
icon: '',
isSelected: isBlocked,
isDark: isDark,
isDestructive: true,
themeService: themeService,
onTap: () => prefController.toggleAllergen(allergen.type),
onTap: () => _controller.toggleAllergen(allergen.type),
);
}).toList(),
);
}),
);
}
Widget _buildSummaryCard(bool isDark) {
return Obx(() {
final diet = _tasteService.dietType.value;
final spice = _tasteService.spiceLevel.value;
final cooking = _tasteService.cookingLevel.value;
final serving = _tasteService.servingSize.value;
final goals = _tasteService.healthGoals;
final parts = <String>[];
parts.add('${diet.emoji} ${diet.label}');
parts.add('${spice.emoji} ${spice.label}');
parts.add('${cooking.emoji} ${cooking.label}');
parts.add('${serving.emoji} ${serving.label}');
if (goals.isNotEmpty) {
parts.add('🎯 ${goals.length}个目标');
}
if (_controller.blockedAllergenTypes.isNotEmpty) {
parts.add('⚠️ ${_controller.blockedAllergenTypes.length}个屏蔽');
}
return Container(
padding: const EdgeInsets.all(DesignTokens.space4),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
DesignTokens.dynamicPrimary.withValues(alpha: 0.08),
DesignTokens.dynamicPrimary.withValues(alpha: 0.03),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: DesignTokens.borderRadiusLg,
border: Border.all(
color: DesignTokens.dynamicPrimary.withValues(alpha: 0.15),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
CupertinoIcons.checkmark_seal_fill,
size: 18,
color: DesignTokens.dynamicPrimary,
),
const SizedBox(width: DesignTokens.space2),
Text(
'偏好摘要',
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w600,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
],
),
const SizedBox(height: DesignTokens.space2),
Wrap(
spacing: DesignTokens.space2,
runSpacing: DesignTokens.space1,
children: parts.map((part) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space2,
vertical: DesignTokens.space1,
),
decoration: BoxDecoration(
color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1),
borderRadius: DesignTokens.borderRadiusSm,
),
child: Text(
part,
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: DesignTokens.dynamicPrimary,
fontWeight: FontWeight.w500,
),
),
);
}).toList(),
),
],
),
);
});
}
Widget _buildChip({
Widget _buildSelectionChip({
required String label,
required String icon,
required bool isSelected,
required ThemeService themeService,
required bool isDark,
required VoidCallback onTap,
bool isDestructive = false,
bool isMultiSelect = false,
}) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space3,
vertical: DesignTokens.space2,
),
decoration: BoxDecoration(
color: isSelected
? (isDestructive
? CupertinoColors.systemRed.withValues(alpha: 0.12)
: themeService.primaryColor.value.withValues(alpha: 0.12))
: themeService.backgroundColor.value,
borderRadius: DesignTokens.borderRadiusLg,
: DesignTokens.dynamicPrimary.withValues(alpha: 0.12))
: (isDark
? DarkDesignTokens.background
: DesignTokens.background),
borderRadius: DesignTokens.borderRadiusMd,
border: Border.all(
color: isSelected
? (isDestructive
? CupertinoColors.systemRed.withValues(alpha: 0.4)
: themeService.primaryColor.value.withValues(alpha: 0.4))
: themeService.textColor.value.withValues(alpha: 0.15),
: DesignTokens.dynamicPrimary.withValues(alpha: 0.4))
: (isDark
? DarkDesignTokens.text3.withValues(alpha: 0.2)
: DesignTokens.text3.withValues(alpha: 0.15)),
width: 1,
),
),
@@ -265,27 +574,29 @@ class PreferencePage extends StatelessWidget {
children: [
if (isSelected)
Padding(
padding: const EdgeInsets.only(right: 4),
padding: const EdgeInsets.only(right: DesignTokens.space1),
child: Icon(
isDestructive
? CupertinoIcons.shield_fill
: CupertinoIcons.checkmark_circle_fill,
: (isMultiSelect
? CupertinoIcons.checkmark_square_fill
: CupertinoIcons.checkmark_circle_fill),
size: 14,
color: isDestructive
? CupertinoColors.systemRed
: themeService.primaryColor.value,
: DesignTokens.dynamicPrimary,
),
),
Text(
icon.isNotEmpty ? '$icon $label' : label,
label,
style: TextStyle(
fontSize: 13,
fontSize: DesignTokens.fontSm,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected
? (isDestructive
? CupertinoColors.systemRed
: themeService.primaryColor.value)
: themeService.textColor.value.withValues(alpha: 0.7),
: DesignTokens.dynamicPrimary)
: (isDark ? DarkDesignTokens.text2 : DesignTokens.text2),
),
),
],
@@ -294,15 +605,12 @@ class PreferencePage extends StatelessWidget {
);
}
void _showClearConfirm(
BuildContext context,
PreferenceController prefController,
) {
void _showClearConfirm(BuildContext context, bool isDark) {
showCupertinoDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
title: const Text('重置偏好'),
content: const Text('确定要清除所有口味偏好设置吗?'),
content: const Text('确定要清除所有口味偏好设置吗?此操作不可撤销。'),
actions: [
CupertinoDialogAction(
isDefaultAction: true,
@@ -313,7 +621,8 @@ class PreferencePage extends StatelessWidget {
isDestructiveAction: true,
onPressed: () {
Navigator.pop(context);
prefController.clearAll();
_tasteService.clearAll();
_controller.clearAll();
},
child: const Text('重置'),
),

View File

@@ -112,7 +112,7 @@ class _FavoritesPageState extends State<FavoritesPage>
children: [
CustomScrollView(
controller: _scrollController,
physics: const ClampingScrollPhysics(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
slivers: [
@@ -1166,7 +1166,7 @@ class _ScrollEndIndicatorState extends State<_ScrollEndIndicator> {
void _onScroll() {
final isVisible =
widget.scrollController.position.pixels >=
widget.scrollController.position.maxScrollExtent - 50;
widget.scrollController.position.maxScrollExtent - 80;
if (_isVisible != isVisible) {
setState(() => _isVisible = isVisible);
}
@@ -1177,19 +1177,56 @@ class _ScrollEndIndicatorState extends State<_ScrollEndIndicator> {
if (!_isVisible) return const SizedBox(height: 20);
return Container(
padding: const EdgeInsets.symmetric(vertical: DesignTokens.space3),
padding: const EdgeInsets.symmetric(vertical: DesignTokens.space5),
alignment: Alignment.center,
child: Row(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 30,
height: 3,
decoration: BoxDecoration(
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 24,
height: 1.5,
decoration: BoxDecoration(
color: widget.isDark
? DarkDesignTokens.text3.withValues(alpha: 0.4)
: DesignTokens.text3.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(1),
),
),
const SizedBox(width: DesignTokens.space3),
Text(
'到底了',
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: widget.isDark
? DarkDesignTokens.text3
: DesignTokens.text3,
fontWeight: FontWeight.w400,
),
),
const SizedBox(width: DesignTokens.space3),
Container(
width: 24,
height: 1.5,
decoration: BoxDecoration(
color: widget.isDark
? DarkDesignTokens.text3.withValues(alpha: 0.4)
: DesignTokens.text3.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(1),
),
),
],
),
const SizedBox(height: DesignTokens.space1),
Text(
'❤️ 已展示全部收藏',
style: TextStyle(
fontSize: 10,
color: widget.isDark
? DarkDesignTokens.text3
: DesignTokens.text3.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
? DarkDesignTokens.text3.withValues(alpha: 0.6)
: DesignTokens.text3.withValues(alpha: 0.6),
),
),
],

View File

@@ -34,7 +34,9 @@ class _FarmShopPageState extends State<FarmShopPage> {
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
return CupertinoPageScaffold(
backgroundColor: isDark ? DarkDesignTokens.background : DesignTokens.background,
backgroundColor: isDark
? DarkDesignTokens.background
: DesignTokens.background,
child: SafeArea(
child: Column(
children: [
@@ -62,8 +64,11 @@ class _FarmShopPageState extends State<FarmShopPage> {
: DesignTokens.text3.withValues(alpha: 0.08),
borderRadius: DesignTokens.borderRadiusMd,
),
child: Icon(CupertinoIcons.back, size: 20,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1),
child: Icon(
CupertinoIcons.back,
size: 20,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
),
const SizedBox(width: DesignTokens.space3),
@@ -79,13 +84,17 @@ class _FarmShopPageState extends State<FarmShopPage> {
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
Obx(() => Text(
'💰 ${_gameController.player.value.gold} 金币',
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
)),
Obx(
() => Text(
'💰 ${_gameController.player.value.gold} 金币',
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark
? DarkDesignTokens.text3
: DesignTokens.text3,
),
),
),
],
),
),
@@ -103,7 +112,8 @@ class _FarmShopPageState extends State<FarmShopPage> {
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final crop = crops[index];
final isUnlocked = _gameController.player.value.unlockedCrops.contains(crop.id);
final isUnlocked = _gameController.player.value.unlockedCrops
.contains(crop.id);
return _buildCropCard(crop, isUnlocked, isDark);
},
);
@@ -123,86 +133,96 @@ class _FarmShopPageState extends State<FarmShopPage> {
),
boxShadow: DesignTokens.shadowsMd,
),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1),
borderRadius: DesignTokens.borderRadiusMd,
),
child: Center(
child: Text(crop.emoji, style: const TextStyle(fontSize: 36)),
),
),
const SizedBox(width: DesignTokens.space4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
crop.name,
style: TextStyle(
fontSize: DesignTokens.fontLg,
fontWeight: FontWeight.w600,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
if (!isUnlocked) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: DesignTokens.orange.withValues(alpha: 0.2),
borderRadius: DesignTokens.borderRadiusSm,
),
child: Text(
'Lv.${crop.unlockLevel} 解锁',
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: DesignTokens.orange,
),
),
),
],
],
Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1),
borderRadius: DesignTokens.borderRadiusMd,
),
const SizedBox(height: 8),
Row(
children: [
_buildInfoTag('⏱️', '${crop.growthTime} 分钟', isDark),
const SizedBox(width: 8),
_buildInfoTag('💰', '收获 ${crop.harvestPrice}', isDark),
const SizedBox(width: 8),
_buildInfoTag('', '+${crop.harvestExp} EXP', isDark),
],
),
],
),
),
const SizedBox(width: DesignTokens.space3),
SizedBox(
width: 80,
child: CupertinoButton(
padding: EdgeInsets.zero,
color: isUnlocked ? DesignTokens.dynamicPrimary : DesignTokens.text3,
borderRadius: DesignTokens.borderRadiusMd,
onPressed: isUnlocked ? () => _shopController.buySeed(crop.id) : null,
child: Text(
'${crop.seedPrice}💰',
style: const TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w600,
color: CupertinoColors.white,
child: Center(
child: Text(crop.emoji, style: const TextStyle(fontSize: 28)),
),
),
),
const SizedBox(width: DesignTokens.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
crop.name,
style: TextStyle(
fontSize: DesignTokens.fontLg,
fontWeight: FontWeight.w600,
color: isDark
? DarkDesignTokens.text1
: DesignTokens.text1,
),
),
if (!isUnlocked) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: DesignTokens.orange.withValues(alpha: 0.2),
borderRadius: DesignTokens.borderRadiusSm,
),
child: Text(
'Lv.${crop.unlockLevel} 解锁',
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: DesignTokens.orange,
),
),
),
],
],
),
],
),
),
SizedBox(
width: 80,
child: CupertinoButton(
padding: EdgeInsets.zero,
color: isUnlocked
? DesignTokens.dynamicPrimary
: DesignTokens.text3,
borderRadius: DesignTokens.borderRadiusMd,
onPressed: isUnlocked
? () => _shopController.buySeed(crop.id)
: null,
child: Text(
'${crop.seedPrice}💰',
style: const TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w600,
color: CupertinoColors.white,
),
),
),
),
],
),
const SizedBox(height: DesignTokens.space2),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
_buildInfoTag('⏱️', '${crop.growthTime}分钟', isDark),
_buildInfoTag('💰', '收获${crop.harvestPrice}', isDark),
_buildInfoTag('', '+${crop.harvestExp}EXP', isDark),
],
),
],
),

View File

@@ -239,8 +239,8 @@ class DataExportService extends GetxService {
if (kIsWeb) return;
await Share.shareXFiles(
[XFile(filePath)],
subject: '妈厨房 - 数据导出',
text: '妈厨房导出的数据',
subject: '妈厨房 - 数据导出',
text: '妈厨房导出的数据',
);
}

View File

@@ -0,0 +1,109 @@
/*
* 文件: recipe_share_service.dart
* 名称: 菜谱分享数据推送服务
* 作用: 将菜谱数据推送到 recipe_share.php 本地存储,供扫码后展示
* 创建: 2026-04-18
* 更新: 2026-04-18 使用 toJson() 传输完整菜谱数据
*/
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:mom_kitchen/src/config/api_config.dart';
import 'package:mom_kitchen/src/models/recipe/recipe_model.dart';
/// 菜谱分享数据推送服务
class RecipeShareService {
static final RecipeShareService _instance = RecipeShareService._internal();
factory RecipeShareService() => _instance;
RecipeShareService._internal();
final Dio _dio = Dio();
/// 推送菜谱数据到分享页面
/// 返回分享URL
Future<String?> pushRecipeToShare(RecipeModel recipe) async {
try {
final response = await _dio.post(
'${ApiConfig.baseUrl}/kitchen/recipe_share.php',
queryParameters: {'act': 'create'},
data: recipe.toJson(),
options: Options(
contentType: 'application/json',
responseType: ResponseType.json,
),
);
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
if (data['code'] == 200) {
debugPrint('✅ 菜谱分享数据推送成功: ${recipe.title}');
return _buildShareUrl(recipe);
} else {
debugPrint('❌ 菜谱分享数据推送失败: ${data['message']}');
}
}
} catch (e) {
debugPrint('❌ 菜谱分享数据推送异常: $e');
}
return null;
}
/// 更新菜谱分享数据
Future<bool> updateRecipeShare(RecipeModel recipe) async {
try {
final response = await _dio.post(
'${ApiConfig.baseUrl}/kitchen/recipe_share.php',
queryParameters: {'act': 'update'},
data: recipe.toJson(),
options: Options(
contentType: 'application/json',
responseType: ResponseType.json,
),
);
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
if (data['code'] == 200) {
debugPrint('✅ 菜谱分享数据更新成功: ${recipe.title}');
return true;
} else {
debugPrint('❌ 菜谱分享数据更新失败: ${data['message']}');
}
}
} catch (e) {
debugPrint('❌ 菜谱分享数据更新异常: $e');
}
return false;
}
/// 删除菜谱分享数据
Future<bool> deleteRecipeShare(int recipeId) async {
try {
final response = await _dio.get(
'${ApiConfig.baseUrl}/kitchen/recipe_share.php',
queryParameters: {'act': 'delete', 'id': recipeId},
);
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
if (data['code'] == 200) {
debugPrint('✅ 菜谱分享数据删除成功: ID=$recipeId');
return true;
} else {
debugPrint('❌ 菜谱分享数据删除失败: ${data['message']}');
}
}
} catch (e) {
debugPrint('❌ 菜谱分享数据删除异常: $e');
}
return false;
}
/// 构建分享URL
String _buildShareUrl(RecipeModel recipe) {
if (recipe.code != null && recipe.code!.isNotEmpty) {
return '${ApiConfig.baseUrl}/kitchen/recipe_share.php?code=${Uri.encodeComponent(recipe.code!)}';
}
return '${ApiConfig.baseUrl}/kitchen/recipe_share.php?id=${recipe.id}';
}
}

View File

@@ -0,0 +1,324 @@
/*
* 文件: taste_preference_service.dart
* 名称: 口味偏好持久化服务
* 作用: 将用户口味偏好(饮食类型/辣度/口味/烹饪水平/人数/健康目标)持久化到 SharedPreferences
* 创建时间: 2026-04-18
* 更新时间: 2026-04-18 初始实现
*/
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
enum DietType {
normal('普通饮食', '🍽️'),
vegetarian('素食', '🥬'),
vegan('纯素', '🌱'),
lowCarb('低碳水', '🥑'),
keto('生酮', '🥩'),
mediterranean('地中海', '🫒'),
halal('清真', '🕌');
final String label;
final String emoji;
const DietType(this.label, this.emoji);
static DietType fromName(String? name) {
if (name == null) return DietType.normal;
return DietType.values.firstWhere(
(e) => e.name == name,
orElse: () => DietType.normal,
);
}
}
enum CookingLevel {
beginner('厨房小白', '👶'),
entry('入门选手', '🍳'),
skilled('熟练厨师', '👨‍🍳'),
master('厨艺大师', '👑');
final String label;
final String emoji;
const CookingLevel(this.label, this.emoji);
static CookingLevel fromName(String? name) {
if (name == null) return CookingLevel.beginner;
return CookingLevel.values.firstWhere(
(e) => e.name == name,
orElse: () => CookingLevel.beginner,
);
}
}
enum ServingSize {
solo('1-2人', '👫'),
family('3-4人', '👨‍👩‍👧'),
party('5人+', '🎉');
final String label;
final String emoji;
const ServingSize(this.label, this.emoji);
static ServingSize fromName(String? name) {
if (name == null) return ServingSize.solo;
return ServingSize.values.firstWhere(
(e) => e.name == name,
orElse: () => ServingSize.solo,
);
}
}
enum HealthGoal {
loseFat('减脂瘦身', '🏃'),
buildMuscle('增肌塑形', '💪'),
maintain('维持体重', '⚖️'),
controlSugar('控糖管理', '🩸'),
heartHealth('心血管健康', '❤️'),
gutHealth('肠胃养护', '🫁'),
immunity('增强免疫', '🛡️'),
boneHealth('骨骼健康', '🦴');
final String label;
final String emoji;
const HealthGoal(this.label, this.emoji);
static HealthGoal fromName(String? name) {
if (name == null) return HealthGoal.maintain;
return HealthGoal.values.firstWhere(
(e) => e.name == name,
orElse: () => HealthGoal.maintain,
);
}
}
enum SpiceLevel {
none('不吃辣', '😶'),
mild('微辣', '🌶️'),
medium('中辣', '🌶️🌶️'),
hot('重辣', '🌶️🌶️🌶️'),
extreme('变态辣', '🔥🔥🔥');
final String label;
final String emoji;
const SpiceLevel(this.label, this.emoji);
static SpiceLevel fromName(String? name) {
if (name == null) return SpiceLevel.mild;
return SpiceLevel.values.firstWhere(
(e) => e.name == name,
orElse: () => SpiceLevel.mild,
);
}
}
class TastePreferenceService extends GetxService {
static TastePreferenceService get to => Get.find<TastePreferenceService>();
final _keyDietType = 'taste_diet_type';
final _keySpiceLevel = 'taste_spice_level';
final _keySweetness = 'taste_sweetness';
final _keySaltiness = 'taste_saltiness';
final _keySourness = 'taste_sourness';
final _keyCookingLevel = 'taste_cooking_level';
final _keyServingSize = 'taste_serving_size';
final _keyHealthGoals = 'taste_health_goals';
final dietType = DietType.normal.obs;
final spiceLevel = SpiceLevel.mild.obs;
final sweetness = 0.5.obs;
final saltiness = 0.5.obs;
final sourness = 0.3.obs;
final cookingLevel = CookingLevel.beginner.obs;
final servingSize = ServingSize.solo.obs;
final healthGoals = <HealthGoal>{}.obs;
@override
void onInit() {
super.onInit();
_loadAll();
}
Future<void> _loadAll() async {
final prefs = await SharedPreferences.getInstance();
dietType.value = DietType.fromName(prefs.getString(_keyDietType));
spiceLevel.value = SpiceLevel.fromName(prefs.getString(_keySpiceLevel));
sweetness.value = prefs.getDouble(_keySweetness) ?? 0.5;
saltiness.value = prefs.getDouble(_keySaltiness) ?? 0.5;
sourness.value = prefs.getDouble(_keySourness) ?? 0.3;
cookingLevel.value = CookingLevel.fromName(
prefs.getString(_keyCookingLevel),
);
servingSize.value = ServingSize.fromName(prefs.getString(_keyServingSize));
final goalsStr = prefs.getStringList(_keyHealthGoals) ?? [];
healthGoals.assignAll(goalsStr.map((n) => HealthGoal.fromName(n)).toSet());
}
Future<void> saveDietType(DietType value) async {
dietType.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyDietType, value.name);
}
Future<void> saveSpiceLevel(SpiceLevel value) async {
spiceLevel.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keySpiceLevel, value.name);
}
Future<void> saveSweetness(double value) async {
sweetness.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_keySweetness, value);
}
Future<void> saveSaltiness(double value) async {
saltiness.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_keySaltiness, value);
}
Future<void> saveSourness(double value) async {
sourness.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_keySourness, value);
}
Future<void> saveCookingLevel(CookingLevel value) async {
cookingLevel.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyCookingLevel, value.name);
}
Future<void> saveServingSize(ServingSize value) async {
servingSize.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyServingSize, value.name);
}
Future<void> saveHealthGoals(Set<HealthGoal> goals) async {
healthGoals.assignAll(goals);
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(
_keyHealthGoals,
goals.map((g) => g.name).toList(),
);
}
Future<void> clearAll() async {
final prefs = await SharedPreferences.getInstance();
await Future.wait([
prefs.remove(_keyDietType),
prefs.remove(_keySpiceLevel),
prefs.remove(_keySweetness),
prefs.remove(_keySaltiness),
prefs.remove(_keySourness),
prefs.remove(_keyCookingLevel),
prefs.remove(_keyServingSize),
prefs.remove(_keyHealthGoals),
]);
dietType.value = DietType.normal;
spiceLevel.value = SpiceLevel.mild;
sweetness.value = 0.5;
saltiness.value = 0.5;
sourness.value = 0.3;
cookingLevel.value = CookingLevel.beginner;
servingSize.value = ServingSize.solo;
healthGoals.clear();
}
String get summary {
final parts = <String>[];
parts.add('${dietType.value.emoji} ${dietType.value.label}');
parts.add('${spiceLevel.value.emoji} ${spiceLevel.value.label}');
parts.add('${cookingLevel.value.emoji} ${cookingLevel.value.label}');
parts.add('${servingSize.value.emoji} ${servingSize.value.label}');
if (healthGoals.isNotEmpty) {
parts.add('🎯 ${healthGoals.length}个目标');
}
return parts.join(' ');
}
bool get hasCustomPreferences {
return dietType.value != DietType.normal ||
spiceLevel.value != SpiceLevel.mild ||
sweetness.value != 0.5 ||
saltiness.value != 0.5 ||
sourness.value != 0.3 ||
cookingLevel.value != CookingLevel.beginner ||
servingSize.value != ServingSize.solo ||
healthGoals.isNotEmpty;
}
double matchSpiceLevel(String? recipeTaste) {
if (recipeTaste == null || recipeTaste.isEmpty) return -1;
final taste = recipeTaste.toLowerCase();
final spice = spiceLevel.value;
final spiceKeywords = {
SpiceLevel.none: ['清淡', '不辣', '原味', '清蒸', '白灼'],
SpiceLevel.mild: ['微辣', '小辣', '香辣', '酱香'],
SpiceLevel.medium: ['中辣', '麻辣', '酸辣', '香辣'],
SpiceLevel.hot: ['重辣', '特辣', '干锅', '水煮', '爆辣'],
SpiceLevel.extreme: ['变态辣', '地狱辣', '超辣', '魔鬼辣'],
};
if (spiceKeywords[spice]?.any((k) => taste.contains(k)) == true) {
return 1.0;
}
if (spice == SpiceLevel.none && !taste.contains('')) return 0.8;
if (spice.index >= SpiceLevel.medium.index && taste.contains('')) {
return 0.6;
}
return 0.3;
}
double matchDifficulty(String? recipeDifficulty) {
if (recipeDifficulty == null || recipeDifficulty.isEmpty) return -1;
final diff = recipeDifficulty.toLowerCase();
final level = cookingLevel.value;
final diffKeywords = {
CookingLevel.beginner: ['简单', '入门', '初级', '快手', '零失败', '新手'],
CookingLevel.entry: ['普通', '中等', '一般', '家常'],
CookingLevel.skilled: ['较难', '进阶', '高级', '复杂'],
CookingLevel.master: ['困难', '大师', '专业', '挑战'],
};
if (diffKeywords[level]?.any((k) => diff.contains(k)) == true) return 1.0;
return 0.4;
}
double matchTaste(String? recipeTaste) {
if (recipeTaste == null || recipeTaste.isEmpty) return -1;
final taste = recipeTaste.toLowerCase();
double score = 0;
int count = 0;
if (sweetness.value > 0.6 && taste.contains('')) {
score += 1;
count++;
} else if (sweetness.value < 0.3 && !taste.contains('')) {
score += 0.5;
count++;
}
if (saltiness.value > 0.6 && (taste.contains('') || taste.contains(''))) {
score += 1;
count++;
} else if (saltiness.value < 0.3 && !taste.contains('')) {
score += 0.5;
count++;
}
if (sourness.value > 0.6 && taste.contains('')) {
score += 1;
count++;
} else if (sourness.value < 0.3 && !taste.contains('')) {
score += 0.5;
count++;
}
return count > 0 ? score / count : 0.5;
}
}

View File

@@ -1,5 +1,6 @@
// 2026-04-09 | PlatformUtils | 平台工具类 | 判断运行平台兼容Web
// 2026-04-09 | 修复Web平台Platform API崩溃问题
// 2026-04-18 | 修复鸿蒙端检测:使用动态检测避免非鸿蒙平台崩溃
import 'dart:io' if (dart.library.html) 'platform_web_stub.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
@@ -15,13 +16,7 @@ class PlatformUtils {
bool get isAndroid => !kIsWeb && Platform.isAndroid;
bool get isHarmonyOS {
if (kIsWeb || !Platform.isAndroid) return false;
final version = Platform.operatingSystemVersion.toLowerCase();
return version.contains('harmony') ||
version.contains('ohos') ||
version.contains('openharmony');
}
bool get isHarmonyOS => _checkIsHarmonyOS();
bool get isWindows => !kIsWeb && Platform.isWindows;
@@ -31,9 +26,26 @@ class PlatformUtils {
bool get isFuchsia => !kIsWeb && Platform.isFuchsia;
static bool? _isHarmonyOS;
static bool _checkIsHarmonyOS() {
if (kIsWeb) return false;
if (_isHarmonyOS != null) return _isHarmonyOS!;
try {
// 动态检测 Platform.isOhos仅鸿蒙平台存在
_isHarmonyOS = (Platform as dynamic).isOhos == true;
return _isHarmonyOS!;
} catch (_) {
// 非 HarmonyOS 平台isOhos 不存在
}
_isHarmonyOS = false;
return false;
}
String get operatingSystemName {
if (kIsWeb) return 'Web';
if (isHarmonyOS) return 'HarmonyOS';
if (_checkIsHarmonyOS()) return 'HarmonyOS';
if (Platform.isIOS) return 'iOS';
if (Platform.isAndroid) return 'Android';
if (Platform.isWindows) return 'Windows';
@@ -48,17 +60,13 @@ class PlatformUtils {
String get dartVersion => Platform.version;
String get localHostname =>
kIsWeb ? 'web' : Platform.localHostname;
String get localHostname => kIsWeb ? 'web' : Platform.localHostname;
Map<String, String> get environment =>
kIsWeb ? {} : Platform.environment;
Map<String, String> get environment => kIsWeb ? {} : Platform.environment;
String get executable =>
kIsWeb ? '' : Platform.executable;
String get executable => kIsWeb ? '' : Platform.executable;
String get resolvedExecutable =>
kIsWeb ? '' : Platform.resolvedExecutable;
String get resolvedExecutable => kIsWeb ? '' : Platform.resolvedExecutable;
String get script => kIsWeb ? '' : Platform.script.toString();
@@ -77,11 +85,9 @@ class PlatformUtils {
bool get isGoogle => isAndroid;
int get numberOfProcessors =>
kIsWeb ? 1 : Platform.numberOfProcessors;
int get numberOfProcessors => kIsWeb ? 1 : Platform.numberOfProcessors;
String get pathSeparator =>
kIsWeb ? '/' : Platform.pathSeparator;
String get pathSeparator => kIsWeb ? '/' : Platform.pathSeparator;
String get lineTerminator {
if (kIsWeb) return '\n';
@@ -98,7 +104,8 @@ class PlatformUtils {
}
bool hasFeature(String feature) {
return systemFeatures
.any((f) => f.toLowerCase().contains(feature.toLowerCase()));
return systemFeatures.any(
(f) => f.toLowerCase().contains(feature.toLowerCase()),
);
}
}

View File

@@ -1,4 +1,5 @@
// 2026-04-09 | platform_web_stub.dart | Web平台dart:io桩 | 提供Web端编译所需的类型桩
// 2026-04-18 | 新增 isOhos / operatingSystem 属性
class Platform {
static bool get isIOS => false;
static bool get isAndroid => false;
@@ -6,6 +7,8 @@ class Platform {
static bool get isMacOS => false;
static bool get isLinux => false;
static bool get isFuchsia => false;
static bool get isOhos => false;
static String get operatingSystem => 'web';
static String get operatingSystemVersion => 'web';
static String get version => '';
static String get localHostname => 'web';

View File

@@ -0,0 +1,288 @@
/*
* 文件: recipe_taste_preference.dart
* 名称: 菜谱口味偏好标注组件
* 作用: 在菜品详情页标注用户口味偏好设置值,对比菜品信息与用户偏好
* 创建时间: 2026-04-18
* 更新时间: 2026-04-18 初始实现,标注辣度/口味/难度/饮食类型/过敏原
*/
import 'package:flutter/cupertino.dart';
import 'package:get/get.dart';
import 'package:mom_kitchen/src/config/design_tokens.dart';
import 'package:mom_kitchen/src/models/recipe/recipe_model.dart';
import 'package:mom_kitchen/src/services/user/taste_preference_service.dart';
import 'package:mom_kitchen/src/controllers/user/preference_controller.dart';
class RecipeTastePreference extends StatelessWidget {
final RecipeModel recipe;
const RecipeTastePreference({super.key, required this.recipe});
@override
Widget build(BuildContext context) {
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
final tasteService = Get.find<TastePreferenceService>();
final prefController = Get.find<PreferenceController>();
final items = <_PreferenceItem>[];
final dietType = tasteService.dietType.value;
if (dietType != DietType.normal) {
items.add(_PreferenceItem(
emoji: '🥗',
label: '饮食类型',
userValue: '${dietType.emoji} ${dietType.label}',
matchLevel: _matchDietType(dietType, recipe),
));
}
final spiceLevel = tasteService.spiceLevel.value;
if (spiceLevel != SpiceLevel.mild) {
final spiceMatch = tasteService.matchSpiceLevel(recipe.meta?.taste);
items.add(_PreferenceItem(
emoji: '🌶️',
label: '辣度偏好',
userValue: '${spiceLevel.emoji} ${spiceLevel.label}',
matchLevel: spiceMatch,
recipeValue: recipe.meta?.taste,
));
}
final sweetness = tasteService.sweetness.value;
final saltiness = tasteService.saltiness.value;
final sourness = tasteService.sourness.value;
if (sweetness != 0.5 || saltiness != 0.5 || sourness != 0.3) {
final tasteMatch = tasteService.matchTaste(recipe.meta?.taste);
final tasteParts = <String>[];
if (sweetness != 0.5) tasteParts.add('🍬 甜${(sweetness * 100).round()}%');
if (saltiness != 0.5) tasteParts.add('🧂 咸${(saltiness * 100).round()}%');
if (sourness != 0.3) tasteParts.add('🍋 酸${(sourness * 100).round()}%');
items.add(_PreferenceItem(
emoji: '👅',
label: '口味偏好',
userValue: tasteParts.join(' '),
matchLevel: tasteMatch,
recipeValue: recipe.meta?.taste,
));
}
final cookingLevel = tasteService.cookingLevel.value;
if (cookingLevel != CookingLevel.beginner) {
final diffMatch = tasteService.matchDifficulty(recipe.meta?.difficulty);
items.add(_PreferenceItem(
emoji: '👨‍🍳',
label: '烹饪水平',
userValue: '${cookingLevel.emoji} ${cookingLevel.label}',
matchLevel: diffMatch,
recipeValue: recipe.meta?.difficulty,
));
}
final blockedAllergens = prefController.blockedAllergenTypes;
if (blockedAllergens.isNotEmpty) {
final allergenChecker = <String>[];
for (final allergen in recipe.allergens) {
if (blockedAllergens.contains(allergen)) {
allergenChecker.add(allergen);
}
}
items.add(_PreferenceItem(
emoji: '⚠️',
label: '过敏原',
userValue: blockedAllergens.map((a) {
final allergenInfo = prefController.availableAllergens
.where((e) => e.type == a);
return allergenInfo.isNotEmpty
? '${allergenInfo.first.icon ?? '⚠️'} ${allergenInfo.first.name}'
: a;
}).join(' '),
matchLevel: allergenChecker.isEmpty ? 1.0 : -1.0,
recipeValue: allergenChecker.isEmpty ? null : '${allergenChecker.join('')}',
isAllergen: true,
));
}
if (items.isEmpty) return const SizedBox();
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4,
vertical: DesignTokens.space2,
),
child: Container(
padding: const EdgeInsets.all(DesignTokens.space3),
decoration: BoxDecoration(
color: isDark ? DarkDesignTokens.card : DesignTokens.card,
borderRadius: DesignTokens.borderRadiusMd,
border: Border.all(
color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3)
.withValues(alpha: 0.1),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
CupertinoIcons.heart_fill,
size: 14,
color: DesignTokens.dynamicPrimary,
),
const SizedBox(width: DesignTokens.space2),
Text(
'我的偏好',
style: TextStyle(
fontSize: DesignTokens.fontSm,
fontWeight: FontWeight.w600,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
],
),
const SizedBox(height: DesignTokens.space2),
...items.map((item) => _buildPreferenceRow(item, isDark)),
],
),
),
);
}
Widget _buildPreferenceRow(_PreferenceItem item, bool isDark) {
final matchColor = item.isAllergen
? (item.matchLevel < 0 ? CupertinoColors.systemRed : CupertinoColors.systemGreen)
: (item.matchLevel >= 0.8
? CupertinoColors.systemGreen
: item.matchLevel >= 0.5
? DesignTokens.orange
: CupertinoColors.systemRed);
final matchIcon = item.isAllergen
? (item.matchLevel < 0 ? CupertinoIcons.xmark_shield_fill : CupertinoIcons.checkmark_shield_fill)
: (item.matchLevel >= 0.8
? CupertinoIcons.checkmark_circle_fill
: item.matchLevel >= 0.5
? CupertinoIcons.minus_circle_fill
: CupertinoIcons.xmark_circle_fill);
final matchLabel = item.isAllergen
? (item.matchLevel < 0 ? '含过敏原' : '安全')
: (item.matchLevel >= 0.8
? '很匹配'
: item.matchLevel >= 0.5
? '一般'
: item.matchLevel >= 0
? '不太匹配'
: '无数据');
return Padding(
padding: const EdgeInsets.only(bottom: DesignTokens.space2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 24,
child: Text(item.emoji, style: const TextStyle(fontSize: 14)),
),
SizedBox(
width: 56,
child: Text(
item.label,
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
),
),
Expanded(
child: Text(
item.userValue,
style: TextStyle(
fontSize: DesignTokens.fontXs,
fontWeight: FontWeight.w500,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space2,
vertical: 2,
),
decoration: BoxDecoration(
color: matchColor.withValues(alpha: 0.1),
borderRadius: DesignTokens.borderRadiusSm,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(matchIcon, size: 12, color: matchColor),
const SizedBox(width: 3),
Text(
matchLabel,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: matchColor,
),
),
],
),
),
],
),
);
}
double _matchDietType(DietType dietType, RecipeModel recipe) {
final ingredientText = recipe.ingredients
.map((i) => '${i.name} ${i.amount ?? ''} ${i.unit ?? ''}')
.join(' ')
.toLowerCase();
switch (dietType) {
case DietType.vegetarian:
final meatKeywords = ['猪肉', '牛肉', '羊肉', '鸡肉', '鸭肉', '', '', '', '排骨', '五花肉', '培根', '火腿'];
final hasMeat = meatKeywords.any((k) => ingredientText.contains(k));
return hasMeat ? 0.0 : 1.0;
case DietType.vegan:
final animalKeywords = ['猪肉', '牛肉', '羊肉', '鸡肉', '鸭肉', '', '', '', '', '', '奶酪', '黄油', '蜂蜜', '排骨'];
final hasAnimal = animalKeywords.any((k) => ingredientText.contains(k));
return hasAnimal ? 0.0 : 1.0;
case DietType.lowCarb:
final carbKeywords = ['米饭', '面条', '馒头', '面包', '土豆', '', '', ''];
final hasCarb = carbKeywords.any((k) => ingredientText.contains(k));
return hasCarb ? 0.3 : 1.0;
case DietType.keto:
final carbKeywords = ['米饭', '面条', '馒头', '面包', '土豆', '', '', '', '', '淀粉'];
final hasCarb = carbKeywords.any((k) => ingredientText.contains(k));
return hasCarb ? 0.0 : 1.0;
case DietType.mediterranean:
final medKeywords = ['橄榄油', '', '番茄', '柠檬', '', '洋葱', '香草'];
final hasMed = medKeywords.any((k) => ingredientText.contains(k));
return hasMed ? 1.0 : 0.5;
case DietType.halal:
final haramKeywords = ['猪肉', '', '酒精', '培根', '火腿'];
final hasHaram = haramKeywords.any((k) => ingredientText.contains(k));
return hasHaram ? 0.0 : 1.0;
case DietType.normal:
return 1.0;
}
}
}
class _PreferenceItem {
final String emoji;
final String label;
final String userValue;
final double matchLevel;
final String? recipeValue;
final bool isAllergen;
const _PreferenceItem({
required this.emoji,
required this.label,
required this.userValue,
required this.matchLevel,
this.recipeValue,
this.isAllergen = false,
});
}

View File

@@ -2,8 +2,10 @@
// 2026-04-12 | API v3.2.0: recommend改为rate评分接口(1-5分)
// 2026-04-13 | 新增IP状态显示评分前显示剩余次数新增二维码海报按钮
// 2026-04-16 | 移除点赞按钮已移至标题区域RecipeTitleSection
// 2026-04-18 | 新增recipe参数支持完整菜谱数据传递到二维码海报
import 'package:flutter/cupertino.dart';
import 'package:mom_kitchen/src/config/design_tokens.dart';
import 'package:mom_kitchen/src/models/recipe/recipe_model.dart';
import 'package:mom_kitchen/src/widgets/recipe_detail/interaction/recipe_qr_poster.dart';
class RecipeActionBar extends StatelessWidget {
@@ -11,9 +13,11 @@ class RecipeActionBar extends StatelessWidget {
final int? userRating;
final int? rateRemaining;
final String? recipeCode;
final int? recipeId;
final String? recipeTitle;
final String? categoryName;
final double? ratingScore;
final RecipeModel? recipe;
final void Function(int score) onRate;
final VoidCallback onShare;
final VoidCallback onNote;
@@ -27,9 +31,11 @@ class RecipeActionBar extends StatelessWidget {
required this.userRating,
this.rateRemaining,
this.recipeCode,
this.recipeId,
this.recipeTitle,
this.categoryName,
this.ratingScore,
this.recipe,
required this.onRate,
required this.onShare,
required this.onNote,
@@ -76,10 +82,12 @@ class RecipeActionBar extends StatelessWidget {
context,
title: recipeTitle ?? '菜谱',
code: recipeCode,
recipeId: recipeId,
categoryName: categoryName,
viewCount: likeCount,
likeCount: likeCount,
ratingScore: ratingScore,
recipe: recipe,
),
),
const SizedBox(width: DesignTokens.space2),

View File

@@ -3,7 +3,7 @@
* 名称: 菜谱二维码海报组件
* 作用: 生成菜谱二维码分享海报,含菜谱信息+二维码+分享链接
* 创建: 2026-04-13
* 更新: 2026-04-13 初始创建
* 更新: 2026-04-18 更新路径为 /kitchen/recipe_share.php
*/
import 'dart:ui' as ui;
@@ -11,10 +11,14 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/rendering.dart';
import 'package:qr/qr.dart';
import 'package:mom_kitchen/src/config/design_tokens.dart';
import 'package:mom_kitchen/src/config/api_config.dart';
import 'package:mom_kitchen/src/models/recipe/recipe_model.dart';
import 'package:mom_kitchen/src/services/data/recipe_share_service.dart';
class RecipeQrPoster extends StatelessWidget {
final String title;
final String? code;
final int? recipeId;
final String? categoryName;
final String? coverUrl;
final int? viewCount;
@@ -25,6 +29,7 @@ class RecipeQrPoster extends StatelessWidget {
super.key,
required this.title,
this.code,
this.recipeId,
this.categoryName,
this.coverUrl,
this.viewCount,
@@ -32,8 +37,15 @@ class RecipeQrPoster extends StatelessWidget {
this.ratingScore,
});
String get _shareUrl =>
code != null ? 'https://eat.wktyl.com/recipe/$code' : '';
String get _shareUrl {
if (code != null && code!.isNotEmpty) {
return '${ApiConfig.baseUrl}/kitchen/recipe_share.php?code=${Uri.encodeComponent(code!)}';
}
if (recipeId != null) {
return '${ApiConfig.baseUrl}/kitchen/recipe_share.php?id=$recipeId';
}
return '';
}
@override
Widget build(BuildContext context) {
@@ -258,6 +270,120 @@ void showQrPosterSheet(
BuildContext context, {
required String title,
String? code,
int? recipeId,
String? categoryName,
int? viewCount,
int? likeCount,
double? ratingScore,
RecipeModel? recipe,
}) {
// 先推送数据到服务器
if (recipe != null) {
_pushAndShow(context, recipe);
} else {
_showPosterOnly(
context,
title: title,
code: code,
recipeId: recipeId,
categoryName: categoryName,
viewCount: viewCount,
likeCount: likeCount,
ratingScore: ratingScore,
);
}
}
/// 推送数据并显示海报
Future<void> _pushAndShow(BuildContext context, RecipeModel recipe) async {
// 显示加载提示
showCupertinoModalPopup<void>(
context: context,
builder: (ctx) {
final isDark = CupertinoTheme.brightnessOf(ctx) == Brightness.dark;
return Container(
padding: const EdgeInsets.all(DesignTokens.space4),
decoration: BoxDecoration(
color: isDark ? DarkDesignTokens.background : DesignTokens.background,
borderRadius: DesignTokens.borderRadiusLg,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CupertinoActivityIndicator(radius: 16),
const SizedBox(height: DesignTokens.space2),
Text(
'正在生成分享海报...',
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
),
),
],
),
);
},
);
try {
// 推送数据到服务器
final shareUrl = await RecipeShareService().pushRecipeToShare(recipe);
// 关闭加载提示
if (context.mounted) {
Navigator.pop(context);
}
if (shareUrl != null && context.mounted) {
// 显示海报
_showPosterOnly(
context,
title: recipe.title,
code: recipe.code,
recipeId: recipe.id,
categoryName: recipe.categoryName,
viewCount: recipe.statistics?.views,
likeCount: recipe.statistics?.likes,
ratingScore: recipe.rating?.score,
);
} else if (context.mounted) {
// 推送失败,仍然显示海报(使用本地数据)
_showPosterOnly(
context,
title: recipe.title,
code: recipe.code,
recipeId: recipe.id,
categoryName: recipe.categoryName,
viewCount: recipe.statistics?.views,
likeCount: recipe.statistics?.likes,
ratingScore: recipe.rating?.score,
);
}
} catch (e) {
debugPrint('❌ 推送分享数据异常: $e');
if (context.mounted) {
Navigator.pop(context);
// 仍然显示海报
_showPosterOnly(
context,
title: recipe.title,
code: recipe.code,
recipeId: recipe.id,
categoryName: recipe.categoryName,
viewCount: recipe.statistics?.views,
likeCount: recipe.statistics?.likes,
ratingScore: recipe.rating?.score,
);
}
}
}
/// 仅显示海报(不推送数据)
void _showPosterOnly(
BuildContext context, {
required String title,
String? code,
int? recipeId,
String? categoryName,
int? viewCount,
int? likeCount,
@@ -304,6 +430,7 @@ void showQrPosterSheet(
RecipeQrPoster(
title: title,
code: code,
recipeId: recipeId,
categoryName: categoryName,
viewCount: viewCount,
likeCount: likeCount,