Files
meezi/mobile/meezi_app/lib/features/menu/menu_screen.dart
T
soroush.asadi a85890f30a chore: Flutter mobile app, CI, and dev tooling
- mobile/: Flutter/Dart merchant mobile app skeleton
- .github/: GitHub Actions CI workflows
- .dockerignore: exclude host node_modules from build context
- .cursorrules: Cursor IDE project rules
- .claude/: Claude Code project settings and launch config

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-27 21:35:27 +03:30

356 lines
17 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/menu_item_visual.dart';
import '../../core/utils/currency_utils.dart';
import '../public/public_api.dart';
import '../cart/cart_state.dart';
import '../table/table_context.dart';
final menuProvider = FutureProvider.autoDispose.family<Map<String, dynamic>?, String>((ref, slug) {
return ref.watch(publicApiProvider).getMenu(slug);
});
int _salePrice(int price, num discountPercent) {
if (discountPercent <= 0) return price;
return (price * (1 - discountPercent / 100)).round();
}
String? _imageUrl(String? path) {
if (path == null || path.isEmpty) return null;
if (path.startsWith('http')) return path;
const base = String.fromEnvironment('API_BASE', defaultValue: 'http://10.0.2.2:5080');
return '$base$path';
}
String _menuPrimaryName(Map<String, dynamic> item, String languageCode) {
final fa = item['name'] as String? ?? '';
final en = item['nameEn'] as String? ?? '';
final ar = item['nameAr'] as String? ?? '';
if (languageCode == 'en') return en.isNotEmpty ? en : fa;
if (languageCode == 'ar') return ar.isNotEmpty ? ar : fa;
return fa;
}
String? _menuEnglishSubtitle(Map<String, dynamic> item, String languageCode) {
final en = (item['nameEn'] as String?)?.trim() ?? '';
if (en.isEmpty || languageCode == 'en') return null;
final primary = _menuPrimaryName(item, languageCode);
if (primary == en) return null;
return en;
}
class MenuScreen extends ConsumerStatefulWidget {
const MenuScreen({super.key, required this.slug, this.tableId, this.tableNumber});
final String slug;
final String? tableId;
final String? tableNumber;
@override
ConsumerState<MenuScreen> createState() => _MenuScreenState();
}
class _MenuScreenState extends ConsumerState<MenuScreen> {
bool _contextSet = false;
@override
Widget build(BuildContext context) {
final menuAsync = ref.watch(menuProvider(widget.slug));
final cart = ref.watch(cartProvider);
final tableLabel = widget.tableNumber;
return Directionality(
textDirection: TextDirection.rtl,
child: Scaffold(
backgroundColor: const Color(0xFFF8FAFB),
appBar: AppBar(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF0F6E56),
elevation: 0,
title: Text(tableLabel != null ? 'منو — میز $tableLabel' : 'منو'),
actions: [
IconButton(
icon: const Icon(Icons.event_seat_outlined),
onPressed: () => context.push('/cafe/${widget.slug}/reserve'),
),
if (cart.itemCount > 0)
TextButton(
onPressed: () => context.push('/cafe/${widget.slug}/cart'),
child: Text('سبد (${cart.itemCount})'),
),
],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (tableLabel != null)
Container(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFE1F5EE),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF0F6E56).withValues(alpha: 0.3)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.table_restaurant, color: Color(0xFF0F6E56), size: 20),
const SizedBox(width: 8),
Text(
'میز $tableLabel',
style: const TextStyle(
color: Color(0xFF0F6E56),
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
],
),
),
Expanded(
child: menuAsync.when(
data: (menu) {
if (menu == null) return const Center(child: Text('منو یافت نشد'));
if (!_contextSet) {
_contextSet = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(cartProvider.notifier).setContext(
slug: widget.slug,
cafeName: menu['cafeName'] as String? ?? widget.slug,
tableId: widget.tableId,
tableNumber: tableLabel != null ? int.tryParse(tableLabel) : null,
);
if (widget.tableId != null) {
ref.read(tableContextProvider.notifier).setTable(
tableId: widget.tableId!,
tableNumber: tableLabel ?? '',
cafeSlug: widget.slug,
);
}
});
}
final categories = menu['categories'] as List<dynamic>? ?? [];
final lang = Localizations.localeOf(context).languageCode;
return ListView(
padding: const EdgeInsets.all(16),
children: [
for (final cat in categories) ...[
Padding(
padding: const EdgeInsets.only(bottom: 8, top: 4),
child: Text(
_menuPrimaryName(cat as Map<String, dynamic>, lang),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
color: Color(0xFF64748B),
),
),
),
...((cat['items'] as List<dynamic>? ?? []).map((item) {
final id = item['id'] as String;
final catMap = cat as Map<String, dynamic>;
final catId = catMap['id'] as String? ?? '';
final catName = _menuPrimaryName(catMap, lang);
final name = _menuPrimaryName(item as Map<String, dynamic>, lang);
final nameEnSub = _menuEnglishSubtitle(item as Map<String, dynamic>, lang);
final price = (item['price'] as num).toInt();
final discount = (item['discountPercent'] as num?) ?? 0;
final sale = _salePrice(price, discount);
final img = _imageUrl(item['imageUrl'] as String?);
final video = _imageUrl(item['videoUrl'] as String?);
final visualKind = inferMenuItemKind(
categoryId: catId,
categoryName: catName,
);
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.shade200),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
ref.read(cartProvider.notifier).addItem(
CartLine(
menuItemId: id,
name: name,
unitPrice: sale,
),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$name به سبد اضافه شد')),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Stack(
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: img != null
? Image.network(img, fit: BoxFit.cover)
: Container(
color: visualKind == MenuItemVisualKind.drink
? const Color(0xFFE8F4F8)
: const Color(0xFFF5F0EB),
child: Icon(
visualKind == MenuItemVisualKind.drink
? Icons.local_cafe_outlined
: Icons.restaurant_outlined,
size: 48,
color: const Color(0xFF0F6E56).withValues(alpha: 0.45),
),
),
),
if (video != null)
Positioned(
bottom: 8,
right: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(6),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.play_circle_outline,
size: 14, color: Colors.white),
SizedBox(width: 4),
Text(
'ویدیو',
style: TextStyle(
fontSize: 11,
color: Colors.white,
),
),
],
),
),
),
if (discount > 0)
Positioned(
top: 8,
left: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFFFFF8E8),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFFBA7517)),
),
child: Text(
'${discount.toInt()}٪ تخفیف',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFFBA7517),
),
),
),
),
],
),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
if (nameEnSub != null) ...[
const SizedBox(height: 2),
Text(
nameEnSub,
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
),
),
],
const SizedBox(height: 4),
Row(
children: [
if (discount > 0) ...[
Text(
formatToman(price),
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
decoration: TextDecoration.lineThrough,
),
),
const SizedBox(width: 8),
],
Text(
formatToman(sale),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF0F6E56),
),
),
],
),
],
),
),
const Icon(
Icons.add_circle,
color: Color(0xFF0F6E56),
size: 32,
),
],
),
),
],
),
),
);
})),
const SizedBox(height: 8),
],
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('خطا: $e')),
),
),
],
),
floatingActionButton: cart.itemCount > 0
? FloatingActionButton.extended(
backgroundColor: const Color(0xFF0F6E56),
onPressed: () => context.push('/cafe/${widget.slug}/cart'),
label: Text('سبد — ${formatToman(cart.subtotal)}'),
icon: const Icon(Icons.shopping_cart),
)
: null,
),
);
}
}