diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..17ad4ca --- /dev/null +++ b/.claude/launch.json @@ -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 + } + ] +} diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9994418 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,3 @@ +{ + "allowedTools": ["*"] +} diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..08f8a16 --- /dev/null +++ b/.cursorrules @@ -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 always: + record ApiResponse(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 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 "۱۵٪ تخفیف" \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2ae62e7 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4c6806c --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..801f967 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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)" diff --git a/mobile/meezi_app/README.md b/mobile/meezi_app/README.md new file mode 100644 index 0000000..83911f2 --- /dev/null +++ b/mobile/meezi_app/README.md @@ -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. diff --git a/mobile/meezi_app/analysis_options.yaml b/mobile/meezi_app/analysis_options.yaml new file mode 100644 index 0000000..7c410f9 --- /dev/null +++ b/mobile/meezi_app/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + prefer_const_constructors: true diff --git a/mobile/meezi_app/lib/app/router.dart b/mobile/meezi_app/lib/app/router.dart new file mode 100644 index 0000000..ddb540f --- /dev/null +++ b/mobile/meezi_app/lib/app/router.dart @@ -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']!), + ), + ], +); diff --git a/mobile/meezi_app/lib/core/api/api_client.dart b/mobile/meezi_app/lib/core/api/api_client.dart new file mode 100644 index 0000000..def18fe --- /dev/null +++ b/mobile/meezi_app/lib/core/api/api_client.dart @@ -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; +} diff --git a/mobile/meezi_app/lib/core/api/api_config.dart b/mobile/meezi_app/lib/core/api/api_config.dart new file mode 100644 index 0000000..dc617d0 --- /dev/null +++ b/mobile/meezi_app/lib/core/api/api_config.dart @@ -0,0 +1,4 @@ +const String apiBaseUrl = String.fromEnvironment( + 'MEEZI_API_URL', + defaultValue: 'http://localhost:5080', +); diff --git a/mobile/meezi_app/lib/core/menu_item_visual.dart b/mobile/meezi_app/lib/core/menu_item_visual.dart new file mode 100644 index 0000000..929b50e --- /dev/null +++ b/mobile/meezi_app/lib/core/menu_item_visual.dart @@ -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; +} diff --git a/mobile/meezi_app/lib/core/sync/sync_engine.dart b/mobile/meezi_app/lib/core/sync/sync_engine.dart new file mode 100644 index 0000000..c650d41 --- /dev/null +++ b/mobile/meezi_app/lib/core/sync/sync_engine.dart @@ -0,0 +1,22 @@ +/// Queues HR attendance actions when offline; syncs on reconnect. +class SyncEngine { + final List> _queue = []; + + List> 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(); +} diff --git a/mobile/meezi_app/lib/core/utils/currency_utils.dart b/mobile/meezi_app/lib/core/utils/currency_utils.dart new file mode 100644 index 0000000..a8908bf --- /dev/null +++ b/mobile/meezi_app/lib/core/utils/currency_utils.dart @@ -0,0 +1 @@ +String formatToman(num value) => '${value.toStringAsFixed(0)} ت'; diff --git a/mobile/meezi_app/lib/features/cart/cart_screen.dart b/mobile/meezi_app/lib/features/cart/cart_screen.dart new file mode 100644 index 0000000..61d9aa4 --- /dev/null +++ b/mobile/meezi_app/lib/features/cart/cart_screen.dart @@ -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 createState() => _CartScreenState(); +} + +class _CartScreenState extends ConsumerState { + final _phoneController = TextEditingController(); + final _nameController = TextEditingController(); + bool _submitting = false; + + @override + void dispose() { + _phoneController.dispose(); + _nameController.dispose(); + super.dispose(); + } + + Future _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('ثبت سفارش'), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/meezi_app/lib/features/cart/cart_state.dart b/mobile/meezi_app/lib/features/cart/cart_state.dart new file mode 100644 index 0000000..6dc075b --- /dev/null +++ b/mobile/meezi_app/lib/features/cart/cart_state.dart @@ -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 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 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? 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 { + 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((ref) => ApiClient()); + +final publicApiProvider = Provider((ref) => PublicApi(ref.watch(apiClientProvider))); + +final cartProvider = StateNotifierProvider((ref) => CartNotifier()); diff --git a/mobile/meezi_app/lib/features/discover/cafe_detail_screen.dart b/mobile/meezi_app/lib/features/discover/cafe_detail_screen.dart new file mode 100644 index 0000000..fa0bfe3 --- /dev/null +++ b/mobile/meezi_app/lib/features/discover/cafe_detail_screen.dart @@ -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?, String>((ref, slug) { + return ref.watch(publicApiProvider).getCafe(slug); +}); + +final cafeReviewsProvider = + FutureProvider.autoDispose.family>, 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 createState() => _CafeDetailScreenState(); +} + +class _CafeDetailScreenState extends ConsumerState { + final _nameController = TextEditingController(); + final _commentController = TextEditingController(); + int _rating = 5; + bool _submitting = false; + + @override + void dispose() { + _nameController.dispose(); + _commentController.dispose(); + super.dispose(); + } + + Future _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('ارسال نظر'), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/mobile/meezi_app/lib/features/discover/discover_screen.dart b/mobile/meezi_app/lib/features/discover/discover_screen.dart new file mode 100644 index 0000000..729e689 --- /dev/null +++ b/mobile/meezi_app/lib/features/discover/discover_screen.dart @@ -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( + (_) => (q: null, minRating: null, sort: 'rating'), +); + +final discoverProvider = FutureProvider.autoDispose>>((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 createState() => _DiscoverScreenState(); +} + +class _DiscoverScreenState extends ConsumerState { + 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( + 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')), + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/meezi_app/lib/features/hr/attendance_screen.dart b/mobile/meezi_app/lib/features/hr/attendance_screen.dart new file mode 100644 index 0000000..5633eec --- /dev/null +++ b/mobile/meezi_app/lib/features/hr/attendance_screen.dart @@ -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 createState() => _AttendanceScreenState(); +} + +class _AttendanceScreenState extends ConsumerState { + final _reasonController = TextEditingController(); + Jalali? _leaveStart; + Jalali? _leaveEnd; + String? _message; + + @override + void dispose() { + _reasonController.dispose(); + super.dispose(); + } + + Future _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 _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)), + ], + ], + ), + ), + ); + } +} diff --git a/mobile/meezi_app/lib/features/hr/hr_api.dart b/mobile/meezi_app/lib/features/hr/hr_api.dart new file mode 100644 index 0000000..fdf9c08 --- /dev/null +++ b/mobile/meezi_app/lib/features/hr/hr_api.dart @@ -0,0 +1,55 @@ +import 'package:dio/dio.dart'; + +import '../../core/api/api_client.dart'; + +class HrApi { + HrApi(this._client); + + final ApiClient _client; + + Future?> fetchTodayShift({ + required String cafeId, + required String employeeId, + }) async { + final res = await _client.dio.get>( + '/api/cafes/$cafeId/employees/$employeeId/shift/today', + ); + final data = res.data?['data'] as Map?; + return data; + } + + Future clockIn({ + required String cafeId, + required String employeeId, + }) async { + await _client.dio.post( + '/api/cafes/$cafeId/employees/$employeeId/attendance/clock-in', + ); + } + + Future clockOut({ + required String cafeId, + required String employeeId, + }) async { + await _client.dio.post( + '/api/cafes/$cafeId/employees/$employeeId/attendance/clock-out', + ); + } + + Future 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, + }, + ); + } +} diff --git a/mobile/meezi_app/lib/features/hr/hr_providers.dart b/mobile/meezi_app/lib/features/hr/hr_providers.dart new file mode 100644 index 0000000..807ffd2 --- /dev/null +++ b/mobile/meezi_app/lib/features/hr/hr_providers.dart @@ -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((ref) => HrApi(ref.watch(apiClientProvider))); + +final syncEngineProvider = Provider((ref) => SyncEngine()); + +final hrSessionProvider = Provider( + (_) => const HrSession(cafeId: _demoCafeId, employeeId: _demoEmployeeId), +); + +final todayShiftProvider = FutureProvider?>((ref) async { + final session = ref.watch(hrSessionProvider); + if (session == null) return null; + return ref.watch(hrApiProvider).fetchTodayShift( + cafeId: session.cafeId, + employeeId: session.employeeId, + ); +}); diff --git a/mobile/meezi_app/lib/features/menu/menu_screen.dart b/mobile/meezi_app/lib/features/menu/menu_screen.dart new file mode 100644 index 0000000..0daf343 --- /dev/null +++ b/mobile/meezi_app/lib/features/menu/menu_screen.dart @@ -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?, 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 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 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 createState() => _MenuScreenState(); +} + +class _MenuScreenState extends ConsumerState { + 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? ?? []; + 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, lang), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.6, + color: Color(0xFF64748B), + ), + ), + ), + ...((cat['items'] as List? ?? []).map((item) { + final id = item['id'] as String; + final catMap = cat as Map; + final catId = catMap['id'] as String? ?? ''; + final catName = _menuPrimaryName(catMap, lang); + final name = _menuPrimaryName(item as Map, lang); + final nameEnSub = _menuEnglishSubtitle(item as Map, 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, + ), + ); + } +} diff --git a/mobile/meezi_app/lib/features/public/public_api.dart b/mobile/meezi_app/lib/features/public/public_api.dart new file mode 100644 index 0000000..8e4ace6 --- /dev/null +++ b/mobile/meezi_app/lib/features/public/public_api.dart @@ -0,0 +1,117 @@ +import '../../core/api/api_client.dart'; + +class PublicApi { + PublicApi(this._client); + + final ApiClient _client; + + Future>> discover({ + String? city, + String? q, + double? minRating, + String? sort, + }) async { + final params = {}; + 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>( + '/api/public/discover', + queryParameters: params.isEmpty ? null : params, + ); + final list = res.data?['data'] as List? ?? []; + return list.cast>(); + } + + Future>> getReviews(String slug, {int page = 1}) async { + final res = await _client.dio.get>( + '/api/public/cafes/$slug/reviews', + queryParameters: {'page': page, 'pageSize': 20}, + ); + final list = res.data?['data'] as List? ?? []; + return list.cast>(); + } + + Future?> createReview( + String slug, { + required String authorName, + required int rating, + String? comment, + String? authorPhone, + }) async { + final res = await _client.dio.post>( + '/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?; + } + + Future?> getCafe(String slug) async { + final res = await _client.dio.get>('/api/public/cafes/$slug'); + return res.data?['data'] as Map?; + } + + Future?> getMenu(String slug) async { + final res = await _client.dio.get>('/api/public/cafes/$slug/menu'); + return res.data?['data'] as Map?; + } + + Future?> resolveQr(String qrCode) async { + final res = await _client.dio.get>('/api/q/$qrCode'); + return res.data?['data'] as Map?; + } + + Future?> placeOrder( + String slug, { + required String? tableId, + required List> items, + String? guestPhone, + String? guestName, + }) async { + final res = await _client.dio.post>( + '/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?; + } + + Future?> trackOrder(String orderId) async { + final res = await _client.dio.get>('/api/public/orders/$orderId/track'); + return res.data?['data'] as Map?; + } + + Future?> 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>( + '/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?; + } +} diff --git a/mobile/meezi_app/lib/features/qr/qr_scan_screen.dart b/mobile/meezi_app/lib/features/qr/qr_scan_screen.dart new file mode 100644 index 0000000..1d27e04 --- /dev/null +++ b/mobile/meezi_app/lib/features/qr/qr_scan_screen.dart @@ -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 createState() => _QrScanScreenState(); +} + +class _QrScanScreenState extends ConsumerState { + final _manualController = TextEditingController(text: 'demo_table_01'); + bool _loading = false; + bool _handled = false; + + @override + void dispose() { + _manualController.dispose(); + super.dispose(); + } + + Future _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('باز کردن منو'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/meezi_app/lib/features/reserve/reserve_screen.dart b/mobile/meezi_app/lib/features/reserve/reserve_screen.dart new file mode 100644 index 0000000..bdad5d8 --- /dev/null +++ b/mobile/meezi_app/lib/features/reserve/reserve_screen.dart @@ -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 createState() => _ReserveScreenState(); +} + +class _ReserveScreenState extends ConsumerState { + 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 _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)), + ], + ], + ), + ), + ); + } +} diff --git a/mobile/meezi_app/lib/features/table/table_context.dart b/mobile/meezi_app/lib/features/table/table_context.dart new file mode 100644 index 0000000..7710c0a --- /dev/null +++ b/mobile/meezi_app/lib/features/table/table_context.dart @@ -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 { + 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((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; +} diff --git a/mobile/meezi_app/lib/features/track/track_screen.dart b/mobile/meezi_app/lib/features/track/track_screen.dart new file mode 100644 index 0000000..fff49f6 --- /dev/null +++ b/mobile/meezi_app/lib/features/track/track_screen.dart @@ -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?, 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? ?? []; + 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')), + ), + ), + ); + } +} diff --git a/mobile/meezi_app/lib/main.dart b/mobile/meezi_app/lib/main.dart new file mode 100644 index 0000000..bfd70fb --- /dev/null +++ b/mobile/meezi_app/lib/main.dart @@ -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, + ); + } +} diff --git a/mobile/meezi_app/pubspec.yaml b/mobile/meezi_app/pubspec.yaml new file mode 100644 index 0000000..758ba5d --- /dev/null +++ b/mobile/meezi_app/pubspec.yaml @@ -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 diff --git a/mobile/meezi_pos/README.md b/mobile/meezi_pos/README.md new file mode 100644 index 0000000..61c5dc9 --- /dev/null +++ b/mobile/meezi_pos/README.md @@ -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). diff --git a/mobile/meezi_pos/analysis_options.yaml b/mobile/meezi_pos/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/mobile/meezi_pos/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/mobile/meezi_pos/lib/core/router/app_router.dart b/mobile/meezi_pos/lib/core/router/app_router.dart new file mode 100644 index 0000000..7a8cef8 --- /dev/null +++ b/mobile/meezi_pos/lib/core/router/app_router.dart @@ -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((ref) { + return GoRouter( + initialLocation: '/login', + routes: [ + GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), + GoRoute(path: '/pos', builder: (_, __) => const PosScreen()), + ], + ); +}); diff --git a/mobile/meezi_pos/lib/features/auth/login_screen.dart b/mobile/meezi_pos/lib/features/auth/login_screen.dart new file mode 100644 index 0000000..c8b799b --- /dev/null +++ b/mobile/meezi_pos/lib/features/auth/login_screen.dart @@ -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 createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + 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('ورود (دمو)'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/meezi_pos/lib/features/pos/pos_screen.dart b/mobile/meezi_pos/lib/features/pos/pos_screen.dart new file mode 100644 index 0000000..69f3017 --- /dev/null +++ b/mobile/meezi_pos/lib/features/pos/pos_screen.dart @@ -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)'), + ), + ); + } +} diff --git a/mobile/meezi_pos/lib/main.dart b/mobile/meezi_pos/lib/main.dart new file mode 100644 index 0000000..d7c2a3e --- /dev/null +++ b/mobile/meezi_pos/lib/main.dart @@ -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, + ); + } +} diff --git a/mobile/meezi_pos/pubspec.yaml b/mobile/meezi_pos/pubspec.yaml new file mode 100644 index 0000000..0a42fb3 --- /dev/null +++ b/mobile/meezi_pos/pubspec.yaml @@ -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 diff --git a/mobile/meezi_waiter/lib/core/api/api_client.dart b/mobile/meezi_waiter/lib/core/api/api_client.dart new file mode 100644 index 0000000..d036004 --- /dev/null +++ b/mobile/meezi_waiter/lib/core/api/api_client.dart @@ -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( + (_) => const FlutterSecureStorage(), +); + +final apiClientProvider = Provider((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 saveToken(String token) => + _storage.write(key: _tokenKey, value: token); + + Future readToken() => _storage.read(key: _tokenKey); + + Future clearToken() => _storage.delete(key: _tokenKey); +} diff --git a/mobile/meezi_waiter/lib/core/api/api_config.dart b/mobile/meezi_waiter/lib/core/api/api_config.dart new file mode 100644 index 0000000..5b6e38b --- /dev/null +++ b/mobile/meezi_waiter/lib/core/api/api_config.dart @@ -0,0 +1,4 @@ +const String apiBaseUrl = String.fromEnvironment( + 'API_BASE_URL', + defaultValue: 'https://localhost:7208', +); diff --git a/mobile/meezi_waiter/lib/core/auth/auth_provider.dart b/mobile/meezi_waiter/lib/core/auth/auth_provider.dart new file mode 100644 index 0000000..060966f --- /dev/null +++ b/mobile/meezi_waiter/lib/core/auth/auth_provider.dart @@ -0,0 +1,59 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/api_client.dart'; +import 'auth_state.dart'; + +final authProvider = + StateNotifierProvider((ref) { + return AuthNotifier(ref.watch(apiClientProvider)); +}); + +class AuthNotifier extends StateNotifier { + AuthNotifier(this._client) : super(null) { + _restore(); + } + + final ApiClient _client; + + Future _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>('/api/auth/me'); + final data = res.data?['data'] as Map?; + 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 sendOtp(String phone) async { + final res = await _client.dio.post>( + '/api/auth/send-otp', + data: {'phone': phone}, + ); + final data = res.data?['data'] as Map?; + return (data?['sessionId'] ?? '') as String; + } + + Future verifyOtp(String phone, String otp) async { + final res = await _client.dio.post>( + '/api/auth/verify-otp', + data: {'phone': phone, 'otp': otp}, + ); + final data = res.data?['data'] as Map?; + if (data == null) throw Exception('AUTH_FAILED'); + final session = WaiterSession.fromJson(data); + await _client.saveToken(session.accessToken); + state = session; + } + + Future logout() async { + await _client.clearToken(); + state = null; + } +} diff --git a/mobile/meezi_waiter/lib/core/auth/auth_state.dart b/mobile/meezi_waiter/lib/core/auth/auth_state.dart new file mode 100644 index 0000000..8b40d89 --- /dev/null +++ b/mobile/meezi_waiter/lib/core/auth/auth_state.dart @@ -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 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; +} diff --git a/mobile/meezi_waiter/lib/core/hub/hub_client.dart b/mobile/meezi_waiter/lib/core/hub/hub_client.dart new file mode 100644 index 0000000..7682b8f --- /dev/null +++ b/mobile/meezi_waiter/lib/core/hub/hub_client.dart @@ -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 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.broadcast(); + + Stream get notifications => _notificationController.stream; + + bool get isConnected => + _connection?.state == HubConnectionState.Connected; + + Future 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? args) { + if (args == null || args.isEmpty) return; + final raw = args[0]; + if (raw is! Map) return; + final m = Map.from(raw); + _notificationController.add(HubNotification.fromMap(m)); + } + + Future dispose() async { + await _connection?.stop(); + await _notificationController.close(); + } +} diff --git a/mobile/meezi_waiter/lib/core/hub/hub_provider.dart b/mobile/meezi_waiter/lib/core/hub/hub_provider.dart new file mode 100644 index 0000000..16ebe5b --- /dev/null +++ b/mobile/meezi_waiter/lib/core/hub/hub_provider.dart @@ -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((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; +}); diff --git a/mobile/meezi_waiter/lib/core/router/app_router.dart b/mobile/meezi_waiter/lib/core/router/app_router.dart new file mode 100644 index 0000000..4284b2a --- /dev/null +++ b/mobile/meezi_waiter/lib/core/router/app_router.dart @@ -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((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(), + ), + ], + ); +}); diff --git a/mobile/meezi_waiter/lib/features/auth/login_screen.dart b/mobile/meezi_waiter/lib/features/auth/login_screen.dart new file mode 100644 index 0000000..b5af2fe --- /dev/null +++ b/mobile/meezi_waiter/lib/features/auth/login_screen.dart @@ -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 createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + 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 _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 _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), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/meezi_waiter/lib/features/home/home_screen.dart b/mobile/meezi_waiter/lib/features/home/home_screen.dart new file mode 100644 index 0000000..289b8af --- /dev/null +++ b/mobile/meezi_waiter/lib/features/home/home_screen.dart @@ -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 createState() => _HomeScreenState(); +} + +class _HomeScreenState extends ConsumerState { + 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: 'شیفت من', + ), + ], + ), + ), + ); + } +} diff --git a/mobile/meezi_waiter/lib/features/notifications/notification_provider.dart b/mobile/meezi_waiter/lib/features/notifications/notification_provider.dart new file mode 100644 index 0000000..2686b1b --- /dev/null +++ b/mobile/meezi_waiter/lib/features/notifications/notification_provider.dart @@ -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 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 _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 items; + final bool isLoading; + + int get unreadCount => items.where((n) => !n.isRead).length; + + NotificationState copyWith({ + List? items, + bool? isLoading, + }) => + NotificationState( + items: items ?? this.items, + isLoading: isLoading ?? this.isLoading, + ); +} + +final notificationProvider = + StateNotifierProvider((ref) { + final notifier = NotificationNotifier(ref); + notifier._init(); + return notifier; +}); + +class NotificationNotifier extends StateNotifier { + NotificationNotifier(this._ref) : super(const NotificationState()); + + final Ref _ref; + StreamSubscription? _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 _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>( + '/api/cafes/${session.cafeId}/notifications', + queryParameters: {'limit': 50}, + ); + final raw = res.data?['data'] as List?; + final items = raw + ?.map((e) => + WaiterNotification.fromJson(Map.from(e as Map))) + .toList() ?? + []; + state = state.copyWith(items: items, isLoading: false); + } catch (_) { + state = state.copyWith(isLoading: false); + } + } + + Future refresh() => _fetchFromApi(); + + Future 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 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(); + } +} diff --git a/mobile/meezi_waiter/lib/features/notifications/notifications_screen.dart b/mobile/meezi_waiter/lib/features/notifications/notifications_screen.dart new file mode 100644 index 0000000..bd5a090 --- /dev/null +++ b/mobile/meezi_waiter/lib/features/notifications/notifications_screen.dart @@ -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, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/meezi_waiter/lib/features/notifications/waiter_notification.dart b/mobile/meezi_waiter/lib/features/notifications/waiter_notification.dart new file mode 100644 index 0000000..957e9c8 --- /dev/null +++ b/mobile/meezi_waiter/lib/features/notifications/waiter_notification.dart @@ -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 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'; +} diff --git a/mobile/meezi_waiter/lib/features/shift/shift_screen.dart b/mobile/meezi_waiter/lib/features/shift/shift_screen.dart new file mode 100644 index 0000000..6115a52 --- /dev/null +++ b/mobile/meezi_waiter/lib/features/shift/shift_screen.dart @@ -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?>((ref) async { + final session = ref.watch(authProvider); + if (session == null) return null; + final client = ref.watch(apiClientProvider); + try { + final res = await client.dio.get>( + '/api/cafes/${session.cafeId}/employees/${session.userId}/shift/today', + ); + return res.data?['data'] as Map?; + } catch (_) { + return null; + } +}); + +class ShiftScreen extends ConsumerStatefulWidget { + const ShiftScreen({super.key}); + + @override + ConsumerState createState() => _ShiftScreenState(); +} + +class _ShiftScreenState extends ConsumerState { + String? _message; + bool _busy = false; + + Future _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, + ), + ), + ], + ], + ), + ); + } +} diff --git a/mobile/meezi_waiter/lib/features/tables/table_board_screen.dart b/mobile/meezi_waiter/lib/features/tables/table_board_screen.dart new file mode 100644 index 0000000..22581ee --- /dev/null +++ b/mobile/meezi_waiter/lib/features/tables/table_board_screen.dart @@ -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 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>((ref) async { + final session = ref.watch(authProvider); + if (session == null) return []; + final client = ref.watch(apiClientProvider); + + final params = {'activeOnly': 'false'}; + if (session.branchId != null) params['branchId'] = session.branchId; + + final res = await client.dio.get>( + '/api/cafes/${session.cafeId}/tables/board', + queryParameters: params, + ); + final raw = res.data?['data'] as List? ?? []; + return raw + .map((e) => TableItem.fromJson(Map.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 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, + ), + ], + ], + ), + ); + } +} diff --git a/mobile/meezi_waiter/lib/main.dart b/mobile/meezi_waiter/lib/main.dart new file mode 100644 index 0000000..0c9bbef --- /dev/null +++ b/mobile/meezi_waiter/lib/main.dart @@ -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 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, + ); + } +} diff --git a/mobile/meezi_waiter/pubspec.yaml b/mobile/meezi_waiter/pubspec.yaml new file mode 100644 index 0000000..9f0aa10 --- /dev/null +++ b/mobile/meezi_waiter/pubspec.yaml @@ -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