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>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
# Meezi mobile app
|
||||
|
||||
Flutter 3 app — customer (discover, QR menu, cart, reservations) + staff HR.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install [Flutter 3.x](https://docs.flutter.dev/get-started/install) and add it to `PATH`.
|
||||
2. From this folder:
|
||||
|
||||
```bash
|
||||
flutter create . --project-name meezi_app
|
||||
flutter pub get
|
||||
flutter run -d chrome
|
||||
```
|
||||
|
||||
3. Set API base URL in `lib/core/api/api_config.dart` (Docker default: `http://localhost:5080`).
|
||||
|
||||
## Routes
|
||||
|
||||
| Path | Screen |
|
||||
|------|--------|
|
||||
| `/discover` | Café list |
|
||||
| `/qr` | Enter table QR (`demo_table_01`) |
|
||||
| `/cafe/:slug/menu` | Menu + add to cart |
|
||||
| `/cafe/:slug/cart` | Checkout |
|
||||
| `/cafe/:slug/reserve` | Table reservation |
|
||||
| `/order/:id/track` | Order status |
|
||||
| `/hr/attendance` | Staff clock-in |
|
||||
|
||||
Demo: open `/qr` → `demo_table_01` → order → track.
|
||||
@@ -0,0 +1,5 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
prefer_const_constructors: true
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../features/cart/cart_screen.dart';
|
||||
import '../features/discover/cafe_detail_screen.dart';
|
||||
import '../features/discover/discover_screen.dart';
|
||||
import '../features/hr/attendance_screen.dart';
|
||||
import '../features/menu/menu_screen.dart';
|
||||
import '../features/qr/qr_scan_screen.dart';
|
||||
import '../features/reserve/reserve_screen.dart';
|
||||
import '../features/track/track_screen.dart';
|
||||
|
||||
final appRouter = GoRouter(
|
||||
initialLocation: '/discover',
|
||||
routes: [
|
||||
GoRoute(path: '/discover', builder: (_, __) => const DiscoverScreen()),
|
||||
GoRoute(
|
||||
path: '/cafe/:slug',
|
||||
builder: (_, state) => CafeDetailScreen(slug: state.pathParameters['slug']!),
|
||||
),
|
||||
GoRoute(path: '/qr', builder: (_, __) => const QrScanScreen()),
|
||||
GoRoute(path: '/hr/attendance', builder: (_, __) => const AttendanceScreen()),
|
||||
GoRoute(
|
||||
path: '/cafe/:slug/menu',
|
||||
builder: (context, state) {
|
||||
final slug = state.pathParameters['slug']!;
|
||||
final tableId = state.uri.queryParameters['tableId'];
|
||||
final tableNumber = state.uri.queryParameters['tableNumber'];
|
||||
return MenuScreen(slug: slug, tableId: tableId, tableNumber: tableNumber);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/cafe/:slug/cart',
|
||||
builder: (_, state) => CartScreen(slug: state.pathParameters['slug']!),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/cafe/:slug/reserve',
|
||||
builder: (_, state) => ReserveScreen(slug: state.pathParameters['slug']!),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/order/:orderId/track',
|
||||
builder: (_, state) => TrackScreen(orderId: state.pathParameters['orderId']!),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import 'api_config.dart';
|
||||
|
||||
class ApiClient {
|
||||
ApiClient({Dio? dio, FlutterSecureStorage? storage})
|
||||
: _storage = storage ?? const FlutterSecureStorage(),
|
||||
_dio = dio ??
|
||||
Dio(BaseOptions(
|
||||
baseUrl: apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
)) {
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
final token = await _storage.read(key: 'access_token');
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
final Dio _dio;
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
Dio get dio => _dio;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
const String apiBaseUrl = String.fromEnvironment(
|
||||
'MEEZI_API_URL',
|
||||
defaultValue: 'http://localhost:5080',
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
enum MenuItemVisualKind { food, drink }
|
||||
|
||||
const _drinkCategoryIds = {'cat_demo_drinks', 'cat_demo_cold'};
|
||||
|
||||
const _drinkHints = [
|
||||
'drink',
|
||||
'cold',
|
||||
'coffee',
|
||||
'tea',
|
||||
'juice',
|
||||
'smoothie',
|
||||
'beverage',
|
||||
'bar',
|
||||
'نوشیدنی',
|
||||
'سرد',
|
||||
'گرم',
|
||||
'قهوه',
|
||||
'چای',
|
||||
'آبمیوه',
|
||||
'اسموتی',
|
||||
'مشروب',
|
||||
'بار',
|
||||
];
|
||||
|
||||
MenuItemVisualKind inferMenuItemKind({
|
||||
required String categoryId,
|
||||
String? categoryName,
|
||||
}) {
|
||||
if (_drinkCategoryIds.contains(categoryId)) return MenuItemVisualKind.drink;
|
||||
|
||||
final haystack = '$categoryId ${categoryName ?? ''}'.toLowerCase();
|
||||
for (final hint in _drinkHints) {
|
||||
if (haystack.contains(hint)) return MenuItemVisualKind.drink;
|
||||
}
|
||||
return MenuItemVisualKind.food;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/// Queues HR attendance actions when offline; syncs on reconnect.
|
||||
class SyncEngine {
|
||||
final List<Map<String, dynamic>> _queue = [];
|
||||
|
||||
List<Map<String, dynamic>> get pending => List.unmodifiable(_queue);
|
||||
|
||||
void enqueueAttendance({
|
||||
required String action,
|
||||
required String cafeId,
|
||||
required String employeeId,
|
||||
}) {
|
||||
_queue.add({
|
||||
'type': 'attendance',
|
||||
'action': action,
|
||||
'cafeId': cafeId,
|
||||
'employeeId': employeeId,
|
||||
'at': DateTime.now().toUtc().toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
void clear() => _queue.clear();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
String formatToman(num value) => '${value.toStringAsFixed(0)} ت';
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/sync/sync_engine.dart';
|
||||
import '../../core/utils/currency_utils.dart';
|
||||
import 'cart_state.dart';
|
||||
|
||||
class CartScreen extends ConsumerStatefulWidget {
|
||||
const CartScreen({super.key, required this.slug});
|
||||
|
||||
final String slug;
|
||||
|
||||
@override
|
||||
ConsumerState<CartScreen> createState() => _CartScreenState();
|
||||
}
|
||||
|
||||
class _CartScreenState extends ConsumerState<CartScreen> {
|
||||
final _phoneController = TextEditingController();
|
||||
final _nameController = TextEditingController();
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _checkout() async {
|
||||
final cart = ref.read(cartProvider);
|
||||
if (cart.lines.isEmpty) return;
|
||||
|
||||
setState(() => _submitting = true);
|
||||
final api = ref.read(publicApiProvider);
|
||||
final sync = SyncEngine();
|
||||
|
||||
try {
|
||||
final result = await api.placeOrder(
|
||||
widget.slug,
|
||||
tableId: cart.tableId,
|
||||
items: cart.lines.map((l) => l.toOrderJson()).toList(),
|
||||
guestPhone: _phoneController.text.isEmpty ? null : _phoneController.text,
|
||||
guestName: _nameController.text.isEmpty ? null : _nameController.text,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (result != null) {
|
||||
ref.read(cartProvider.notifier).clear();
|
||||
final orderId = result['orderId'] as String;
|
||||
context.go('/order/$orderId/track');
|
||||
}
|
||||
} catch (_) {
|
||||
sync.enqueueAttendance(
|
||||
action: 'guest-order',
|
||||
cafeId: widget.slug,
|
||||
employeeId: cart.tableId ?? '',
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('آفلاین ذخیره شد — پس از اتصال دوباره تلاش کنید')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cart = ref.watch(cartProvider);
|
||||
|
||||
return Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('سبد خرید')),
|
||||
body: cart.lines.isEmpty
|
||||
? const Center(child: Text('سبد خالی است'))
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(cart.cafeName ?? '', style: Theme.of(context).textTheme.titleLarge),
|
||||
if (cart.tableNumber != null)
|
||||
Text('میز ${cart.tableNumber}'),
|
||||
const SizedBox(height: 16),
|
||||
...cart.lines.map(
|
||||
(line) => ListTile(
|
||||
title: Text('${line.name} × ${line.quantity}'),
|
||||
subtitle: Text(formatToman(line.lineTotal)),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () => ref.read(cartProvider.notifier).removeItem(line.menuItemId),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Text('جمع: ${formatToman(cart.subtotal)}', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(labelText: 'نام (اختیاری)'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _phoneController,
|
||||
decoration: const InputDecoration(labelText: 'موبایل (اختیاری)'),
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _submitting ? null : _checkout,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('ثبت سفارش'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../public/public_api.dart';
|
||||
|
||||
class CartLine {
|
||||
CartLine({
|
||||
required this.menuItemId,
|
||||
required this.name,
|
||||
required this.unitPrice,
|
||||
this.quantity = 1,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
final String menuItemId;
|
||||
final String name;
|
||||
final int unitPrice;
|
||||
int quantity;
|
||||
final String? notes;
|
||||
|
||||
int get lineTotal => unitPrice * quantity;
|
||||
|
||||
Map<String, dynamic> toOrderJson() => {
|
||||
'menuItemId': menuItemId,
|
||||
'quantity': quantity,
|
||||
if (notes != null && notes!.isNotEmpty) 'notes': notes,
|
||||
};
|
||||
}
|
||||
|
||||
class CartState {
|
||||
CartState({
|
||||
this.cafeSlug,
|
||||
this.cafeName,
|
||||
this.tableId,
|
||||
this.tableNumber,
|
||||
this.lines = const [],
|
||||
});
|
||||
|
||||
final String? cafeSlug;
|
||||
final String? cafeName;
|
||||
final String? tableId;
|
||||
final int? tableNumber;
|
||||
final List<CartLine> lines;
|
||||
|
||||
int get itemCount => lines.fold(0, (sum, l) => sum + l.quantity);
|
||||
int get subtotal => lines.fold(0, (sum, l) => sum + l.lineTotal);
|
||||
|
||||
CartState copyWith({
|
||||
String? cafeSlug,
|
||||
String? cafeName,
|
||||
String? tableId,
|
||||
int? tableNumber,
|
||||
List<CartLine>? lines,
|
||||
}) =>
|
||||
CartState(
|
||||
cafeSlug: cafeSlug ?? this.cafeSlug,
|
||||
cafeName: cafeName ?? this.cafeName,
|
||||
tableId: tableId ?? this.tableId,
|
||||
tableNumber: tableNumber ?? this.tableNumber,
|
||||
lines: lines ?? this.lines,
|
||||
);
|
||||
}
|
||||
|
||||
class CartNotifier extends StateNotifier<CartState> {
|
||||
CartNotifier() : super(CartState());
|
||||
|
||||
void setContext({
|
||||
required String slug,
|
||||
required String cafeName,
|
||||
String? tableId,
|
||||
int? tableNumber,
|
||||
}) {
|
||||
state = CartState(
|
||||
cafeSlug: slug,
|
||||
cafeName: cafeName,
|
||||
tableId: tableId,
|
||||
tableNumber: tableNumber,
|
||||
lines: state.lines,
|
||||
);
|
||||
}
|
||||
|
||||
void addItem(CartLine line) {
|
||||
final existing = state.lines.where((l) => l.menuItemId == line.menuItemId).toList();
|
||||
if (existing.isNotEmpty) {
|
||||
existing.first.quantity += line.quantity;
|
||||
state = state.copyWith(lines: [...state.lines]);
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(lines: [...state.lines, line]);
|
||||
}
|
||||
|
||||
void removeItem(String menuItemId) {
|
||||
state = state.copyWith(
|
||||
lines: state.lines.where((l) => l.menuItemId != menuItemId).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
void clear() => state = CartState();
|
||||
}
|
||||
|
||||
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient());
|
||||
|
||||
final publicApiProvider = Provider<PublicApi>((ref) => PublicApi(ref.watch(apiClientProvider)));
|
||||
|
||||
final cartProvider = StateNotifierProvider<CartNotifier, CartState>((ref) => CartNotifier());
|
||||
@@ -0,0 +1,233 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../cart/cart_state.dart';
|
||||
|
||||
final cafeDetailProvider =
|
||||
FutureProvider.autoDispose.family<Map<String, dynamic>?, String>((ref, slug) {
|
||||
return ref.watch(publicApiProvider).getCafe(slug);
|
||||
});
|
||||
|
||||
final cafeReviewsProvider =
|
||||
FutureProvider.autoDispose.family<List<Map<String, dynamic>>, String>((ref, slug) {
|
||||
return ref.watch(publicApiProvider).getReviews(slug);
|
||||
});
|
||||
|
||||
class CafeDetailScreen extends ConsumerStatefulWidget {
|
||||
const CafeDetailScreen({super.key, required this.slug});
|
||||
|
||||
final String slug;
|
||||
|
||||
@override
|
||||
ConsumerState<CafeDetailScreen> createState() => _CafeDetailScreenState();
|
||||
}
|
||||
|
||||
class _CafeDetailScreenState extends ConsumerState<CafeDetailScreen> {
|
||||
final _nameController = TextEditingController();
|
||||
final _commentController = TextEditingController();
|
||||
int _rating = 5;
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_commentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submitReview() async {
|
||||
final name = _nameController.text.trim();
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('نام خود را وارد کنید')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await ref.read(publicApiProvider).createReview(
|
||||
widget.slug,
|
||||
authorName: name,
|
||||
rating: _rating,
|
||||
comment: _commentController.text.trim(),
|
||||
);
|
||||
_commentController.clear();
|
||||
ref.invalidate(cafeReviewsProvider(widget.slug));
|
||||
ref.invalidate(cafeDetailProvider(widget.slug));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('نظر شما ثبت شد')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('خطا: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cafeAsync = ref.watch(cafeDetailProvider(widget.slug));
|
||||
final reviewsAsync = ref.watch(cafeReviewsProvider(widget.slug));
|
||||
|
||||
return Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('جزئیات کافه')),
|
||||
body: cafeAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('خطا: $e')),
|
||||
data: (cafe) {
|
||||
if (cafe == null) {
|
||||
return const Center(child: Text('کافه یافت نشد'));
|
||||
}
|
||||
final name = cafe['name'] as String? ?? widget.slug;
|
||||
final avg = (cafe['averageRating'] as num?)?.toDouble() ?? 0;
|
||||
final count = cafe['reviewCount'] as int? ?? 0;
|
||||
final description = cafe['description'] as String?;
|
||||
final address = cafe['address'] as String?;
|
||||
final city = cafe['city'] as String?;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(name, style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.star, color: Colors.amber, size: 20),
|
||||
const SizedBox(width: 4),
|
||||
Text('${avg.toStringAsFixed(1)} ($count نظر)'),
|
||||
],
|
||||
),
|
||||
if (city != null || address != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
[city, address].where((e) => e != null && e.isNotEmpty).join(' — '),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
if (description != null && description.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(description),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => context.push('/cafe/${widget.slug}/menu'),
|
||||
icon: const Icon(Icons.restaurant_menu),
|
||||
label: const Text('منو'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => context.push('/cafe/${widget.slug}/reserve'),
|
||||
icon: const Icon(Icons.event_seat_outlined),
|
||||
label: const Text('رزرو'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('نظرات', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
reviewsAsync.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => Text('خطا در بارگذاری نظرات: $e'),
|
||||
data: (reviews) {
|
||||
if (reviews.isEmpty) {
|
||||
return const Text('هنوز نظری ثبت نشده است.');
|
||||
}
|
||||
return Column(
|
||||
children: reviews.map((r) {
|
||||
final author = r['authorName'] as String? ?? '';
|
||||
final rating = r['rating'] as int? ?? 0;
|
||||
final comment = r['comment'] as String?;
|
||||
final reply = r['ownerReply'] as String?;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
title: Text(author),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('★' * rating + '☆' * (5 - rating)),
|
||||
if (comment != null && comment.isNotEmpty) Text(comment),
|
||||
if (reply != null && reply.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'پاسخ کافه: $reply',
|
||||
style: const TextStyle(color: Colors.teal),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('ثبت نظر', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'نام',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: List.generate(5, (i) {
|
||||
final star = i + 1;
|
||||
return IconButton(
|
||||
onPressed: () => setState(() => _rating = star),
|
||||
icon: Icon(
|
||||
star <= _rating ? Icons.star : Icons.star_border,
|
||||
color: Colors.amber,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
TextField(
|
||||
controller: _commentController,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'نظر (اختیاری)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _submitting ? null : _submitReview,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('ارسال نظر'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../cart/cart_state.dart';
|
||||
|
||||
typedef DiscoverFilters = ({String? q, double? minRating, String sort});
|
||||
|
||||
final discoverFiltersProvider = StateProvider<DiscoverFilters>(
|
||||
(_) => (q: null, minRating: null, sort: 'rating'),
|
||||
);
|
||||
|
||||
final discoverProvider = FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) {
|
||||
final filters = ref.watch(discoverFiltersProvider);
|
||||
return ref.watch(publicApiProvider).discover(
|
||||
city: 'تهران',
|
||||
q: filters.q,
|
||||
minRating: filters.minRating,
|
||||
sort: filters.sort,
|
||||
);
|
||||
});
|
||||
|
||||
class DiscoverScreen extends ConsumerStatefulWidget {
|
||||
const DiscoverScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DiscoverScreen> createState() => _DiscoverScreenState();
|
||||
}
|
||||
|
||||
class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _applySearch() {
|
||||
final q = _searchController.text.trim();
|
||||
ref.read(discoverFiltersProvider.notifier).update(
|
||||
(s) => (q: q.isEmpty ? null : q, minRating: s.minRating, sort: s.sort),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cafesAsync = ref.watch(discoverProvider);
|
||||
final filters = ref.watch(discoverFiltersProvider);
|
||||
|
||||
return Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('کافهیاب میزی'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code_scanner),
|
||||
onPressed: () => context.push('/qr'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'جستجوی نام کافه...',
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: _applySearch,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _applySearch(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
FilterChip(
|
||||
label: const Text('همه'),
|
||||
selected: filters.minRating == null,
|
||||
onSelected: (_) {
|
||||
ref.read(discoverFiltersProvider.notifier).update(
|
||||
(s) => (q: s.q, minRating: null, sort: s.sort),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
for (final min in [3.0, 4.0, 4.5])
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: FilterChip(
|
||||
label: Text('★ $min+'),
|
||||
selected: filters.minRating == min,
|
||||
onSelected: (_) {
|
||||
ref.read(discoverFiltersProvider.notifier).update(
|
||||
(s) => (q: s.q, minRating: min, sort: s.sort),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: filters.sort,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'مرتبسازی',
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'rating', child: Text('بیشترین امتیاز')),
|
||||
DropdownMenuItem(value: 'reviews', child: Text('بیشترین نظر')),
|
||||
DropdownMenuItem(value: 'name', child: Text('نام')),
|
||||
],
|
||||
onChanged: (sort) {
|
||||
if (sort == null) return;
|
||||
ref.read(discoverFiltersProvider.notifier).update(
|
||||
(s) => (q: s.q, minRating: s.minRating, sort: sort),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: cafesAsync.when(
|
||||
data: (cafes) {
|
||||
if (cafes.isEmpty) {
|
||||
return const Center(child: Text('کافهای یافت نشد'));
|
||||
}
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: cafes.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final cafe = cafes[index];
|
||||
final slug = cafe['slug'] as String;
|
||||
final name = cafe['name'] as String? ?? slug;
|
||||
final avg = (cafe['averageRating'] as num?)?.toDouble() ?? 0;
|
||||
final count = cafe['reviewCount'] as int? ?? 0;
|
||||
final address = cafe['address'] as String?;
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Text(name),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(cafe['city'] as String? ?? ''),
|
||||
if (address != null && address.isNotEmpty) Text(address),
|
||||
Text('★ ${avg.toStringAsFixed(1)} · $count نظر'),
|
||||
],
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_left),
|
||||
onTap: () => context.push('/cafe/$slug'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('خطا: $e')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shamsi_date/shamsi_date.dart';
|
||||
|
||||
import '../../core/sync/sync_engine.dart';
|
||||
import 'hr_api.dart';
|
||||
import 'hr_providers.dart';
|
||||
|
||||
class AttendanceScreen extends ConsumerStatefulWidget {
|
||||
const AttendanceScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AttendanceScreen> createState() => _AttendanceScreenState();
|
||||
}
|
||||
|
||||
class _AttendanceScreenState extends ConsumerState<AttendanceScreen> {
|
||||
final _reasonController = TextEditingController();
|
||||
Jalali? _leaveStart;
|
||||
Jalali? _leaveEnd;
|
||||
String? _message;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_reasonController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _clock(bool isIn) async {
|
||||
final session = ref.read(hrSessionProvider);
|
||||
if (session == null) {
|
||||
setState(() => _message = 'ابتدا وارد شوید');
|
||||
return;
|
||||
}
|
||||
final api = ref.read(hrApiProvider);
|
||||
final sync = ref.read(syncEngineProvider);
|
||||
try {
|
||||
if (isIn) {
|
||||
await api.clockIn(cafeId: session.cafeId, employeeId: session.employeeId);
|
||||
} else {
|
||||
await api.clockOut(cafeId: session.cafeId, employeeId: session.employeeId);
|
||||
}
|
||||
ref.invalidate(todayShiftProvider);
|
||||
setState(() => _message = isIn ? 'ورود ثبت شد' : 'خروج ثبت شد');
|
||||
} catch (_) {
|
||||
sync.enqueueAttendance(
|
||||
action: isIn ? 'clock-in' : 'clock-out',
|
||||
cafeId: session.cafeId,
|
||||
employeeId: session.employeeId,
|
||||
);
|
||||
setState(() => _message = 'آفلاین ذخیره شد — پس از اتصال همگامسازی میشود');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitLeave() async {
|
||||
final session = ref.read(hrSessionProvider);
|
||||
if (session == null || _leaveStart == null || _leaveEnd == null) return;
|
||||
final api = ref.read(hrApiProvider);
|
||||
try {
|
||||
await api.submitLeave(
|
||||
cafeId: session.cafeId,
|
||||
employeeId: session.employeeId,
|
||||
startDate: _formatDate(_leaveStart!),
|
||||
endDate: _formatDate(_leaveEnd!),
|
||||
reason: _reasonController.text,
|
||||
);
|
||||
setState(() => _message = 'درخواست مرخصی ثبت شد');
|
||||
} catch (e) {
|
||||
setState(() => _message = 'خطا در ثبت مرخصی');
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDate(Jalali d) =>
|
||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shiftAsync = ref.watch(todayShiftProvider);
|
||||
final todayJalali = Jalali.now();
|
||||
|
||||
return Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('حضور و غیاب')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(
|
||||
'امروز: ${todayJalali.formatter.yyyyMMdd()}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
shiftAsync.when(
|
||||
data: (shift) => Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('شیفت: ${shift?['label'] ?? '—'}'),
|
||||
),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => const Text('شیفت امروز در دسترس نیست'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () => _clock(true),
|
||||
child: const Text('ورود'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _clock(false),
|
||||
child: const Text('خروج'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Text('درخواست مرخصی', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
title: Text(_leaveStart == null ? 'از تاریخ' : _formatDate(_leaveStart!)),
|
||||
trailing: const Icon(Icons.calendar_today),
|
||||
onTap: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime.now(),
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 365)),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
if (picked != null) setState(() => _leaveStart = Jalali.fromDateTime(picked));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text(_leaveEnd == null ? 'تا تاریخ' : _formatDate(_leaveEnd!)),
|
||||
trailing: const Icon(Icons.calendar_today),
|
||||
onTap: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime.now(),
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 365)),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
if (picked != null) setState(() => _leaveEnd = Jalali.fromDateTime(picked));
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
controller: _reasonController,
|
||||
decoration: const InputDecoration(labelText: 'دلیل'),
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(onPressed: _submitLeave, child: const Text('ثبت مرخصی')),
|
||||
if (_message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(_message!, style: TextStyle(color: Theme.of(context).colorScheme.primary)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
|
||||
class HrApi {
|
||||
HrApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<Map<String, dynamic>?> fetchTodayShift({
|
||||
required String cafeId,
|
||||
required String employeeId,
|
||||
}) async {
|
||||
final res = await _client.dio.get<Map<String, dynamic>>(
|
||||
'/api/cafes/$cafeId/employees/$employeeId/shift/today',
|
||||
);
|
||||
final data = res.data?['data'] as Map<String, dynamic>?;
|
||||
return data;
|
||||
}
|
||||
|
||||
Future<void> clockIn({
|
||||
required String cafeId,
|
||||
required String employeeId,
|
||||
}) async {
|
||||
await _client.dio.post(
|
||||
'/api/cafes/$cafeId/employees/$employeeId/attendance/clock-in',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clockOut({
|
||||
required String cafeId,
|
||||
required String employeeId,
|
||||
}) async {
|
||||
await _client.dio.post(
|
||||
'/api/cafes/$cafeId/employees/$employeeId/attendance/clock-out',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> submitLeave({
|
||||
required String cafeId,
|
||||
required String employeeId,
|
||||
required String startDate,
|
||||
required String endDate,
|
||||
String? reason,
|
||||
}) async {
|
||||
await _client.dio.post(
|
||||
'/api/cafes/$cafeId/employees/$employeeId/leave-requests',
|
||||
data: {
|
||||
'startDate': startDate,
|
||||
'endDate': endDate,
|
||||
if (reason != null && reason.isNotEmpty) 'reason': reason,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/sync/sync_engine.dart';
|
||||
import '../cart/cart_state.dart' show apiClientProvider;
|
||||
import 'hr_api.dart';
|
||||
|
||||
class HrSession {
|
||||
const HrSession({required this.cafeId, required this.employeeId});
|
||||
final String cafeId;
|
||||
final String employeeId;
|
||||
}
|
||||
|
||||
const _demoCafeId = 'cafe_demo_001';
|
||||
const _demoEmployeeId = 'emp_demo_owner';
|
||||
|
||||
final hrApiProvider = Provider<HrApi>((ref) => HrApi(ref.watch(apiClientProvider)));
|
||||
|
||||
final syncEngineProvider = Provider<SyncEngine>((ref) => SyncEngine());
|
||||
|
||||
final hrSessionProvider = Provider<HrSession?>(
|
||||
(_) => const HrSession(cafeId: _demoCafeId, employeeId: _demoEmployeeId),
|
||||
);
|
||||
|
||||
final todayShiftProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
|
||||
final session = ref.watch(hrSessionProvider);
|
||||
if (session == null) return null;
|
||||
return ref.watch(hrApiProvider).fetchTodayShift(
|
||||
cafeId: session.cafeId,
|
||||
employeeId: session.employeeId,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,355 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import '../../core/api/api_client.dart';
|
||||
|
||||
class PublicApi {
|
||||
PublicApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<List<Map<String, dynamic>>> discover({
|
||||
String? city,
|
||||
String? q,
|
||||
double? minRating,
|
||||
String? sort,
|
||||
}) async {
|
||||
final params = <String, String>{};
|
||||
if (city != null && city.isNotEmpty) params['city'] = city;
|
||||
if (q != null && q.isNotEmpty) params['q'] = q;
|
||||
if (minRating != null) params['minRating'] = minRating.toString();
|
||||
if (sort != null && sort.isNotEmpty) params['sort'] = sort;
|
||||
final res = await _client.dio.get<Map<String, dynamic>>(
|
||||
'/api/public/discover',
|
||||
queryParameters: params.isEmpty ? null : params,
|
||||
);
|
||||
final list = res.data?['data'] as List<dynamic>? ?? [];
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getReviews(String slug, {int page = 1}) async {
|
||||
final res = await _client.dio.get<Map<String, dynamic>>(
|
||||
'/api/public/cafes/$slug/reviews',
|
||||
queryParameters: {'page': page, 'pageSize': 20},
|
||||
);
|
||||
final list = res.data?['data'] as List<dynamic>? ?? [];
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> createReview(
|
||||
String slug, {
|
||||
required String authorName,
|
||||
required int rating,
|
||||
String? comment,
|
||||
String? authorPhone,
|
||||
}) async {
|
||||
final res = await _client.dio.post<Map<String, dynamic>>(
|
||||
'/api/public/cafes/$slug/reviews',
|
||||
data: {
|
||||
'authorName': authorName,
|
||||
'rating': rating,
|
||||
if (comment != null && comment.isNotEmpty) 'comment': comment,
|
||||
if (authorPhone != null && authorPhone.isNotEmpty) 'authorPhone': authorPhone,
|
||||
},
|
||||
);
|
||||
return res.data?['data'] as Map<String, dynamic>?;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getCafe(String slug) async {
|
||||
final res = await _client.dio.get<Map<String, dynamic>>('/api/public/cafes/$slug');
|
||||
return res.data?['data'] as Map<String, dynamic>?;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getMenu(String slug) async {
|
||||
final res = await _client.dio.get<Map<String, dynamic>>('/api/public/cafes/$slug/menu');
|
||||
return res.data?['data'] as Map<String, dynamic>?;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> resolveQr(String qrCode) async {
|
||||
final res = await _client.dio.get<Map<String, dynamic>>('/api/q/$qrCode');
|
||||
return res.data?['data'] as Map<String, dynamic>?;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> placeOrder(
|
||||
String slug, {
|
||||
required String? tableId,
|
||||
required List<Map<String, dynamic>> items,
|
||||
String? guestPhone,
|
||||
String? guestName,
|
||||
}) async {
|
||||
final res = await _client.dio.post<Map<String, dynamic>>(
|
||||
'/api/public/cafes/$slug/orders',
|
||||
data: {
|
||||
'orderType': 'DineIn',
|
||||
if (tableId != null) 'tableId': tableId,
|
||||
if (guestPhone != null) 'guestPhone': guestPhone,
|
||||
if (guestName != null) 'guestName': guestName,
|
||||
'items': items,
|
||||
},
|
||||
);
|
||||
return res.data?['data'] as Map<String, dynamic>?;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> trackOrder(String orderId) async {
|
||||
final res = await _client.dio.get<Map<String, dynamic>>('/api/public/orders/$orderId/track');
|
||||
return res.data?['data'] as Map<String, dynamic>?;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> createReservation(
|
||||
String slug, {
|
||||
required String guestName,
|
||||
required String guestPhone,
|
||||
required String date,
|
||||
required String time,
|
||||
required int partySize,
|
||||
String? notes,
|
||||
}) async {
|
||||
final res = await _client.dio.post<Map<String, dynamic>>(
|
||||
'/api/public/cafes/$slug/reservations',
|
||||
data: {
|
||||
'guestName': guestName,
|
||||
'guestPhone': guestPhone,
|
||||
'date': date,
|
||||
'time': time,
|
||||
'partySize': partySize,
|
||||
if (notes != null) 'notes': notes,
|
||||
},
|
||||
);
|
||||
return res.data?['data'] as Map<String, dynamic>?;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
import '../cart/cart_state.dart';
|
||||
import '../table/table_context.dart';
|
||||
|
||||
class QrScanScreen extends ConsumerStatefulWidget {
|
||||
const QrScanScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<QrScanScreen> createState() => _QrScanScreenState();
|
||||
}
|
||||
|
||||
class _QrScanScreenState extends ConsumerState<QrScanScreen> {
|
||||
final _manualController = TextEditingController(text: 'demo_table_01');
|
||||
bool _loading = false;
|
||||
bool _handled = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_manualController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _resolve(String raw) async {
|
||||
final code = parseQrCode(raw);
|
||||
if (code == null || code.isEmpty) return;
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final data = await ref.read(publicApiProvider).resolveQr(code);
|
||||
if (!mounted || data == null) return;
|
||||
final slug = data['cafeSlug'] as String;
|
||||
final tableId = data['tableId'] as String?;
|
||||
final tableNumber = data['tableNumber']?.toString();
|
||||
ref.read(tableContextProvider.notifier).setTable(
|
||||
tableId: tableId ?? '',
|
||||
tableNumber: tableNumber ?? '',
|
||||
cafeSlug: slug,
|
||||
);
|
||||
if (tableId != null) {
|
||||
ref.read(cartProvider.notifier).setTable(tableId);
|
||||
}
|
||||
context.push(
|
||||
'/cafe/$slug/menu?tableId=$tableId&tableNumber=$tableNumber',
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('خطا: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
if (_handled || _loading) return;
|
||||
final raw = capture.barcodes.firstOrNull?.rawValue;
|
||||
if (raw == null) return;
|
||||
_handled = true;
|
||||
_resolve(raw);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('اسکن QR میز')),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MobileScanner(onDetect: _onDetect),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _manualController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'کد QR (دستی)',
|
||||
hintText: 'demo_table_01',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _loading ? null : () => _resolve(_manualController.text),
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('باز کردن منو'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shamsi_date/shamsi_date.dart';
|
||||
|
||||
import '../cart/cart_state.dart';
|
||||
|
||||
class ReserveScreen extends ConsumerStatefulWidget {
|
||||
const ReserveScreen({super.key, required this.slug});
|
||||
|
||||
final String slug;
|
||||
|
||||
@override
|
||||
ConsumerState<ReserveScreen> createState() => _ReserveScreenState();
|
||||
}
|
||||
|
||||
class _ReserveScreenState extends ConsumerState<ReserveScreen> {
|
||||
final _nameController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _notesController = TextEditingController();
|
||||
Jalali? _date;
|
||||
TimeOfDay _time = const TimeOfDay(hour: 19, minute: 0);
|
||||
int _partySize = 2;
|
||||
bool _submitting = false;
|
||||
String? _message;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_phoneController.dispose();
|
||||
_notesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _formatDate(Jalali d) =>
|
||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_date == null) {
|
||||
setState(() => _message = 'تاریخ را انتخاب کنید');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
_message = null;
|
||||
});
|
||||
try {
|
||||
await ref.read(publicApiProvider).createReservation(
|
||||
widget.slug,
|
||||
guestName: _nameController.text,
|
||||
guestPhone: _phoneController.text,
|
||||
date: _formatDate(_date!),
|
||||
time: '${_time.hour.toString().padLeft(2, '0')}:${_time.minute.toString().padLeft(2, '0')}:00',
|
||||
partySize: _partySize,
|
||||
notes: _notesController.text.isEmpty ? null : _notesController.text,
|
||||
);
|
||||
setState(() => _message = 'رزرو ثبت شد — منتظر تأیید کافه باشید');
|
||||
} catch (_) {
|
||||
setState(() => _message = 'خطا در ثبت رزرو');
|
||||
} finally {
|
||||
setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('رزرو میز')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextField(controller: _nameController, decoration: const InputDecoration(labelText: 'نام')),
|
||||
TextField(
|
||||
controller: _phoneController,
|
||||
decoration: const InputDecoration(labelText: 'موبایل'),
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
ListTile(
|
||||
title: Text(_date == null ? 'تاریخ' : _formatDate(_date!)),
|
||||
trailing: const Icon(Icons.calendar_today),
|
||||
onTap: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime.now(),
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 90)),
|
||||
);
|
||||
if (picked != null) setState(() => _date = Jalali.fromDateTime(picked));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('ساعت ${_time.format(context)}'),
|
||||
trailing: const Icon(Icons.access_time),
|
||||
onTap: () async {
|
||||
final picked = await showTimePicker(context: context, initialTime: _time);
|
||||
if (picked != null) setState(() => _time = picked);
|
||||
},
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Text('تعداد نفر:'),
|
||||
IconButton(onPressed: () => setState(() => _partySize = (_partySize - 1).clamp(1, 20)), icon: const Icon(Icons.remove)),
|
||||
Text('$_partySize'),
|
||||
IconButton(onPressed: () => setState(() => _partySize = (_partySize + 1).clamp(1, 20)), icon: const Icon(Icons.add)),
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
controller: _notesController,
|
||||
decoration: const InputDecoration(labelText: 'یادداشت'),
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
child: _submitting ? const CircularProgressIndicator() : const Text('ثبت رزرو'),
|
||||
),
|
||||
if (_message != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(_message!, style: TextStyle(color: Theme.of(context).colorScheme.primary)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class TableContext {
|
||||
const TableContext({
|
||||
this.tableId,
|
||||
this.tableNumber,
|
||||
this.cafeSlug,
|
||||
});
|
||||
|
||||
final String? tableId;
|
||||
final String? tableNumber;
|
||||
final String? cafeSlug;
|
||||
|
||||
TableContext copyWith({
|
||||
String? tableId,
|
||||
String? tableNumber,
|
||||
String? cafeSlug,
|
||||
}) =>
|
||||
TableContext(
|
||||
tableId: tableId ?? this.tableId,
|
||||
tableNumber: tableNumber ?? this.tableNumber,
|
||||
cafeSlug: cafeSlug ?? this.cafeSlug,
|
||||
);
|
||||
}
|
||||
|
||||
class TableContextNotifier extends StateNotifier<TableContext> {
|
||||
TableContextNotifier() : super(const TableContext());
|
||||
|
||||
void setTable({
|
||||
required String tableId,
|
||||
required String tableNumber,
|
||||
required String cafeSlug,
|
||||
}) {
|
||||
state = TableContext(
|
||||
tableId: tableId,
|
||||
tableNumber: tableNumber,
|
||||
cafeSlug: cafeSlug,
|
||||
);
|
||||
}
|
||||
|
||||
void clear() => state = const TableContext();
|
||||
}
|
||||
|
||||
final tableContextProvider =
|
||||
StateNotifierProvider<TableContextNotifier, TableContext>((ref) => TableContextNotifier());
|
||||
|
||||
/// Extract QR code from full URL or raw token.
|
||||
String? parseQrCode(String raw) {
|
||||
final trimmed = raw.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
final uri = Uri.tryParse(trimmed);
|
||||
if (uri != null && uri.pathSegments.isNotEmpty) {
|
||||
return uri.pathSegments.last;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/utils/currency_utils.dart';
|
||||
import '../cart/cart_state.dart';
|
||||
|
||||
final trackProvider = FutureProvider.autoDispose.family<Map<String, dynamic>?, String>((ref, orderId) {
|
||||
return ref.watch(publicApiProvider).trackOrder(orderId);
|
||||
});
|
||||
|
||||
class TrackScreen extends ConsumerWidget {
|
||||
const TrackScreen({super.key, required this.orderId});
|
||||
|
||||
final String orderId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final trackAsync = ref.watch(trackProvider(orderId));
|
||||
|
||||
return Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('پیگیری سفارش')),
|
||||
body: trackAsync.when(
|
||||
data: (order) {
|
||||
if (order == null) return const Center(child: Text('سفارش یافت نشد'));
|
||||
final status = order['status'] as String? ?? '';
|
||||
final items = order['items'] as List<dynamic>? ?? [];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('وضعیت: $status', style: Theme.of(context).textTheme.titleLarge),
|
||||
Text('مبلغ: ${formatToman((order['total'] as num).toInt())}'),
|
||||
const SizedBox(height: 16),
|
||||
const Text('اقلام:'),
|
||||
...items.map((i) => Text('• ${i['menuItemName']} × ${i['quantity']}')),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('خطا: $e')),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'app/router.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: MeeziApp()));
|
||||
}
|
||||
|
||||
class MeeziApp extends StatelessWidget {
|
||||
const MeeziApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp.router(
|
||||
title: 'میزی',
|
||||
locale: const Locale('fa'),
|
||||
supportedLocales: const [Locale('fa'), Locale('ar'), Locale('en')],
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6B4F3A)),
|
||||
useMaterial3: true,
|
||||
),
|
||||
routerConfig: appRouter,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
name: meezi_app
|
||||
description: Meezi - میزی - سیستم کافه و رستوران
|
||||
publish_to: "none"
|
||||
version: 0.1.0+1
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.0 <4.0.0"
|
||||
flutter: ">=3.19.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
flutter_riverpod: ^2.5.1
|
||||
go_router: ^14.2.0
|
||||
dio: ^5.4.3
|
||||
shamsi_date: ^1.1.1
|
||||
flutter_secure_storage: ^9.2.2
|
||||
shared_preferences: ^2.3.2
|
||||
intl: ^0.19.0
|
||||
uuid: ^4.4.2
|
||||
mobile_scanner: ^5.2.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^4.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
Reference in New Issue
Block a user