642 lines
20 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|