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