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
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "Dr Sousan API",
"runtimeExecutable": "dotnet",
"runtimeArgs": ["run", "--project", "F:/Projects/DrSousan/DrSousan.Api", "--urls", "http://localhost:5000"],
"port": 5000
}
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"allowedTools": ["*"]
}
+159
View File
@@ -0,0 +1,159 @@
You are building Meezi (میزی) — a Persian-first SaaS POS and community
platform for Iranian cafés in Tehran and Karaj.
Always read MEEZI_PRD.md at the start of any new session for full context.
## Product
Brand: Meezi (میزی) | Tagline: میزت منتظرته
Competitor: Sepidz (سپیدز) — legacy license, no SaaS, no customer app
Markets V1: Tehran (تهران) + Karaj (کرج)
Languages: Farsi fa (default) + Arabic ar + English en
Pricing: Free / Pro 1.49M ت / Business 3.49M ت / Enterprise custom
Hardware: Android tablet + thermal printer bundle
## Stack
Backend: ASP.NET Core 10 C# — src/Meezi.API
Web: Next.js 14 TypeScript — web/dashboard
Mobile: Flutter 3 Dart — mobile/meezi_app
DB: PostgreSQL 16 + Redis
ORM: EF Core 10 (Npgsql)
Queue: Hangfire
Realtime: SignalR (KDS live orders)
SMS: Kavenegar API
Payment: ZarinPal
Maps: Neshan API
Tax: Taraz API (سامانه مودیان)
Delivery: Snappfood webhook
Hosting: Arvan Cloud Iran
## C# / ASP.NET Core Rules
- Async/await everywhere — NEVER .Result or .Wait()
- EF Core 10 only — no raw SQL unless aggregation requires it
- EVERY query: .Where(x => x.CafeId == _tenant.CafeId) — multi-tenant
- Return ApiResponse<T> always:
record ApiResponse<T>(bool Success, T? Data, ApiError? Error = null)
record ApiError(string Code, string Message, string? Field = null)
- Use record types for all DTOs
- FluentValidation for ALL request models
- ILogger<T> for logging — never Console.WriteLine
- Hangfire for all background jobs (SMS, coupons, renewal reminders)
- SignalR hub /hubs/kds for real-time kitchen display
- Program.cs minimal hosting style
## Next.js / TypeScript Rules
- next-intl for ALL i18n — zero hardcoded strings in components
- ALL user text in messages/fa.json + messages/ar.json + messages/en.json
- Dynamic direction: fa/ar → dir="rtl" | en → dir="ltr"
- Spacing: ms-* me-* ps-* pe-* ALWAYS — never ml-* mr-* pl-* pr-*
- TanStack Query v5 for ALL server state
- Zustand for cart + UI-only state
- Dates: date-fns-jalali ALWAYS — never display Gregorian to user
- Numbers fa: n.toLocaleString('fa-IR')
- Currency: n.toLocaleString('fa-IR') + ' ت'
- shadcn/ui components — don't rebuild what shadcn provides
- TypeScript strict — no `any`, no `as unknown`
## Flutter / Dart Rules
- Riverpod 2.x for ALL state — no setState in business logic
- GoRouter for all navigation
- Drift SQLite for offline storage (lib/core/db/)
- Sync pattern: write to Drift first → queue → upload on reconnect
- shamsi_date package for ALL date display — never show Gregorian
- 3 locales: fa (RTL), ar (RTL), en (LTR)
- Feature-first folders: lib/features/{feature}/
- Thermal printer: bluetooth_print or esc_pos_utils_plus
- QR scanner: mobile_scanner
- Dio + Retrofit for API calls
- freezed for immutable models
## Multi-Tenancy (CRITICAL)
- JWT claims: { userId, cafeId, role, planTier, lang }
- TenantMiddleware injects ITenantContext into every request
- Every EF query filters by CafeId — no exceptions
- PlanLimitMiddleware checks limits before: orders, customers, SMS
- On limit hit return: { code: "PLAN_LIMIT_REACHED", message: "..." }
## Plan Limits to enforce
Free: 50 orders/day, 1 terminal, 50 CRM, 0 SMS, 1 branch
Pro: unlimited orders, 3 terminals, unlimited CRM, 50 SMS, 1 branch
Business: unlimited everything, 200 SMS, 5 branches + HR + delivery
Enterprise: unlimited + badges + white_label + API
## API Format
GET list: { success: true, data: [...], meta: { total, page, pageSize } }
GET single: { success: true, data: { ... } }
POST/PATCH: { success: true, data: { id, ... } }
Error: { success: false, error: { code: "...", message: "..." } }
## Endpoint Pattern
/api/cafes/{cafeId}/orders → protected, validate cafeId == JWT cafeId
/api/public/discover → no auth
/api/q/{qrCode} → no auth, returns cafeSlug + tableId
/api/webhooks/snappfood → no JWT, verify HMAC secret
/api/auth/send-otp → no auth, rate limit 5/hour/phone
/api/billing/verify → ZarinPal callback
## Security
- Validate cafeId ownership: if (order.CafeId != _tenant.CafeId) return 403
- OTP rate limit: Redis INCR "otp:attempts:{phone}" with 1h TTL, block at 5
- Never log phone, nationalId, or payment tokens
- Soft delete: DeletedAt DateTime? — never hard DELETE customer data
- File upload: validate MIME + max 5MB
## i18n String Keys Convention
fa.json:
{
"common": { "save":"ذخیره", "cancel":"انصراف", "confirm":"تأیید",
"delete":"حذف", "search":"جستجو", "loading":"در حال بارگذاری..." },
"pos": { "order":"سفارش", "table":"میز", "total":"مبلغ نهایی",
"confirmOrder":"ثبت و پرداخت", "applyСoupon":"اعمال کوپن" },
"crm": { "customer":"مشتری", "nationalId":"کد ملی", "phone":"موبایل" },
"hr": { "employee":"کارمند", "shift":"شیفت", "salary":"حقوق",
"clockIn":"ورود", "clockOut":"خروج", "leave":"مرخصی" },
"errors": { "planLimit":"به سقف پلن رسیده‌اید. برای ادامه ارتقا دهید",
"notFound":"یافت نشد", "unauthorized":"دسترسی ندارید" }
}
UI QUALITY RULES — apply to every screen:
Visual hierarchy: 3 levels always
Level 1: page title + primary action button (largest, highest contrast)
Level 2: section headers + card titles (medium, color-coded)
Level 3: metadata, secondary info (small, muted)
Cards: always border-radius-lg (12px), 0.5px border, white background
Never flat boxes without border — everything lives in a card
Color system:
Primary action: #0F6E56 (Meezi green)
Positive/money: #0F6E56 green
Warning/promo: #BA7517 amber
Destructive: #A32D2D red
Info: #0C447C blue
Backgrounds: tertiary (page) → secondary (section) → primary (card)
Typography:
Page titles: 18px weight 500
Section labels: 11px UPPERCASE letter-spacing .06em muted
Body text: 13px regular
Prices/amounts: 13-14px weight 500 green
Metadata: 11px muted
Status indicators:
All orders/statuses have colored dot + badge — never plain text
Badges: colored background matching meaning (green=active, amber=pending)
Every list row: icon or emoji + name + metadata + right-side value + action
Never a plain text list — always structured rows with visual anchors
Interactive states:
Hover: border-color changes to primary (#0F6E56)
Active: scale(0.98) transform
Selected: green background tint #E1F5EE
Section headers above every group of items:
"پیشنهاد ویژه امروز" / "همه آیتم‌ها" / "پرفروش‌ترین"
Small uppercase label + optional "مشاهده همه" link
Promo tags on items with active discount:
Small amber badge top-right of item card showing "۱۵٪ تخفیف"
+19
View File
@@ -0,0 +1,19 @@
**/.git
**/.vs
**/bin
**/obj
**/node_modules
**/.next
**/out
**/.env
**/.env.local
!**/.env.example
**/mobile
**/*.md
!DOCKER.md
**/.cursor
**/terminals
**/agent-transcripts
# web/website still uses the old host-copy pattern via meezi-node base image.
!web/website/node_modules
+75
View File
@@ -0,0 +1,75 @@
name: CI
on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master, develop]
jobs:
api:
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Restore
run: dotnet restore src/Meezi.API/Meezi.API.csproj
- name: Build
run: dotnet build src/Meezi.API/Meezi.API.csproj --no-restore -c Release
- name: Test
run: dotnet test tests/Meezi.API.Tests/Meezi.API.Tests.csproj -c Release
web:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web/dashboard
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: web/dashboard/package-lock.json
- run: npm ci
- run: npm run build
env:
NEXT_PUBLIC_API_URL: http://localhost:5080
e2e:
runs-on: ubuntu-latest
continue-on-error: true
defaults:
run:
working-directory: web/dashboard
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: web/dashboard/package-lock.json
- run: npm ci
- run: npx playwright install chromium --with-deps
- name: E2E (API-only smoke; set PLAYWRIGHT_API_URL when API service available)
run: npm run test:e2e -- e2e/api-health.spec.ts
env:
PLAYWRIGHT_API_URL: http://localhost:5080
flutter:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- run: flutter analyze mobile/meezi_app
- run: flutter analyze mobile/meezi_pos
+20
View File
@@ -0,0 +1,20 @@
name: Deploy
on:
push:
tags:
- "v*"
jobs:
build-images:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build API image
run: docker build -f docker/api/Dockerfile -t meezi-api:${{ github.ref_name }} .
- name: Build Web image
run: docker build -f docker/web/Dockerfile -t meezi-web:${{ github.ref_name }} .
- name: Deploy note
run: |
echo "Push images to your registry and deploy on Arvan per DEPLOY.md"
echo "Required secrets: registry credentials, connection strings (not in repo)"
+30
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_const_constructors: true
+45
View File
@@ -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')),
),
),
);
}
}
+32
View File
@@ -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,
);
}
}
+31
View File
@@ -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
+25
View File
@@ -0,0 +1,25 @@
# meezi_pos
Tablet POS app for Meezi (parallel to `meezi_app` customer app).
## Phase 1 (current)
- Flutter 3 + Riverpod + GoRouter
- Login shell → POS shell
- `X-Meezi-Terminal-Id` header (wire in Dio client)
## Phase 2
- Drift SQLite: menu cache, cart, sync queue
- Full POS flow + `bluetooth_print` / `esc_pos_utils_plus`
- Same OTP API as dashboard
## Run
```bash
cd mobile/meezi_pos
flutter pub get
flutter run
```
Set API base URL via `--dart-define=API_URL=http://10.0.2.2:5080` (Android emulator).
+1
View File
@@ -0,0 +1 @@
include: package:flutter_lints/flutter.yaml
@@ -0,0 +1,14 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:meezi_pos/features/auth/login_screen.dart';
import 'package:meezi_pos/features/pos/pos_screen.dart';
final appRouterProvider = Provider<GoRouter>((ref) {
return GoRouter(
initialLocation: '/login',
routes: [
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: '/pos', builder: (_, __) => const PosScreen()),
],
);
});
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// Phase 1: online-only OTP login (wire Dio to /api/auth/*).
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _phone = TextEditingController(text: '09121234567');
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('میزی — صندوق', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
TextField(
controller: _phone,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(labelText: 'موبایل', border: OutlineInputBorder()),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => context.go('/pos'),
child: const Text('ورود (دمو)'),
),
],
),
),
),
);
}
}
@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
/// Phase 1: online POS shell. Phase 2: Drift cart + sync queue + bluetooth_print.
class PosScreen extends StatelessWidget {
const PosScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('صندوق')),
body: const Center(
child: Text('POS — منو و سبد در فاز بعدی (Drift + API)'),
),
);
}
}
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:meezi_pos/core/router/app_router.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const ProviderScope(child: MeeziPosApp()));
}
class MeeziPosApp extends ConsumerWidget {
const MeeziPosApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(appRouterProvider);
return MaterialApp.router(
title: 'Meezi POS',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0F6E56)),
useMaterial3: true,
),
routerConfig: router,
);
}
}
+26
View File
@@ -0,0 +1,26 @@
name: meezi_pos
description: Meezi tablet POS (Riverpod, Drift, offline sync)
publish_to: "none"
version: 0.1.0+1
environment:
sdk: ">=3.2.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
flutter_riverpod: ^2.6.1
go_router: ^14.6.2
dio: ^5.7.0
shamsi_date: ^1.0.4
intl: any
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
@@ -0,0 +1,49 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'api_config.dart';
const _tokenKey = 'waiter_access_token';
final _storageProvider = Provider<FlutterSecureStorage>(
(_) => const FlutterSecureStorage(),
);
final apiClientProvider = Provider<ApiClient>((ref) {
final storage = ref.watch(_storageProvider);
return ApiClient(storage: storage);
});
class ApiClient {
ApiClient({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage() {
_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: _tokenKey);
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
));
}
final FlutterSecureStorage _storage;
late final Dio _dio;
Dio get dio => _dio;
Future<void> saveToken(String token) =>
_storage.write(key: _tokenKey, value: token);
Future<String?> readToken() => _storage.read(key: _tokenKey);
Future<void> clearToken() => _storage.delete(key: _tokenKey);
}
@@ -0,0 +1,4 @@
const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://localhost:7208',
);
@@ -0,0 +1,59 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/api_client.dart';
import 'auth_state.dart';
final authProvider =
StateNotifierProvider<AuthNotifier, WaiterSession?>((ref) {
return AuthNotifier(ref.watch(apiClientProvider));
});
class AuthNotifier extends StateNotifier<WaiterSession?> {
AuthNotifier(this._client) : super(null) {
_restore();
}
final ApiClient _client;
Future<void> _restore() async {
// Check stored token and validate it's still usable
final token = await _client.readToken();
if (token == null || token.isEmpty) return;
try {
final res = await _client.dio.get<Map<String, dynamic>>('/api/auth/me');
final data = res.data?['data'] as Map<String, dynamic>?;
if (data == null) return;
// Merge stored token with fetched profile
final json = {...data, 'accessToken': token};
state = WaiterSession.fromJson(json);
} catch (_) {
// Token expired or network error — stay logged out
}
}
Future<String> sendOtp(String phone) async {
final res = await _client.dio.post<Map<String, dynamic>>(
'/api/auth/send-otp',
data: {'phone': phone},
);
final data = res.data?['data'] as Map<String, dynamic>?;
return (data?['sessionId'] ?? '') as String;
}
Future<void> verifyOtp(String phone, String otp) async {
final res = await _client.dio.post<Map<String, dynamic>>(
'/api/auth/verify-otp',
data: {'phone': phone, 'otp': otp},
);
final data = res.data?['data'] as Map<String, dynamic>?;
if (data == null) throw Exception('AUTH_FAILED');
final session = WaiterSession.fromJson(data);
await _client.saveToken(session.accessToken);
state = session;
}
Future<void> logout() async {
await _client.clearToken();
state = null;
}
}
@@ -0,0 +1,28 @@
class WaiterSession {
const WaiterSession({
required this.accessToken,
required this.cafeId,
required this.userId,
required this.role,
this.branchId,
this.actor,
});
final String accessToken;
final String cafeId;
final String userId;
final String role;
final String? branchId;
final String? actor;
factory WaiterSession.fromJson(Map<String, dynamic> json) => WaiterSession(
accessToken: json['accessToken'] as String,
cafeId: json['cafeId'] as String,
userId: json['userId'] as String,
role: json['role'] as String,
branchId: json['branchId'] as String?,
actor: json['actor'] as String?,
);
String get displayName => actor ?? userId;
}
@@ -0,0 +1,95 @@
import 'dart:async';
import 'package:signalr_netcore/signalr_client.dart';
import '../api/api_config.dart';
/// Events broadcast from the KDS hub.
class HubNotification {
const HubNotification({
required this.id,
required this.type,
required this.title,
this.body,
this.tableNumber,
this.referenceId,
required this.createdAt,
});
final String id;
final String type;
final String title;
final String? body;
final String? tableNumber;
final String? referenceId;
final DateTime createdAt;
factory HubNotification.fromMap(Map<String, dynamic> m) => HubNotification(
id: (m['id'] ?? '') as String,
type: (m['type'] ?? '') as String,
title: (m['title'] ?? '') as String,
body: m['body'] as String?,
tableNumber: m['tableNumber'] as String?,
referenceId: m['referenceId'] as String?,
createdAt: DateTime.tryParse(m['createdAt'] as String? ?? '') ??
DateTime.now(),
);
}
class WaiterHubClient {
WaiterHubClient({required this.cafeId, required this.accessToken});
final String cafeId;
final String accessToken;
HubConnection? _connection;
final _notificationController =
StreamController<HubNotification>.broadcast();
Stream<HubNotification> get notifications => _notificationController.stream;
bool get isConnected =>
_connection?.state == HubConnectionState.Connected;
Future<void> connect() async {
final hubUrl = '$apiBaseUrl/hubs/kds';
_connection = HubConnectionBuilder()
.withUrl(
hubUrl,
options: HttpConnectionOptions(
accessTokenFactory: () async => accessToken,
skipNegotiation: false,
),
)
.withAutomaticReconnect()
.build();
_connection!.on('NotificationReceived', _onNotification);
_connection!.onclose(({error}) async {
// Auto-reconnect handled by withAutomaticReconnect
});
try {
await _connection!.start();
await _connection!.invoke('JoinCafe', args: [cafeId]);
} catch (_) {
// Will retry via automatic reconnect
}
}
void _onNotification(List<Object?>? args) {
if (args == null || args.isEmpty) return;
final raw = args[0];
if (raw is! Map) return;
final m = Map<String, dynamic>.from(raw);
_notificationController.add(HubNotification.fromMap(m));
}
Future<void> dispose() async {
await _connection?.stop();
await _notificationController.close();
}
}
@@ -0,0 +1,22 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_provider.dart';
import 'hub_client.dart';
/// Keeps a single live hub connection alive while the user is authenticated.
final hubClientProvider = Provider<WaiterHubClient?>((ref) {
final session = ref.watch(authProvider);
if (session == null) return null;
final client = WaiterHubClient(
cafeId: session.cafeId,
accessToken: session.accessToken,
);
// Connect asynchronously; provider consumers will react to stream events.
client.connect();
ref.onDispose(client.dispose);
return client;
});
@@ -0,0 +1,31 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../features/auth/login_screen.dart';
import '../../features/home/home_screen.dart';
import '../auth/auth_provider.dart';
final appRouterProvider = Provider<GoRouter>((ref) {
final auth = ref.watch(authProvider);
return GoRouter(
initialLocation: auth != null ? '/home' : '/login',
redirect: (context, state) {
final loggedIn = ref.read(authProvider) != null;
final goingToLogin = state.matchedLocation == '/login';
if (!loggedIn && !goingToLogin) return '/login';
if (loggedIn && goingToLogin) return '/home';
return null;
},
routes: [
GoRoute(
path: '/login',
builder: (_, __) => const LoginScreen(),
),
GoRoute(
path: '/home',
builder: (_, __) => const HomeScreen(),
),
],
);
});
@@ -0,0 +1,213 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/auth/auth_provider.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _phoneCtrl = TextEditingController();
final _otpCtrl = TextEditingController();
final _phoneFocus = FocusNode();
final _otpFocus = FocusNode();
bool _otpSent = false;
bool _loading = false;
String? _error;
@override
void dispose() {
_phoneCtrl.dispose();
_otpCtrl.dispose();
_phoneFocus.dispose();
_otpFocus.dispose();
super.dispose();
}
Future<void> _sendOtp() async {
final phone = _phoneCtrl.text.trim();
if (phone.isEmpty) {
setState(() => _error = 'شماره موبایل را وارد کنید');
return;
}
setState(() {
_loading = true;
_error = null;
});
try {
await ref.read(authProvider.notifier).sendOtp(phone);
setState(() {
_otpSent = true;
_loading = false;
});
_otpFocus.requestFocus();
} catch (e) {
setState(() {
_loading = false;
_error = 'ارسال کد ناموفق بود. دوباره تلاش کنید.';
});
}
}
Future<void> _verify() async {
final phone = _phoneCtrl.text.trim();
final otp = _otpCtrl.text.trim();
if (otp.length < 4) {
setState(() => _error = 'کد تأیید را وارد کنید');
return;
}
setState(() {
_loading = true;
_error = null;
});
try {
await ref.read(authProvider.notifier).verifyOtp(phone, otp);
if (mounted) context.go('/home');
} catch (e) {
setState(() {
_loading = false;
_error = 'کد اشتباه یا منقضی شده است';
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Directionality(
textDirection: TextDirection.rtl,
child: Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Spacer(),
// Brand
Center(
child: Column(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
Icons.notifications_active_rounded,
size: 38,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 16),
Text(
'میزی — گارسون',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 6),
Text(
'برای دریافت اعلان‌های میز وارد شوید',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
const Spacer(),
// Phone field
TextField(
controller: _phoneCtrl,
focusNode: _phoneFocus,
keyboardType: TextInputType.phone,
enabled: !_otpSent,
textDirection: TextDirection.ltr,
decoration: InputDecoration(
labelText: 'شماره موبایل',
hintText: '09121234567',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.phone),
suffixIcon: _otpSent
? IconButton(
icon: const Icon(Icons.edit),
onPressed: () =>
setState(() => _otpSent = false),
tooltip: 'ویرایش شماره',
)
: null,
),
),
const SizedBox(height: 14),
// OTP field
if (_otpSent) ...[
TextField(
controller: _otpCtrl,
focusNode: _otpFocus,
keyboardType: TextInputType.number,
textDirection: TextDirection.ltr,
maxLength: 6,
decoration: const InputDecoration(
labelText: 'کد تأیید',
hintText: '۶ رقم',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock_outline),
),
),
const SizedBox(height: 4),
],
// Error
if (_error != null) ...[
const SizedBox(height: 8),
Text(
_error!,
style: TextStyle(
color: theme.colorScheme.error, fontSize: 13),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 16),
// Action button
FilledButton(
onPressed: _loading
? null
: (_otpSent ? _verify : _sendOtp),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: _loading
? const SizedBox(
height: 20,
width: 20,
child:
CircularProgressIndicator(strokeWidth: 2.5),
)
: Text(_otpSent ? 'ورود' : 'ارسال کد'),
),
const SizedBox(height: 32),
],
),
),
),
),
);
}
}
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../notifications/notification_provider.dart';
import '../notifications/notifications_screen.dart';
import '../shift/shift_screen.dart';
import '../tables/table_board_screen.dart';
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@override
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen> {
int _tab = 0;
static const _screens = [
NotificationsScreen(),
TableBoardScreen(),
ShiftScreen(),
];
@override
Widget build(BuildContext context) {
final unread = ref.watch(
notificationProvider.select((s) => s.unreadCount),
);
return Directionality(
textDirection: TextDirection.rtl,
child: Scaffold(
body: IndexedStack(index: _tab, children: _screens),
bottomNavigationBar: NavigationBar(
selectedIndex: _tab,
onDestinationSelected: (i) => setState(() => _tab = i),
destinations: [
NavigationDestination(
icon: Badge(
isLabelVisible: unread > 0,
label: Text(unread > 9 ? '۹+' : '$unread'),
child: const Icon(Icons.notifications_outlined),
),
selectedIcon: Badge(
isLabelVisible: unread > 0,
label: Text(unread > 9 ? '۹+' : '$unread'),
child: const Icon(Icons.notifications),
),
label: 'اعلان‌ها',
),
const NavigationDestination(
icon: Icon(Icons.table_restaurant_outlined),
selectedIcon: Icon(Icons.table_restaurant),
label: 'میزها',
),
const NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: 'شیفت من',
),
],
),
),
);
}
}
@@ -0,0 +1,159 @@
import 'dart:async';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
import '../../core/auth/auth_provider.dart';
import '../../core/hub/hub_provider.dart';
import 'waiter_notification.dart';
// ── Local notification setup ────────────────────────────────────────────────
final _localNotifications = FlutterLocalNotificationsPlugin();
Future<void> initLocalNotifications() async {
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
const ios = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
await _localNotifications.initialize(
const InitializationSettings(android: android, iOS: ios),
);
}
Future<void> _showLocalNotification(WaiterNotification n) async {
const android = AndroidNotificationDetails(
'meezi_waiter_channel',
'میزی — گارسون',
channelDescription: 'اعلان‌های میزی برای گارسون',
importance: Importance.max,
priority: Priority.high,
playSound: true,
);
const ios = DarwinNotificationDetails(presentSound: true, presentAlert: true);
await _localNotifications.show(
n.createdAt.millisecondsSinceEpoch ~/ 1000,
n.title,
n.body,
const NotificationDetails(android: android, iOS: ios),
);
}
// ── Notification state ───────────────────────────────────────────────────────
class NotificationState {
const NotificationState({
this.items = const [],
this.isLoading = false,
});
final List<WaiterNotification> items;
final bool isLoading;
int get unreadCount => items.where((n) => !n.isRead).length;
NotificationState copyWith({
List<WaiterNotification>? items,
bool? isLoading,
}) =>
NotificationState(
items: items ?? this.items,
isLoading: isLoading ?? this.isLoading,
);
}
final notificationProvider =
StateNotifierProvider<NotificationNotifier, NotificationState>((ref) {
final notifier = NotificationNotifier(ref);
notifier._init();
return notifier;
});
class NotificationNotifier extends StateNotifier<NotificationState> {
NotificationNotifier(this._ref) : super(const NotificationState());
final Ref _ref;
StreamSubscription<dynamic>? _hubSub;
void _init() {
_fetchFromApi();
_subscribeHub();
}
void _subscribeHub() {
_hubSub?.cancel();
final hub = _ref.read(hubClientProvider);
if (hub == null) return;
_hubSub = hub.notifications.listen((event) {
final n = WaiterNotification.fromHub(event);
state = state.copyWith(items: [n, ...state.items]);
_showLocalNotification(n);
});
}
Future<void> _fetchFromApi() async {
final session = _ref.read(authProvider);
if (session == null) return;
state = state.copyWith(isLoading: true);
try {
final client = _ref.read(apiClientProvider);
final res = await client.dio.get<Map<String, dynamic>>(
'/api/cafes/${session.cafeId}/notifications',
queryParameters: {'limit': 50},
);
final raw = res.data?['data'] as List?;
final items = raw
?.map((e) =>
WaiterNotification.fromJson(Map<String, dynamic>.from(e as Map)))
.toList() ??
[];
state = state.copyWith(items: items, isLoading: false);
} catch (_) {
state = state.copyWith(isLoading: false);
}
}
Future<void> refresh() => _fetchFromApi();
Future<void> markRead(String id) async {
final session = _ref.read(authProvider);
if (session == null) return;
try {
final client = _ref.read(apiClientProvider);
await client.dio.post(
'/api/cafes/${session.cafeId}/notifications/read',
data: {'ids': [id]},
);
} catch (_) {}
final updated = state.items.map((n) {
if (n.id == id) n.isRead = true;
return n;
}).toList();
state = state.copyWith(items: updated);
}
Future<void> markAllRead() async {
final session = _ref.read(authProvider);
if (session == null) return;
try {
final client = _ref.read(apiClientProvider);
await client.dio.post(
'/api/cafes/${session.cafeId}/notifications/read',
data: {'all': true},
);
} catch (_) {}
final updated = state.items.map((n) {
n.isRead = true;
return n;
}).toList();
state = state.copyWith(items: updated);
}
@override
void dispose() {
_hubSub?.cancel();
super.dispose();
}
}
@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shamsi_date/shamsi_date.dart';
import 'notification_provider.dart';
import 'waiter_notification.dart';
class NotificationsScreen extends ConsumerWidget {
const NotificationsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(notificationProvider);
final notifier = ref.read(notificationProvider.notifier);
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('اعلان‌ها'),
actions: [
if (state.unreadCount > 0)
TextButton(
onPressed: notifier.markAllRead,
child: const Text('همه خوانده شد'),
),
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'بارگذاری مجدد',
onPressed: notifier.refresh,
),
],
),
body: state.isLoading
? const Center(child: CircularProgressIndicator())
: state.items.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.notifications_none,
size: 56,
color: theme.colorScheme.onSurface.withOpacity(0.3)),
const SizedBox(height: 12),
const Text('اعلانی وجود ندارد',
style: TextStyle(fontSize: 15)),
],
),
)
: RefreshIndicator(
onRefresh: notifier.refresh,
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: state.items.length,
itemBuilder: (_, i) {
final n = state.items[i];
return _NotificationTile(
notification: n,
onTap: () => notifier.markRead(n.id),
);
},
),
),
);
}
}
class _NotificationTile extends StatelessWidget {
const _NotificationTile({
required this.notification,
required this.onTap,
});
final WaiterNotification notification;
final VoidCallback onTap;
IconData get _icon {
if (notification.isCallWaiter) return Icons.notifications_active;
if (notification.isNewOrder) return Icons.restaurant;
if (notification.isOrderReady) return Icons.check_circle_outline;
return Icons.notifications;
}
Color _iconColor(BuildContext context) {
final cs = Theme.of(context).colorScheme;
if (notification.isCallWaiter) return cs.error;
if (notification.isNewOrder) return cs.primary;
if (notification.isOrderReady) return Colors.green;
return cs.onSurfaceVariant;
}
String get _timeLabel {
try {
final j = Jalali.fromDateTime(notification.createdAt.toLocal());
final f = j.formatter;
return '${f.d} ${f.mN}${notification.createdAt.toLocal().hour.toString().padLeft(2, '0')}:${notification.createdAt.toLocal().minute.toString().padLeft(2, '0')}';
} catch (_) {
return '';
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isUnread = !notification.isRead;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Material(
color: isUnread
? theme.colorScheme.primaryContainer.withOpacity(0.25)
: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: _iconColor(context).withOpacity(0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(_icon,
color: _iconColor(context), size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
notification.title,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: isUnread
? FontWeight.bold
: FontWeight.normal,
),
),
),
if (isUnread)
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
),
],
),
if (notification.body != null) ...[
const SizedBox(height: 3),
Text(
notification.body!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 4),
Row(
children: [
if (notification.tableNumber != null) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'میز ${notification.tableNumber}',
style: theme.textTheme.labelSmall,
),
),
const SizedBox(width: 6),
],
Text(
_timeLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
],
),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,51 @@
import '../../core/hub/hub_client.dart';
class WaiterNotification {
WaiterNotification({
required this.id,
required this.type,
required this.title,
this.body,
this.tableNumber,
this.referenceId,
required this.createdAt,
this.isRead = false,
});
final String id;
final String type;
final String title;
final String? body;
final String? tableNumber;
final String? referenceId;
final DateTime createdAt;
bool isRead;
factory WaiterNotification.fromHub(HubNotification n) => WaiterNotification(
id: n.id,
type: n.type,
title: n.title,
body: n.body,
tableNumber: n.tableNumber,
referenceId: n.referenceId,
createdAt: n.createdAt,
);
factory WaiterNotification.fromJson(Map<String, dynamic> json) =>
WaiterNotification(
id: json['id'] as String,
type: json['type'] as String,
title: json['title'] as String,
body: json['body'] as String?,
tableNumber: json['tableNumber'] as String?,
referenceId: json['referenceId'] as String?,
createdAt:
DateTime.tryParse(json['createdAt'] as String? ?? '') ??
DateTime.now(),
isRead: json['isRead'] as bool? ?? false,
);
bool get isCallWaiter => type == 'table_call_waiter';
bool get isNewOrder => type == 'guest_order_new';
bool get isOrderReady => type == 'guest_order_ready';
}
@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shamsi_date/shamsi_date.dart';
import '../../core/api/api_client.dart';
import '../../core/auth/auth_provider.dart';
final _shiftProvider =
FutureProvider.autoDispose<Map<String, dynamic>?>((ref) async {
final session = ref.watch(authProvider);
if (session == null) return null;
final client = ref.watch(apiClientProvider);
try {
final res = await client.dio.get<Map<String, dynamic>>(
'/api/cafes/${session.cafeId}/employees/${session.userId}/shift/today',
);
return res.data?['data'] as Map<String, dynamic>?;
} catch (_) {
return null;
}
});
class ShiftScreen extends ConsumerStatefulWidget {
const ShiftScreen({super.key});
@override
ConsumerState<ShiftScreen> createState() => _ShiftScreenState();
}
class _ShiftScreenState extends ConsumerState<ShiftScreen> {
String? _message;
bool _busy = false;
Future<void> _clock(bool isIn) async {
final session = ref.read(authProvider);
if (session == null) return;
setState(() => _busy = true);
try {
final client = ref.read(apiClientProvider);
final path = isIn ? 'clock-in' : 'clock-out';
await client.dio.post(
'/api/cafes/${session.cafeId}/employees/${session.userId}/attendance/$path',
);
ref.invalidate(_shiftProvider);
setState(() => _message = isIn ? 'ورود ثبت شد ✓' : 'خروج ثبت شد ✓');
} catch (_) {
setState(() => _message = 'خطا — اتصال را بررسی کنید');
} finally {
setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final session = ref.watch(authProvider);
final shiftAsync = ref.watch(_shiftProvider);
final theme = Theme.of(context);
final todayJ = Jalali.now();
return Scaffold(
appBar: AppBar(
title: const Text('شیفت من'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
tooltip: 'خروج از حساب',
onPressed: () async {
await ref.read(authProvider.notifier).logout();
},
),
],
),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
// Profile card
if (session != null)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
CircleAvatar(
radius: 26,
backgroundColor:
theme.colorScheme.primaryContainer,
child: Text(
session.displayName.isNotEmpty
? session.displayName[0].toUpperCase()
: '؟',
style: TextStyle(
fontSize: 22,
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 14),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(session.displayName,
style: theme.textTheme.titleMedium),
Text(session.role,
style: theme.textTheme.bodySmall?.copyWith(
color:
theme.colorScheme.onSurfaceVariant)),
],
),
],
),
),
),
const SizedBox(height: 16),
// Date
Text(
'امروز: ${todayJ.formatter.d} ${todayJ.formatter.mN} ${todayJ.formatter.y}',
style: theme.textTheme.titleSmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
// Shift info
shiftAsync.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (_, __) =>
const Text('شیفت در دسترس نیست'),
data: (shift) => Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('شیفت',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant)),
const SizedBox(height: 4),
Text(
shift?['label'] as String? ?? 'شیفت تعریف نشده',
style: theme.textTheme.bodyLarge,
),
if (shift?['startTime'] != null) ...[
const SizedBox(height: 4),
Text(
'${shift!['startTime']}${shift['endTime'] ?? ''}',
style: theme.textTheme.bodySmall,
),
],
],
),
),
),
),
const SizedBox(height: 20),
// Clock in/out buttons
Row(
children: [
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.login),
label: const Text('ورود'),
onPressed: _busy ? null : () => _clock(true),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.logout),
label: const Text('خروج'),
onPressed: _busy ? null : () => _clock(false),
),
),
],
),
if (_message != null) ...[
const SizedBox(height: 16),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
_message!,
style: TextStyle(color: theme.colorScheme.onPrimaryContainer),
textAlign: TextAlign.center,
),
),
],
],
),
);
}
}
@@ -0,0 +1,254 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
import '../../core/auth/auth_provider.dart';
// ── Model ────────────────────────────────────────────────────────────────────
enum TableStatus { free, busy, reserved, cleaning }
class TableItem {
const TableItem({
required this.id,
required this.number,
required this.status,
this.guestLabel,
this.orderTotal,
});
final String id;
final String number;
final TableStatus status;
final String? guestLabel;
final double? orderTotal;
factory TableItem.fromJson(Map<String, dynamic> j) {
final rawStatus = (j['status'] as String? ?? '').toLowerCase();
final status = switch (rawStatus) {
'busy' => TableStatus.busy,
'reserved' => TableStatus.reserved,
'cleaning' => TableStatus.cleaning,
_ => TableStatus.free,
};
final order = j['currentOrder'] as Map?;
return TableItem(
id: j['id'] as String,
number: j['number'] as String,
status: status,
guestLabel: order?['guestLabel'] as String?,
orderTotal: (order?['total'] as num?)?.toDouble(),
);
}
}
// ── Provider ─────────────────────────────────────────────────────────────────
final tableBoardProvider =
FutureProvider.autoDispose<List<TableItem>>((ref) async {
final session = ref.watch(authProvider);
if (session == null) return [];
final client = ref.watch(apiClientProvider);
final params = <String, dynamic>{'activeOnly': 'false'};
if (session.branchId != null) params['branchId'] = session.branchId;
final res = await client.dio.get<Map<String, dynamic>>(
'/api/cafes/${session.cafeId}/tables/board',
queryParameters: params,
);
final raw = res.data?['data'] as List? ?? [];
return raw
.map((e) => TableItem.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
});
// ── Screen ───────────────────────────────────────────────────────────────────
class TableBoardScreen extends ConsumerWidget {
const TableBoardScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final boardAsync = ref.watch(tableBoardProvider);
return Scaffold(
appBar: AppBar(
title: const Text('وضعیت میزها'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.invalidate(tableBoardProvider),
),
],
),
body: boardAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('خطا در بارگذاری میزها'),
const SizedBox(height: 12),
FilledButton(
onPressed: () => ref.invalidate(tableBoardProvider),
child: const Text('تلاش مجدد'),
),
],
),
),
data: (tables) => tables.isEmpty
? const Center(child: Text('میزی یافت نشد'))
: _TableGrid(tables: tables),
),
);
}
}
class _TableGrid extends StatelessWidget {
const _TableGrid({required this.tables});
final List<TableItem> tables;
@override
Widget build(BuildContext context) {
final free = tables.where((t) => t.status == TableStatus.free).length;
final busy = tables.where((t) => t.status == TableStatus.busy).length;
final cleaning =
tables.where((t) => t.status == TableStatus.cleaning).length;
return Column(
children: [
_StatusBar(free: free, busy: busy, cleaning: cleaning),
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1,
),
itemCount: tables.length,
itemBuilder: (_, i) => _TableCard(table: tables[i]),
),
),
],
);
}
}
class _StatusBar extends StatelessWidget {
const _StatusBar({
required this.free,
required this.busy,
required this.cleaning,
});
final int free;
final int busy;
final int cleaning;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
color: Theme.of(context).colorScheme.surfaceContainerLow,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_Chip(label: 'آزاد', count: free, color: Colors.green),
_Chip(label: 'اشغال', count: busy, color: Colors.orange),
_Chip(label: 'نظافت', count: cleaning, color: Colors.blue),
],
),
);
}
}
class _Chip extends StatelessWidget {
const _Chip(
{required this.label, required this.count, required this.color});
final String label;
final int count;
final Color color;
@override
Widget build(BuildContext context) {
return Row(children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text('$label: $count',
style: Theme.of(context).textTheme.labelMedium),
]);
}
}
class _TableCard extends StatelessWidget {
const _TableCard({required this.table});
final TableItem table;
Color _bgColor(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return switch (table.status) {
TableStatus.busy => Colors.orange.withOpacity(0.15),
TableStatus.cleaning => Colors.blue.withOpacity(0.12),
TableStatus.reserved => cs.primaryContainer.withOpacity(0.4),
TableStatus.free => cs.surfaceContainerHighest,
};
}
Color _borderColor() => switch (table.status) {
TableStatus.busy => Colors.orange,
TableStatus.cleaning => Colors.blue,
TableStatus.reserved => Colors.purple,
TableStatus.free => Colors.transparent,
};
String get _statusLabel => switch (table.status) {
TableStatus.busy => 'اشغال',
TableStatus.cleaning => 'نظافت',
TableStatus.reserved => 'رزرو',
TableStatus.free => 'آزاد',
};
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
decoration: BoxDecoration(
color: _bgColor(context),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: _borderColor(), width: 1.5),
),
padding: const EdgeInsets.all(8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
table.number,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(_statusLabel,
style: theme.textTheme.labelSmall
?.copyWith(color: _borderColor())),
if (table.guestLabel != null) ...[
const SizedBox(height: 2),
Text(
table.guestLabel!,
style: theme.textTheme.labelSmall,
overflow: TextOverflow.ellipsis,
),
],
],
),
);
}
}
+51
View File
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/router/app_router.dart';
import 'features/notifications/notification_provider.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await initLocalNotifications();
runApp(const ProviderScope(child: MeeziWaiterApp()));
}
class MeeziWaiterApp extends ConsumerWidget {
const MeeziWaiterApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(appRouterProvider);
return MaterialApp.router(
title: 'میزی — گارسون',
debugShowCheckedModeBanner: false,
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(0xFF10B981), // emerald-500
brightness: Brightness.light,
),
useMaterial3: true,
fontFamily: 'Vazirmatn',
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF10B981),
brightness: Brightness.dark,
),
useMaterial3: true,
fontFamily: 'Vazirmatn',
),
themeMode: ThemeMode.system,
routerConfig: router,
);
}
}
+48
View File
@@ -0,0 +1,48 @@
name: meezi_waiter
description: Meezi Waiter — real-time notification app for cafe staff
publish_to: "none"
version: 1.0.0+1
environment:
sdk: ">=3.3.0 <4.0.0"
flutter: ">=3.19.0"
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
# State management
flutter_riverpod: ^2.5.1
# Navigation
go_router: ^14.2.0
# HTTP
dio: ^5.4.3
# Secure token storage
flutter_secure_storage: ^9.2.2
# SignalR (ASP.NET Core compatible)
signalr_netcore: ^1.3.5
# Local notifications (foreground + background alerts)
flutter_local_notifications: ^17.2.3
# Persian date
shamsi_date: ^1.1.1
# Shared prefs for non-sensitive settings
shared_preferences: ^2.3.2
intl: ^0.19.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
flutter:
uses-material-design: true