570 lines
18 KiB
Dart
570 lines
18 KiB
Dart
/*
|
||
* 文件: 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,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|