Files
kitchen/lib/src/controllers/tools/what_to_eat_controller.dart
Developer efe98f60e7 chore: 清理HarmonyOS平台无关的依赖文件并更新依赖配置
本次提交包含以下主要变更:
1. 删除所有packages/fluttertoast_ohos下的HarmonyOS原生模块文件与编译配置
2. 更新pubspec.yaml的SDK与Flutter最低版本要求
3. 修复多处代码细节问题:
   - 替换弃用的Share.shareXFiles为SharePlus新API
   - 修正ConnectivityResult判断逻辑,使用contains替代直接相等判断
   - 修复列表分隔符的unused参数命名
   - 调整条件渲染语法为更简洁的空值判断写法
   - 统一CupertinoButton的minSize参数写法
   - 简化空字符串默认值处理逻辑
4. 更新pubspec.lock依赖版本
2026-06-16 04:08:02 +08:00

426 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 文件: what_to_eat_controller.dart
* 名称: 今天吃什么控制器
* 作用: 使用filter_steps动态筛选+api_filter分类选项选越多剩余符合项越少
* 创建: 2026-04-10
* 更新: 2026-04-20 重写分类从api_filter加载+标签/匹配数从filter_steps动态更新+防抖
*/
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:cute_kitchen/src/controllers/browse/base_controller.dart';
import 'package:cute_kitchen/src/models/recipe/recipe_model.dart';
import 'package:cute_kitchen/src/models/recipe/category_model.dart';
import 'package:cute_kitchen/src/models/recipe/tag_model.dart';
import 'package:cute_kitchen/src/repositories/what_to_eat_repository.dart';
import 'package:cute_kitchen/src/repositories/recipe_repository.dart';
import 'package:cute_kitchen/src/repositories/action_repository.dart';
import 'package:cute_kitchen/src/services/ui/toast_service.dart';
class WhatToEatController extends BaseController {
final WhatToEatRepository _whatToEatRepo = WhatToEatRepository();
final RecipeRepository _recipeRepo = RecipeRepository();
final ActionRepository _actionRepo = ActionRepository();
final Rx<RecipeModel?> selectedRecipe = Rx<RecipeModel?>(null);
final RxBool isSpinning = false.obs;
final RxInt candidatesCount = 0.obs;
final RxList<CategoryModel> mainCategories = <CategoryModel>[].obs;
final RxMap<int, List<CategoryModel>> subCategoriesCache =
<int, List<CategoryModel>>{}.obs;
final RxList<TagModel> availableTags = <TagModel>[].obs;
final RxInt matchedCount = 0.obs;
final RxList<int> selectedCategoryIds = <int>[].obs;
final RxList<int> selectedTagIds = <int>[].obs;
final RxList<String> excludeAllergens = <String>[].obs;
final RxList<RecipeModel> recentResults = <RecipeModel>[].obs;
final RxList<Map<String, dynamic>> eatHistory = <Map<String, dynamic>>[].obs;
static const String _historyKey = 'what_to_eat_history';
static const int _maxHistorySize = 50;
final Rx<int?> expandedCategoryId = Rx<int?>(null);
final RxBool isOptionsLoading = false.obs;
final RxBool isOptionsLoaded = false.obs;
final RxString optionsError = ''.obs;
final RxBool isRefreshing = false.obs;
Timer? _debounceTimer;
static const List<String> defaultAllergens = [
'花生',
'牛奶',
'鸡蛋',
'坚果',
'海鲜',
'麸质',
];
@override
void onInit() {
super.onInit();
Future.delayed(Duration.zero, () async {
await _loadHistory();
_loadInitialData();
});
}
@override
void onClose() {
_debounceTimer?.cancel();
super.onClose();
}
Future<void> _loadInitialData() async {
isOptionsLoading.value = true;
optionsError.value = '';
try {
await Future.wait([_loadMainCategories(), _refreshFilterSteps()]).timeout(
const Duration(seconds: 15),
onTimeout: () {
debugPrint('WhatToEatController: _loadInitialData timeout');
return [];
},
);
isOptionsLoaded.value = true;
} catch (e) {
debugPrint('WhatToEatController: _loadInitialData error: $e');
optionsError.value = '筛选选项加载失败,请重试';
} finally {
isOptionsLoading.value = false;
}
}
Future<void> reloadOptions() async {
await _loadInitialData();
}
Future<void> _loadMainCategories() async {
try {
final result = await _recipeRepo.fetchMainCategories();
debugPrint(
'WhatToEatController: loaded ${result.length} main categories',
);
mainCategories.value = result;
} catch (e) {
debugPrint('WhatToEatController: _loadMainCategories error: $e');
}
}
Future<void> loadSubCategories(int parentId) async {
if (subCategoriesCache.containsKey(parentId)) return;
try {
final result = await _recipeRepo.fetchRecipeSubCategories(parentId);
subCategoriesCache[parentId] = result;
debugPrint(
'WhatToEatController: loaded ${result.length} sub categories for $parentId',
);
} catch (e) {
debugPrint('WhatToEatController: loadSubCategories error: $e');
}
}
void _scheduleRefresh() {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
_refreshFilterSteps();
});
}
Future<void> _refreshFilterSteps() async {
if (isRefreshing.value) return;
isRefreshing.value = true;
try {
final result = await _whatToEatRepo
.fetchFilterSteps(
categories: selectedCategoryIds.isNotEmpty
? selectedCategoryIds
: null,
tags: selectedTagIds.isNotEmpty ? selectedTagIds : null,
)
.timeout(const Duration(seconds: 10));
if (result != null) {
_parseFilterStepsResult(result);
}
} catch (e) {
debugPrint('WhatToEatController: _refreshFilterSteps error: $e');
} finally {
isRefreshing.value = false;
}
}
void _parseFilterStepsResult(Map<String, dynamic> data) {
matchedCount.value = _parseInt(data['matched_count']);
final tagsList = data['available_tags'] as List? ?? [];
final parsedTags = <TagModel>[];
for (final tag in tagsList) {
if (tag is! Map) continue;
parsedTags.add(TagModel.fromJson(Map<String, dynamic>.from(tag)));
}
availableTags.value = parsedTags;
debugPrint(
'WhatToEatController: ${parsedTags.length} tags, matched=$matchedCount',
);
}
Future<void> roll() async {
if (isSpinning.value) return;
isSpinning.value = true;
selectedRecipe.value = null;
candidatesCount.value = 0;
errorMessage.value = '';
try {
final categoryIds = _collectCategoryIds();
final tagIds = selectedTagIds.toList();
debugPrint(
'WhatToEatController.roll: categories=$categoryIds, tags=$tagIds',
);
final results = await _whatToEatRepo
.fetchFilterApply(
categories: categoryIds.isNotEmpty ? categoryIds : null,
tags: tagIds.isNotEmpty ? tagIds : null,
count: 5,
)
.timeout(
const Duration(seconds: 12),
onTimeout: () {
debugPrint('WhatToEatController: roll timeout');
return <RecipeModel>[];
},
);
debugPrint('WhatToEatController.roll: got ${results.length} results');
final filtered = _filterByAllergens(results);
if (filtered.isNotEmpty) {
await Future.delayed(const Duration(milliseconds: 300));
final recipe = filtered.first;
selectedRecipe.value = recipe;
candidatesCount.value = filtered.length;
recentResults.value = filtered;
_saveToHistory(recipe);
try {
_actionRepo.view(type: 'recipe', id: recipe.id);
} catch (_) {}
ToastService.show(message: '已为您选择: ${recipe.title} 🎉');
} else if (results.isNotEmpty && filtered.isEmpty) {
ToastService.show(message: '选中的菜谱含过敏原,已过滤。试试减少过敏原排除 🤔');
} else {
final filterInfo = <String>[];
if (categoryIds.isNotEmpty) filterInfo.add('${categoryIds.length}个分类');
if (tagIds.isNotEmpty) filterInfo.add('${tagIds.length}个标签');
final filterText = filterInfo.isEmpty
? ''
: '(已选${filterInfo.join('')}';
ToastService.show(message: '没有找到合适的菜谱$filterText,试试调整筛选条件 🤔');
}
} catch (e) {
debugPrint('Roll error: $e');
errorMessage.value = e.toString();
ToastService.show(message: '获取推荐失败,请重试 🔄');
} finally {
isSpinning.value = false;
}
}
Future<void> rollAgain() async {
if (recentResults.isNotEmpty && recentResults.length > 1) {
final current = selectedRecipe.value;
final others = recentResults.where((r) => r.id != current?.id).toList();
if (others.isNotEmpty) {
selectedRecipe.value = others.first;
ToastService.show(message: '换一个: ${others.first.title} 🔄');
return;
}
}
await roll();
}
List<int> _collectCategoryIds() {
final ids = <int>[];
for (final catId in selectedCategoryIds) {
final subs = subCategoriesCache[catId];
if (subs != null && subs.isNotEmpty) {
ids.addAll(subs.map((c) => c.id));
} else {
ids.add(catId);
}
}
return ids.where((id) => id > 0).toSet().toList();
}
List<RecipeModel> _filterByAllergens(List<RecipeModel> recipes) {
if (excludeAllergens.isEmpty) return recipes;
return recipes.where((recipe) {
final allergens = recipe.allergens;
return !excludeAllergens.any(
(blocked) => allergens.any(
(a) => a.toLowerCase().contains(blocked.toLowerCase()),
),
);
}).toList();
}
void toggleCategory(int categoryId) {
final idx = selectedCategoryIds.indexOf(categoryId);
if (idx >= 0) {
selectedCategoryIds.removeAt(idx);
} else {
selectedCategoryIds.add(categoryId);
}
selectedRecipe.value = null;
_scheduleRefresh();
}
void toggleTag(int tagId) {
final idx = selectedTagIds.indexOf(tagId);
if (idx >= 0) {
selectedTagIds.removeAt(idx);
} else {
selectedTagIds.add(tagId);
}
selectedRecipe.value = null;
_scheduleRefresh();
}
void toggleAllergen(String allergenType) {
if (excludeAllergens.contains(allergenType)) {
excludeAllergens.remove(allergenType);
} else {
excludeAllergens.add(allergenType);
}
selectedRecipe.value = null;
}
void clearFilters() {
selectedCategoryIds.clear();
selectedTagIds.clear();
excludeAllergens.clear();
selectedRecipe.value = null;
recentResults.clear();
_scheduleRefresh();
}
bool isCategorySelected(int categoryId) =>
selectedCategoryIds.contains(categoryId);
bool isTagSelected(int tagId) => selectedTagIds.contains(tagId);
bool isAllergenSelected(String allergenType) =>
excludeAllergens.contains(allergenType);
bool get hasActiveFilters =>
selectedCategoryIds.isNotEmpty ||
selectedTagIds.isNotEmpty ||
excludeAllergens.isNotEmpty;
String get filterSummary {
final parts = <String>[];
if (selectedCategoryIds.isNotEmpty) {
parts.add('${selectedCategoryIds.length}个分类');
}
if (selectedTagIds.isNotEmpty) {
parts.add('${selectedTagIds.length}个标签');
}
if (excludeAllergens.isNotEmpty) {
parts.add('${excludeAllergens.length}个过敏原');
}
return parts.isEmpty ? '无筛选' : parts.join(' + ');
}
void toggleExpandCategory(int categoryId) {
if (expandedCategoryId.value == categoryId) {
expandedCategoryId.value = null;
} else {
expandedCategoryId.value = categoryId;
loadSubCategories(categoryId);
}
}
bool isCategoryExpanded(int categoryId) =>
expandedCategoryId.value == categoryId;
List<CategoryModel> getSubCategories(int parentId) =>
subCategoriesCache[parentId] ?? [];
String get matchedCountDisplay {
final count = matchedCount.value;
if (count >= 10000) {
return '${(count / 10000).toStringAsFixed(1)}';
}
return count.toString();
}
int _parseInt(dynamic value) {
if (value == null) return 0;
if (value is int) return value;
if (value is String) return int.tryParse(value) ?? 0;
if (value is double) return value.toInt();
return 0;
}
Future<void> _loadHistory() async {
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_historyKey);
if (raw != null && raw.isNotEmpty) {
final List<dynamic> decoded = jsonDecode(raw);
eatHistory.value = decoded.cast<Map<String, dynamic>>().toList();
debugPrint(
'WhatToEatController: loaded ${eatHistory.length} history items',
);
}
} catch (e) {
debugPrint('WhatToEatController: _loadHistory error: $e');
}
}
Future<void> _saveToHistory(RecipeModel recipe) async {
try {
final entry = {
'id': recipe.id,
'title': recipe.title,
'categoryName': recipe.categoryName ?? '',
'time': DateTime.now().toIso8601String(),
'displayIntro': recipe.displayIntro,
};
eatHistory.insert(0, entry);
if (eatHistory.length > _maxHistorySize) {
eatHistory.removeRange(_maxHistorySize, eatHistory.length);
}
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_historyKey, jsonEncode(eatHistory.toList()));
} catch (e) {
debugPrint('WhatToEatController: _saveToHistory error: $e');
}
}
Future<void> clearHistory() async {
eatHistory.clear();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_historyKey);
} catch (e) {
debugPrint('WhatToEatController: clearHistory error: $e');
}
}
}