本次提交包含以下主要变更: 1. 删除所有packages/fluttertoast_ohos下的HarmonyOS原生模块文件与编译配置 2. 更新pubspec.yaml的SDK与Flutter最低版本要求 3. 修复多处代码细节问题: - 替换弃用的Share.shareXFiles为SharePlus新API - 修正ConnectivityResult判断逻辑,使用contains替代直接相等判断 - 修复列表分隔符的unused参数命名 - 调整条件渲染语法为更简洁的空值判断写法 - 统一CupertinoButton的minSize参数写法 - 简化空字符串默认值处理逻辑 4. 更新pubspec.lock依赖版本
426 lines
13 KiB
Dart
426 lines
13 KiB
Dart
/*
|
||
* 文件: 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');
|
||
}
|
||
}
|
||
}
|