feat: 新增口味偏好服务和菜谱分享功能
- 新增 TastePreferenceService 用于管理用户口味偏好设置 - 实现菜谱分享功能,包括 RecipeShareService 和分享页面 - 更新平台工具类以支持鸿蒙系统检测 - 优化收藏页和农场商店页面的UI交互 - 添加新的参考文献和关于页面内容 - 更新API文档至v3.3.0版本
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -410,7 +410,7 @@ class _ToolsPanelWidgetState extends State<ToolsPanelWidget> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'更多',
|
||||
'工具中心 旧版UI',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontSm,
|
||||
color: DesignTokens.dynamicPrimary,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
1229
lib/src/pages/profile/app_info_page.dart
Normal file
1229
lib/src/pages/profile/app_info_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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. 确认后即可完成导入',
|
||||
),
|
||||
|
||||
904
lib/src/pages/profile/learn_us_page.dart
Normal file
904
lib/src/pages/profile/learn_us_page.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,13 +280,13 @@ class ProfileHomeTab extends StatelessWidget {
|
||||
),
|
||||
_FeatureItem(
|
||||
CupertinoIcons.cart,
|
||||
'购物清单',
|
||||
'分享记录',
|
||||
DesignTokens.green,
|
||||
AppRoutes.shoppingList,
|
||||
),
|
||||
_FeatureItem(
|
||||
CupertinoIcons.bookmark,
|
||||
'收藏',
|
||||
'评分记录',
|
||||
DesignTokens.secondary,
|
||||
AppRoutes.favorites,
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
'参考文献',
|
||||
|
||||
@@ -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('重置'),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -239,8 +239,8 @@ class DataExportService extends GetxService {
|
||||
if (kIsWeb) return;
|
||||
await Share.shareXFiles(
|
||||
[XFile(filePath)],
|
||||
subject: '妈妈厨房 - 数据导出',
|
||||
text: '从妈妈厨房导出的数据',
|
||||
subject: '小妈厨房 - 数据导出',
|
||||
text: '从小妈厨房导出的数据',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
109
lib/src/services/data/recipe_share_service.dart
Normal file
109
lib/src/services/data/recipe_share_service.dart
Normal 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}';
|
||||
}
|
||||
}
|
||||
324
lib/src/services/user/taste_preference_service.dart
Normal file
324
lib/src/services/user/taste_preference_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
288
lib/src/widgets/recipe_detail/info/recipe_taste_preference.dart
Normal file
288
lib/src/widgets/recipe_detail/info/recipe_taste_preference.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user