Files
kitchen/lib/src/pages/tools/ranking/dish_pick_sheet.dart
Developer 236bffb1bc 重构4
2026-04-19 04:26:55 +08:00

642 lines
20 KiB
Dart

/*
* 文件: dish_pick_sheet.dart
* 名称: 菜品选择面板
* 作用: 底部弹出面板,支持从浏览记录/收藏/手动输入三种方式选择菜品加入Tier List
* 创建: 2026-04-16 初始创建
*/
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart' as material;
import 'package:get/get.dart';
import 'package:mom_kitchen/src/config/design_tokens.dart';
import 'package:mom_kitchen/src/controllers/data/browse_history_controller.dart';
import 'package:mom_kitchen/src/controllers/user/favorites_controller.dart';
import 'package:mom_kitchen/src/models/data/record/browse_history_model.dart';
import 'package:mom_kitchen/src/models/feed/dish_rank_model.dart';
import 'package:mom_kitchen/src/models/feed/feed_item_model.dart';
class DishPickSheet extends StatefulWidget {
final int targetTierIndex;
final Function(DishRankItem) onDishSelected;
final Set<String> existingSourceIds;
const DishPickSheet({
super.key,
required this.targetTierIndex,
required this.onDishSelected,
this.existingSourceIds = const {},
});
static Future<void> show(
BuildContext context, {
required int targetTierIndex,
required Function(DishRankItem) onDishSelected,
Set<String> existingSourceIds = const {},
}) {
return showCupertinoModalPopup<void>(
context: context,
builder: (ctx) => DishPickSheet(
targetTierIndex: targetTierIndex,
onDishSelected: onDishSelected,
existingSourceIds: existingSourceIds,
),
);
}
@override
State<DishPickSheet> createState() => _DishPickSheetState();
}
class _DishPickSheetState extends State<DishPickSheet> {
int _selectedTab = 0;
String _searchQuery = '';
final TextEditingController _nameController = TextEditingController();
String _selectedEmoji = '🍽️';
static const List<String> _tabLabels = ['浏览记录', '我的收藏', '手动输入'];
static const List<String> _foodEmojis = [
'🍽️',
'🍳',
'🍔',
'🍕',
'🍜',
'🍣',
'🍱',
'🥘',
'🍲',
'🥗',
'🌮',
'🥟',
'🍗',
'🥩',
'🐟',
'🦀',
'🍰',
'🧁',
'🍦',
'🍪',
'',
'🍵',
'🍹',
'🍺',
];
bool get isDark => CupertinoTheme.brightnessOf(context) == Brightness.dark;
List<BrowseHistoryModel> get _filteredHistory {
try {
final controller = Get.find<BrowseHistoryController>();
var items = controller.history;
if (_searchQuery.isNotEmpty) {
final q = _searchQuery.toLowerCase();
items = items.where((e) => e.title.toLowerCase().contains(q)).toList();
}
return items;
} catch (_) {
return [];
}
}
List<FeedItemModel> get _filteredFavorites {
try {
final controller = Get.find<FavoritesController>();
var items = controller.allFavorites;
if (_searchQuery.isNotEmpty) {
final q = _searchQuery.toLowerCase();
items = items.where((e) => e.title.toLowerCase().contains(q)).toList();
}
return items;
} catch (_) {
return [];
}
}
void _onSelectFromHistory(BrowseHistoryModel item) {
if (widget.existingSourceIds.contains(item.recipeId)) return;
final dishItem = DishRankItem(
id: 'hist_${item.recipeId}_${DateTime.now().millisecondsSinceEpoch}',
name: item.title,
coverImage: item.coverImage,
tierIndex: widget.targetTierIndex,
isCustom: false,
sourceId: item.recipeId,
);
widget.onDishSelected(dishItem);
Navigator.of(context).pop();
}
void _onSelectFromFavorite(FeedItemModel item) {
final sid = item.id.toString();
if (widget.existingSourceIds.contains(sid)) return;
final dishItem = DishRankItem(
id: 'fav_${item.id}_${DateTime.now().millisecondsSinceEpoch}',
name: item.title,
emoji: item.favoriteType.icon,
coverImage: item.cover,
tierIndex: widget.targetTierIndex,
isCustom: false,
sourceId: sid,
);
widget.onDishSelected(dishItem);
Navigator.of(context).pop();
}
void _onManualAdd() {
final name = _nameController.text.trim();
if (name.isEmpty) return;
final dishItem = DishRankItem(
id: 'custom_${DateTime.now().millisecondsSinceEpoch}',
name: name,
emoji: _selectedEmoji,
tierIndex: widget.targetTierIndex,
isCustom: true,
);
widget.onDishSelected(dishItem);
Navigator.of(context).pop();
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final bottomPadding = MediaQuery.of(context).viewInsets.bottom;
final isDark = this.isDark;
return material.Material(
color: material.Colors.transparent,
child: Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.7,
),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1C1C1E) : material.Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: material.MainAxisSize.min,
children: [
_buildHandle(isDark),
_buildHeader(isDark),
_buildSegmentedControl(isDark),
if (_selectedTab < 2) _buildSearchBar(isDark),
Flexible(child: _buildContent(isDark, bottomPadding)),
],
),
),
);
}
Widget _buildHandle(bool isDark) {
return Center(
child: Container(
margin: const EdgeInsets.only(top: 10),
width: 40,
height: 4,
decoration: BoxDecoration(
color: isDark ? material.Colors.white24 : material.Colors.black12,
borderRadius: BorderRadius.circular(2),
),
),
);
}
Widget _buildHeader(bool isDark) {
return Padding(
padding: EdgeInsets.symmetric(
horizontal: DesignTokens.space4,
vertical: DesignTokens.space2,
),
child: Row(
children: [
Text(
'添加到「${TierDefinition.getTierName(widget.targetTierIndex)}',
style: TextStyle(
fontSize: DesignTokens.fontLg,
fontWeight: FontWeight.w700,
color: isDark ? material.Colors.white : DesignTokens.text1,
),
),
const Spacer(),
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
padding: const EdgeInsets.all(8),
child: Text(
'取消',
style: TextStyle(
fontSize: DesignTokens.fontMd,
color: DesignTokens.dynamicPrimary,
),
),
),
),
],
),
);
}
Widget _buildSegmentedControl(bool isDark) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: DesignTokens.space4),
child: Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF2C2C2E) : const Color(0xFFF2F2F7),
borderRadius: DesignTokens.borderRadiusSm,
),
child: CupertinoSlidingSegmentedControl<int>(
groupValue: _selectedTab,
children: {
for (var i = 0; i < _tabLabels.length; i++)
i: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
child: Text(
_tabLabels[i],
style: TextStyle(fontSize: DesignTokens.fontSm),
),
),
},
onValueChanged: (v) {
if (v != null) setState(() => _selectedTab = v);
},
),
),
);
}
Widget _buildSearchBar(bool isDark) {
return Padding(
padding: EdgeInsets.fromLTRB(
DesignTokens.space4,
DesignTokens.space2,
DesignTokens.space4,
0,
),
child: Container(
height: 36,
decoration: BoxDecoration(
color: isDark ? const Color(0xFF2C2C2E) : const Color(0xFFF2F2F7),
borderRadius: DesignTokens.borderRadiusSm,
),
child: Row(
children: [
const SizedBox(width: 10),
Icon(
CupertinoIcons.search,
size: 16,
color: isDark ? material.Colors.white54 : material.Colors.black38,
),
const SizedBox(width: 8),
Expanded(
child: CupertinoTextField(
placeholder: '搜索菜品...',
placeholderStyle: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark
? material.Colors.white38
: material.Colors.black26,
),
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark ? material.Colors.white : material.Colors.black,
),
decoration: null,
onChanged: (v) => setState(() => _searchQuery = v),
),
),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () => setState(() => _searchQuery = ''),
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: Icon(
CupertinoIcons.clear_circled_solid,
size: 14,
color: isDark
? material.Colors.white38
: material.Colors.black26,
),
),
),
],
),
),
);
}
Widget _buildContent(bool isDark, double bottomPadding) {
switch (_selectedTab) {
case 0:
return _buildHistoryList(isDark, bottomPadding);
case 1:
return _buildFavoriteList(isDark, bottomPadding);
case 2:
return _buildManualInput(isDark, bottomPadding);
default:
return const SizedBox.shrink();
}
}
Widget _buildHistoryList(bool isDark, double bottomPadding) {
final items = _filteredHistory;
if (items.isEmpty) {
return _buildEmpty('暂无浏览记录,去发现更多菜谱吧~', isDark);
}
return ListView.separated(
shrinkWrap: true,
padding: EdgeInsets.only(
left: DesignTokens.space4,
right: DesignTokens.space4,
bottom: bottomPadding + DesignTokens.space3,
),
itemCount: items.length,
separatorBuilder: (_, __) => material.Divider(
height: 1,
color: isDark ? material.Colors.white10 : const Color(0x0F000000),
),
itemBuilder: (context, index) {
final item = items[index];
final added = widget.existingSourceIds.contains(item.recipeId);
return _buildDishTile(
title: item.title,
subtitle: item.displayDate,
coverUrl: item.coverImage,
emoji: '📖',
isAdded: added,
isDark: isDark,
onTap: added ? null : () => _onSelectFromHistory(item),
);
},
);
}
Widget _buildFavoriteList(bool isDark, double bottomPadding) {
final items = _filteredFavorites;
if (items.isEmpty) {
return _buildEmpty('暂无收藏,去收藏喜欢的菜谱吧~', isDark);
}
return ListView.separated(
shrinkWrap: true,
padding: EdgeInsets.only(
left: DesignTokens.space4,
right: DesignTokens.space4,
bottom: bottomPadding + DesignTokens.space3,
),
itemCount: items.length,
separatorBuilder: (_, __) => material.Divider(
height: 1,
color: isDark ? material.Colors.white10 : const Color(0x0F000000),
),
itemBuilder: (context, index) {
final item = items[index];
final sid = item.id.toString();
final added = widget.existingSourceIds.contains(sid);
return _buildDishTile(
title: item.title,
subtitle: item.categoryName ?? item.favoriteType.label,
coverUrl: item.cover,
emoji: item.favoriteType.icon,
isAdded: added,
isDark: isDark,
onTap: added ? null : () => _onSelectFromFavorite(item),
);
},
);
}
Widget _buildManualInput(bool isDark, double bottomPadding) {
return SingleChildScrollView(
padding: EdgeInsets.only(
left: DesignTokens.space4,
right: DesignTokens.space4,
bottom: bottomPadding + DesignTokens.space3,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: DesignTokens.space2),
Text(
'菜品名称',
style: TextStyle(
fontSize: DesignTokens.fontSm,
fontWeight: FontWeight.w600,
color: isDark ? material.Colors.white60 : material.Colors.black54,
),
),
const SizedBox(height: 6),
Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF2C2C2E) : const Color(0xFFF2F2F7),
borderRadius: DesignTokens.borderRadiusSm,
),
child: CupertinoTextField(
controller: _nameController,
placeholder: '输入菜品名称...',
placeholderStyle: TextStyle(
fontSize: DesignTokens.fontMd,
color: isDark
? material.Colors.white38
: material.Colors.black26,
),
style: TextStyle(
fontSize: DesignTokens.fontMd,
color: isDark ? material.Colors.white : material.Colors.black,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: null,
),
),
const SizedBox(height: DesignTokens.space3),
Text(
'选择图标',
style: TextStyle(
fontSize: DesignTokens.fontSm,
fontWeight: FontWeight.w600,
color: isDark ? material.Colors.white60 : material.Colors.black54,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _foodEmojis.map((emoji) {
final isSelected = emoji == _selectedEmoji;
return GestureDetector(
onTap: () => setState(() => _selectedEmoji = emoji),
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: isSelected
? DesignTokens.dynamicPrimary.withValues(alpha: 0.15)
: (isDark
? const Color(0xFF2C2C2E)
: const Color(0xFFF2F2F7)),
borderRadius: DesignTokens.borderRadiusSm,
border: isSelected
? Border.all(
color: DesignTokens.dynamicPrimary,
width: 2,
)
: null,
),
child: Center(
child: Text(emoji, style: const TextStyle(fontSize: 22)),
),
),
);
}).toList(),
),
const SizedBox(height: DesignTokens.space4),
SizedBox(
width: double.infinity,
height: 48,
child: CupertinoButton(
color: DesignTokens.dynamicPrimary,
borderRadius: DesignTokens.borderRadiusMd,
onPressed: _onManualAdd,
child: Text(
'添加到「${TierDefinition.getTierName(widget.targetTierIndex)}',
style: const TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w600,
color: material.Colors.white,
),
),
),
),
],
),
);
}
Widget _buildDishTile({
required String title,
required String subtitle,
String? coverUrl,
required String emoji,
required bool isAdded,
required bool isDark,
VoidCallback? onTap,
}) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: isDark
? const Color(0xFF2C2C2E)
: const Color(0xFFF2F2F7),
borderRadius: DesignTokens.borderRadiusSm,
),
clipBehavior: Clip.antiAlias,
child: coverUrl != null && coverUrl.isNotEmpty
? Image.network(
coverUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Center(
child: Text(
emoji,
style: const TextStyle(fontSize: 22),
),
),
)
: Center(
child: Text(emoji, style: const TextStyle(fontSize: 22)),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w600,
color: isAdded
? (isDark
? material.Colors.white30
: material.Colors.black26)
: (isDark
? material.Colors.white
: DesignTokens.text1),
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: isDark
? material.Colors.white30
: material.Colors.black38,
),
),
],
),
),
if (isAdded)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0x1A34C759),
borderRadius: DesignTokens.borderRadiusSm,
),
child: Text(
'已添加',
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: const Color(0xFF34C759),
),
),
)
else
Icon(
CupertinoIcons.add_circled,
size: 24,
color: DesignTokens.dynamicPrimary,
),
],
),
),
);
}
Widget _buildEmpty(String message, bool isDark) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('📭', style: TextStyle(fontSize: 48)),
const SizedBox(height: 12),
Text(
message,
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark ? material.Colors.white30 : material.Colors.black38,
),
),
],
),
);
}
}