a85890f30a
- 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>
356 lines
17 KiB
Dart
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,
|
|
),
|
|
);
|
|
}
|
|
}
|