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:
soroush.asadi
2026-05-27 21:35:27 +03:30
parent 42d4cb896a
commit a85890f30a
52 changed files with 3919 additions and 0 deletions
@@ -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')),
),
),
);
}
}