Files
kitchen/lib/src/pages/tools/eating_times_page.dart
2026-04-11 07:07:13 +08:00

570 lines
18 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.
/*
* 文件: eating_times_page.dart
* 名称: 用餐时段推荐页面
* 作用: 展示API提供的用餐时段分类数据支持按时段浏览菜谱
* 创建: 2026-04-11
* 更新: 2026-04-11 初始创建基于eating_times.json数据
*/
import 'package:flutter/cupertino.dart';
import 'package:get/get.dart';
import 'package:dio/dio.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/repositories/recipe_repository.dart';
import 'package:mom_kitchen/src/widgets/recipe_image.dart';
class EatingTimeItem {
final int id;
final String name;
final int count;
const EatingTimeItem({
required this.id,
required this.name,
required this.count,
});
factory EatingTimeItem.fromJson(Map<String, dynamic> json) {
return EatingTimeItem(
id: (json['id'] is int) ? json['id'] : int.tryParse('${json['id']}') ?? 0,
name: '${json['name'] ?? ''}',
count: (json['count'] is int)
? json['count']
: int.tryParse('${json['count']}') ?? 0,
);
}
}
class EatingTimesGroup {
final String title;
final String icon;
final List<EatingTimeItem> items;
const EatingTimesGroup({
required this.title,
required this.icon,
required this.items,
});
}
class EatingTimesPage extends StatefulWidget {
const EatingTimesPage({super.key});
@override
State<EatingTimesPage> createState() => _EatingTimesPageState();
}
class _EatingTimesPageState extends State<EatingTimesPage> {
final Dio _dio = Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
),
);
final RecipeRepository _recipeRepo = RecipeRepository();
List<EatingTimesGroup> _groups = [];
bool _isLoading = true;
String _errorMessage = '';
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
final response = await _dio
.get(ApiConfig.eatingTimesJson)
.timeout(const Duration(seconds: 10));
if (response.data == null) {
throw Exception('数据为空');
}
final data = response.data as Map<String, dynamic>;
final groups = <EatingTimesGroup>[];
final groupConfigs = [
('standard_times', '🌅 标准时段', '🍽️'),
('combined_times', '🔄 组合时段', '🥘'),
('frequency_times', '📅 频率时段', ''),
('method_times', '👨‍🍳 方法时段', '🍳'),
('other_times', '📋 其他时段', '📌'),
];
for (final config in groupConfigs) {
final key = config.$1;
final title = config.$2;
final icon = config.$3;
final items = data[key];
if (items is List && items.isNotEmpty) {
groups.add(
EatingTimesGroup(
title: title,
icon: icon,
items: items
.whereType<Map<String, dynamic>>()
.map((e) => EatingTimeItem.fromJson(e))
.where((e) => e.count > 0)
.toList(),
),
);
}
}
setState(() {
_groups = groups;
_isLoading = false;
});
} catch (e) {
debugPrint('EatingTimesPage load error: $e');
setState(() {
_errorMessage = '加载失败: $e';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(
'🍽️ 用餐时段',
style: TextStyle(
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
backgroundColor: isDark
? DarkDesignTokens.background.withValues(alpha: 0.85)
: DesignTokens.background.withValues(alpha: 0.85),
border: null,
),
child: SafeArea(
child: _isLoading
? const Center(child: CupertinoActivityIndicator())
: _errorMessage.isNotEmpty
? _buildError(isDark)
: _buildContent(isDark),
),
);
}
Widget _buildError(bool isDark) {
return Center(
child: Padding(
padding: const EdgeInsets.all(DesignTokens.space6),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('😕', style: TextStyle(fontSize: 56)),
const SizedBox(height: DesignTokens.space4),
Text(
_errorMessage,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: DesignTokens.fontMd,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
),
),
const SizedBox(height: DesignTokens.space4),
CupertinoButton.filled(
onPressed: _loadData,
child: const Text('重试'),
),
],
),
),
);
}
Widget _buildContent(bool isDark) {
return ListView(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4,
vertical: DesignTokens.space2,
),
children: [
Container(
padding: const EdgeInsets.all(DesignTokens.space4),
decoration: BoxDecoration(
color: isDark ? DarkDesignTokens.card : DesignTokens.card,
borderRadius: DesignTokens.borderRadiusLg,
border: Border.all(
color: (isDark ? DarkDesignTokens.primary : DesignTokens.primary)
.withValues(alpha: 0.15),
),
),
child: Row(
children: [
const Text('🕐', style: TextStyle(fontSize: 32)),
const SizedBox(width: DesignTokens.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'用餐时段推荐',
style: TextStyle(
fontSize: DesignTokens.fontLg,
fontWeight: FontWeight.w600,
color: isDark
? DarkDesignTokens.text1
: DesignTokens.text1,
),
),
const SizedBox(height: 2),
Text(
'根据不同用餐时段浏览适合的菜谱',
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark
? DarkDesignTokens.text3
: DesignTokens.text3,
),
),
],
),
),
],
),
),
const SizedBox(height: DesignTokens.space4),
..._groups.map((group) => _buildGroup(group, isDark)),
const SizedBox(height: DesignTokens.space6),
],
);
}
Widget _buildGroup(EatingTimesGroup group, bool isDark) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(
left: DesignTokens.space1,
bottom: DesignTokens.space2,
),
child: Text(
group.title,
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w600,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
),
Container(
decoration: BoxDecoration(
color: isDark ? DarkDesignTokens.card : DesignTokens.card,
borderRadius: DesignTokens.borderRadiusLg,
border: Border.all(
color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3)
.withValues(alpha: 0.1),
),
),
child: Column(
children: group.items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
final isLast = index == group.items.length - 1;
return _buildTimeItem(item, isDark, isLast);
}).toList(),
),
),
const SizedBox(height: DesignTokens.space4),
],
);
}
Widget _buildTimeItem(EatingTimeItem item, bool isDark, bool isLast) {
final timeIcon = _getTimeIcon(item.name);
return GestureDetector(
onTap: () => _browseByTime(item),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4,
vertical: DesignTokens.space3,
),
decoration: BoxDecoration(
border: isLast
? null
: Border(
bottom: BorderSide(
color:
(isDark ? DarkDesignTokens.text3 : DesignTokens.text3)
.withValues(alpha: 0.08),
),
),
),
child: Row(
children: [
Text(timeIcon, style: const TextStyle(fontSize: 24)),
const SizedBox(width: DesignTokens.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w500,
color: isDark
? DarkDesignTokens.text1
: DesignTokens.text1,
),
),
Text(
'${item.count} 道菜谱',
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: isDark
? DarkDesignTokens.text3
: DesignTokens.text3,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color:
(isDark ? DarkDesignTokens.primary : DesignTokens.primary)
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'浏览',
style: TextStyle(
fontSize: DesignTokens.fontXs,
fontWeight: FontWeight.w500,
color: isDark
? DarkDesignTokens.primary
: DesignTokens.primary,
),
),
),
const SizedBox(width: 4),
Icon(
CupertinoIcons.chevron_forward,
size: 16,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
],
),
),
);
}
String _getTimeIcon(String name) {
if (name.contains('')) return '🌅';
if (name.contains('') || name.contains('')) return '🍱';
if (name.contains('')) return '🌙';
if (name.contains('零食') || name.contains('小吃')) return '🍿';
if (name.contains('每日') || name.contains('')) return '📅';
if (name.contains('') || name.contains('')) return '🥘';
return '🍽️';
}
Future<void> _browseByTime(EatingTimeItem item) async {
showCupertinoModalPopup(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CupertinoActivityIndicator()),
);
try {
final searchKeyword = item.name
.replaceAll(RegExp(r'[时段均可作菜佐食。]'), '')
.trim();
final result = await _recipeRepo
.fetchList(
search: searchKeyword.isNotEmpty ? searchKeyword : item.name,
page: 1,
limit: 20,
)
.timeout(const Duration(seconds: 10));
Navigator.of(context).pop();
if (result.items.isNotEmpty) {
Get.to(
EatingTimeRecipesPage(
title: '${_getTimeIcon(item.name)} ${item.name}',
recipes: result.items,
),
);
} else {
_showToast('暂无"${item.name}"相关菜谱');
}
} catch (e) {
Navigator.of(context).pop();
debugPrint('Browse by time error: $e');
_showToast('加载失败,请重试');
}
}
void _showToast(String msg) {
Get.snackbar(
'提示',
msg,
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2),
);
}
}
class EatingTimeRecipesPage extends StatelessWidget {
final String title;
final List<RecipeModel> recipes;
const EatingTimeRecipesPage({
super.key,
required this.title,
required this.recipes,
});
@override
Widget build(BuildContext context) {
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(
title,
style: TextStyle(
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
backgroundColor: isDark
? DarkDesignTokens.background.withValues(alpha: 0.85)
: DesignTokens.background.withValues(alpha: 0.85),
border: null,
),
child: SafeArea(
child: ListView.builder(
padding: const EdgeInsets.all(DesignTokens.space4),
itemCount: recipes.length,
itemBuilder: (context, index) {
final recipe = recipes[index];
return _buildRecipeCard(recipe, isDark);
},
),
),
);
}
Widget _buildRecipeCard(RecipeModel recipe, bool isDark) {
return GestureDetector(
onTap: () => Get.toNamed('/recipe-detail', arguments: '${recipe.id}'),
child: Container(
margin: const EdgeInsets.only(bottom: DesignTokens.space2),
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: Row(
children: [
ClipRRect(
borderRadius: DesignTokens.borderRadiusSm,
child: RecipeImage(
recipeId: recipe.id,
coverUrl: recipe.cover,
width: 64,
height: 64,
fit: BoxFit.cover,
mode: RecipeImageMode.thumbnail,
),
),
const SizedBox(width: DesignTokens.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipe.title,
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w500,
color: isDark
? DarkDesignTokens.text1
: DesignTokens.text1,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (recipe.intro != null && recipe.intro!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
recipe.intro!,
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: isDark
? DarkDesignTokens.text3
: DesignTokens.text3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
if (recipe.categoryName != null &&
recipe.categoryName!.isNotEmpty) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color:
(isDark
? DarkDesignTokens.primary
: DesignTokens.primary)
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
recipe.categoryName!,
style: TextStyle(
fontSize: 10,
color: isDark
? DarkDesignTokens.primary
: DesignTokens.primary,
),
),
),
],
],
),
),
Icon(
CupertinoIcons.chevron_forward,
size: 16,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
],
),
),
);
}
}