+
+ ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+
+ 🔄 Events - ارسال Real-time
+
+ این eventها هر بار که چیزی تغییر میکند ارسال میشوند.
+
+ ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+
+ 1️⃣ SCREEN_SIZE_CHANGED - تغییر اندازه صفحه
+
+ 📱 چه موقع رخ میدهد؟
+
+ ┌─────────────┐ ┌──────────────────────┐
+ │ Portrait │ │ Landscape │
+ │ │ │ │
+ │ iPhone │ چرخش گوشی │ iPhone │
+ │ │ ──────────> │ │
+ │ 📱 │ │ 📱 │
+ │ │ │ │
+ └─────────────┘ └──────────────────────┘
+ 375 × 812 812 × 375
+
+ Event ارسالی:
+
+ {
+ "type": "SCREEN_SIZE_CHANGED",
+ "payload": {
+ "width": 812,
+ "height": 375,
+ "orientation": "landscape",
+ "breakpoint": "tablet" // ممکن است تغییر کند!
+ }
+ }
+
+ React چه کار باید بکند:
+
+ useEffect(() => {
+ window.addEventListener('message', (event) => {
+ if (event.data.type === 'SCREEN_SIZE_CHANGED') {
+ const { width, orientation, breakpoint } = event.data.payload;
+
+ // 1. تغییر layout
+ if (breakpoint === 'tablet') {
+ setPadding(64); // در landscape = tablet
+ } else {
+ setPadding(16); // در portrait = mobile
+ }
+
+ // 2. تنظیم مجدد video player
+ if (orientation === 'landscape') {
+ setVideoPlayerFullWidth(true);
+ }
+ }
+ });
+ }, []);
+
+ مثال واقعی:
+
+ کاربر گوشی را میچرخاند (portrait → landscape)
+ → عرض صفحه: 375 → 812
+ → breakpoint تغییر میکند: mobile → tablet
+ → padding چت باید تغییر کند: 16px → 64px
+
+ ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+
+ 2️⃣ KEYBOARD_CHANGED - باز/بسته شدن کیبورد
+
+ 📱 چه موقع رخ میدهد؟
+
+ ┌─────────────────┐ ┌─────────────────┐
+ │ Chat │ │ Chat │
+ │ Messages │ │ Messages │
+ │ │ │ (کمتر فضا) │
+ │ │ ├─────────────────┤
+ │ │ کلیک → │ TextField │
+ ├─────────────────┤ ├─────────────────┤
+ │ TextField │ │ Keyboard │
+ └─────────────────┘ │ ⌨️ │
+ └─────────────────┘
+
+ Event ارسالی:
+
+ // کیبورد باز شد:
+ {
+ "type": "KEYBOARD_CHANGED",
+ "payload": {
+ "visible": true,
+ "height": 336, // ارتفاع کیبورد
+ "animationDuration": 250 // مدت زمان انیمیشن (ms)
+ }
+ }
+
+ // کیبورد بسته شد:
+ {
+ "type": "KEYBOARD_CHANGED",
+ "payload": {
+ "visible": false,
+ "height": 0,
+ "animationDuration": 250
+ }
+ }
+
+ React چه کار باید بکند:
+
+ const [keyboardHeight, setKeyboardHeight] = useState(0);
+
+ useEffect(() => {
+ window.addEventListener('message', (event) => {
+ if (event.data.type === 'KEYBOARD_CHANGED') {
+ const { visible, height, animationDuration } = event.data.payload;
+
+ // با انیمیشن smooth
+ setKeyboardHeight(visible ? height : 0);
+
+ // Scroll به آخرین پیام
+ if (visible) {
+ setTimeout(() => {
+ chatListRef.current?.scrollToBottom();
+ }, animationDuration);
+ }
+ }
+ });
+ }, []);
+
+ // در UI:
+
+
+ مثال واقعی:
+
+ کاربر روی TextField کلیک میکند
+ → کیبورد باز میشود (336px)
+ → لیست چت باید 336px بالا برود
+ → با انیمیشن smooth (250ms)
+
+ ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+
+ 3️⃣ SAFE_AREA_CHANGED - تغییر فضای امن
+
+ 📱 چه موقع رخ میدهد؟
+
+ ┌─────────────────┐ ┌─────────────────┐
+ │ Status Bar 44px │ │ (مخفی شده) │
+ ├─────────────────┤ ├─────────────────┤
+ │ │ │ │
+ │ Full Screen │ تمام صفحه│ Video/Game │
+ │ Video │ ──────→ │ Full Screen │
+ │ │ │ │
+ ├─────────────────┤ ├─────────────────┤
+ │ Home Bar 34px │ │ (مخفی شده) │
+ └─────────────────┘ └─────────────────┘
+
+ Event ارسالی:
+
+ // حالت عادی:
+ {
+ "type": "SAFE_AREA_CHANGED",
+ "payload": {
+ "top": 44,
+ "bottom": 34,
+ "left": 0,
+ "right": 0
+ }
+ }
+
+ // حالت full screen (video player):
+ {
+ "type": "SAFE_AREA_CHANGED",
+ "payload": {
+ "top": 0, // status bar مخفی شد
+ "bottom": 0, // home indicator مخفی شد
+ "left": 0,
+ "right": 0
+ }
+ }
+
+ React چه کار باید بکند:
+
+ useEffect(() => {
+ window.addEventListener('message', (event) => {
+ if (event.data.type === 'SAFE_AREA_CHANGED') {
+ const { top, bottom } = event.data.payload;
+
+ // اگر safe area = 0 → یعنی full screen شده
+ if (top === 0 && bottom === 0) {
+ setIsFullScreen(true);
+ hideHeader();
+ hideBottomNav();
+ } else {
+ setIsFullScreen(false);
+ showHeader();
+ showBottomNav();
+ }
+ }
+ });
+ }, []);
+
+ مثال واقعی:
+
+ کاربر video call را full screen میکند
+ → status bar و home indicator مخفی میشوند
+ → safe area: top=44 → 0, bottom=34 → 0
+ → ویدیو تمام صفحه را میپوشاند
+
+ ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+
+ 4️⃣ ORIENTATION_CHANGED - تغییر orientation
+
+ 📱 چه موقع رخ میدهد؟
+
+ Portrait → Landscape → Portrait
+ 📱 → 📱 → 📱
+
+ Event ارسالی:
+
+ {
+ "type": "ORIENTATION_CHANGED",
+ "payload": {
+ "orientation": "landscape", // portrait | landscape
+ "screenWidth": 812,
+ "screenHeight": 375
+ }
+ }
+
+ React چه کار باید بکند:
+
+ useEffect(() => {
+ window.addEventListener('message', (event) => {
+ if (event.data.type === 'ORIENTATION_CHANGED') {
+ const { orientation } = event.data.payload;
+
+ if (orientation === 'landscape') {
+ // ویدیو پلیر را بزرگتر کن
+ setVideoPlayerSize('large');
+
+ // دکمهها را در کنار قرار بده (row)
+ setButtonsLayout('horizontal');
+ } else {
+ // ویدیو پلیر را عادی کن
+ setVideoPlayerSize('normal');
+
+ // دکمهها را زیر هم قرار بده (column)
+ setButtonsLayout('vertical');
+ }
+ }
+ });
+ }, []);
+
+ ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
+
+ 📊 مثال کامل: سناریوی واقعی
+
+ سناریو: کاربر در حال چت است و گوشی را میچرخاند.
+
+ // 1. Initial Load
+ {
+ "layout": { "screenWidth": 375, "isMobile": true },
+ "safeArea": { "top": 44, "bottom": 34 }
+ }
+ → Chat با padding 16px نمایش داده میشود
+
+ // 2. کاربر روی TextField کلیک میکند
+ {
+ "type": "KEYBOARD_CHANGED",
+ "payload": { "visible": true, "height": 336 }
+ }
+ → Chat list 336px بالا میرود
+
+ // 3. کاربر گوشی را میچرخاند (landscape)
+ {
+ "type": "ORIENTATION_CHANGED",
+ "payload": { "orientation": "landscape", "screenWidth": 812 }
+ }
+ → Layout تغییر میکند
+
+ {
+ "type": "SCREEN_SIZE_CHANGED",
+ "payload": { "width": 812, "breakpoint": "tablet" }
+ }
+ → Padding از 16px به 64px تغییر میکند
+
+ {
+ "type": "KEYBOARD_CHANGED",
+ "payload": { "visible": true, "height": 220 } // کیبورد کوچکتر در landscape
+ }
+ → Keyboard height جدید
+
+ // 4. کاربر کیبورد را میبندد
+ {
+ "type": "KEYBOARD_CHANGED",
+ "payload": { "visible": false, "height": 0 }
+ }
+ → Chat list به حالت عادی برمیگردد
+
+ ✅ خلاصه
+
+ ┌─────────────────────┬───────────────────┬────────────────────────────────────────────────┐
+ │ Event │ چه موقع؟ │ چرا مهم است؟ │
+ ├─────────────────────┼───────────────────┼────────────────────────────────────────────────┤
+ │ Initial Config │ یکبار هنگام load │ تنظیمات اولیه (breakpoint, safeArea, platform) │
+ ├─────────────────────┼───────────────────┼────────────────────────────────────────────────┤
+ │ SCREEN_SIZE_CHANGED │ چرخش گوشی │ تغییر padding, layout │
+ ├─────────────────────┼───────────────────┼────────────────────────────────────────────────┤
+ │ KEYBOARD_CHANGED │ باز/بسته کیبورد │ جا دادن TextField، scroll به پیام │
+ ├─────────────────────┼───────────────────┼────────────────────────────────────────────────┤
+ │ SAFE_AREA_CHANGED │ full screen video │ مخفی/نمایش header/footer │
+ ├─────────────────────┼───────────────────┼────────────────────────────────────────────────┤
+ │ ORIENTATION_CHANGED │ چرخش گوشی │ تغییر UI (horizontal/vertical) │
+ └─────────────────────┴───────────────────┴────────────────────────────────────────────────┘
diff --git a/WEBVIEW_LAYOUT_BRIDGE.md b/WEBVIEW_LAYOUT_BRIDGE.md
new file mode 100644
index 0000000..cfef8ca
--- /dev/null
+++ b/WEBVIEW_LAYOUT_BRIDGE.md
@@ -0,0 +1,652 @@
+# 📐 اطلاعات Layout و Responsive که باید از Flutter به WebView ارسال شود
+
+## 🎯 خلاصه اجرایی
+
+این سند تمام اطلاعاتی که Flutter باید به WebView ارسال کند تا صفحه بهدرستی نمایش داده شود را مستند میکند.
+
+---
+
+## 📊 ۱. Responsive Breakpoints
+
+### تعریف در Flutter:
+
+```dart
+// lib/app/application.dart (خط 123-126)
+ResponsiveBreakpoints.builder(
+ breakpoints: const [
+ Breakpoint(start: 0, end: 800, name: MOBILE),
+ Breakpoint(start: 800, end: 1600, name: TABLET),
+ ],
+)
+```
+
+### استفاده در کد:
+
+```dart
+// lib/core/util/extensions/context_extensions.dart
+bool get isMobile => breakpoints.isMobile; // width < 800
+bool get isTablet => breakpoints.isTablet; // width >= 800
+bool get isDesktop => breakpoints.isDesktop; // width >= 1600 (غیرفعال)
+double get screenWidth => breakpoints.screenWidth;
+```
+
+### ✅ باید به WebView ارسال شود:
+
+```json
+{
+ "layout": {
+ "breakpoint": "mobile" | "tablet" | "desktop",
+ "screenWidth": 375,
+ "isMobile": true,
+ "isTablet": false
+ }
+}
+```
+
+### 📱 تاثیر در UI:
+
+| بخش | Mobile (< 800px) | Tablet (>= 800px) |
+|-----|-----------------|-------------------|
+| **Padding افقی** | `16px` | `64px` |
+| **Search Bar** | عرض کامل | عرض محدود با padding 64px |
+| **Chat List** | Single column | Single column با padding 64px |
+| **TabBar** | Centered، غیر اسکرول | Left-aligned، scrollable |
+| **Calendar Grid** | `width / 120` columns | `width / 120` columns |
+| **Chat TextField** | padding: 16px | padding: 64px horizontal |
+
+---
+
+## 📏 ۲. Safe Area & System Insets
+
+### تعریف در Flutter:
+
+```dart
+// lib/core/util/extensions/context_extensions.dart
+MediaQueryData get mq => MediaQuery.of(context);
+EdgeInsets get insets => mq.viewInsets; // Keyboard, etc
+EdgeInsets get padding => mq.viewPadding; // Status bar, notch, home indicator
+double get navigationBarHeight => max(insets.bottom, padding.bottom);
+```
+
+### ✅ باید به WebView ارسال شود:
+
+```json
+{
+ "safeArea": {
+ "top": 44, // Status bar / notch
+ "bottom": 34, // Home indicator (iPhone X+)
+ "left": 0,
+ "right": 0
+ },
+ "viewInsets": {
+ "top": 0,
+ "bottom": 336, // Keyboard height (تغییر میکند)
+ "left": 0,
+ "right": 0
+ },
+ "navigationBarHeight": 34 // max(viewInsets.bottom, safeArea.bottom)
+}
+```
+
+### 📱 تاثیر در UI:
+
+```dart
+// مثالهای استفاده در کد:
+
+// 1. Chat Page - Floating Action Button
+bottom: 16 + context.padding.bottom, // +34px on iPhone X
+right: context.isMobile ? 16 : 64,
+
+// 2. Chat TextField - Bottom padding
+EdgeInsets.only(
+ bottom: 16 + max(insets.bottom, padding.bottom),
+)
+
+// 3. Reviews Tab - List padding
+padding.addMore(bottom: context.padding.bottom),
+
+// 4. Call Screen - Bottom spacing
+SizedBox(height: 16 + context.padding.bottom),
+
+// 5. Become Provider Page - Top padding
+SizedBox(height: context.padding.top + 8),
+```
+
+### 🎨 پیادهسازی در React:
+
+```tsx
+// استفاده از CSS env() برای safe-area
+
+```
+
+**اما بهتر است از Flutter دریافت شود** چون:
+- Flutter دسترسی دقیقتر به system metrics دارد
+- `env()` در همه WebViewها پشتیبانی نمیشود
+- keyboard height در CSS قابل دسترسی نیست
+
+---
+
+## 🔄 ۳. تغییرات Real-time (باید لیسن شوند)
+
+### ۱. **Keyboard Visibility**
+
+```dart
+// زمانی که کیبورد باز/بسته میشود:
+MediaQuery.of(context).viewInsets.bottom
+```
+
+**Event به WebView:**
+```json
+{
+ "type": "KEYBOARD_CHANGED",
+ "payload": {
+ "visible": true,
+ "height": 336,
+ "animationDuration": 250
+ }
+}
+```
+
+**استفاده در React:**
+```tsx
+useEffect(() => {
+ window.addEventListener('message', (event) => {
+ if (event.data.type === 'KEYBOARD_CHANGED') {
+ const { height } = event.data.payload;
+ // تنظیم bottom padding چت
+ setChatBottomPadding(height + 16);
+ }
+ });
+}, []);
+```
+
+---
+
+### ۲. **Screen Rotation / Size Change**
+
+```dart
+// تغییر orientation یا resize
+MediaQuery.of(context).size
+```
+
+**Event به WebView:**
+```json
+{
+ "type": "SCREEN_SIZE_CHANGED",
+ "payload": {
+ "width": 812,
+ "height": 375,
+ "orientation": "landscape",
+ "breakpoint": "mobile"
+ }
+}
+```
+
+---
+
+### ۳. **Safe Area Changes**
+
+```dart
+// تغییر وقتی که status bar مخفی/نمایش داده میشود
+MediaQuery.of(context).viewPadding
+```
+
+**Event به WebView:**
+```json
+{
+ "type": "SAFE_AREA_CHANGED",
+ "payload": {
+ "top": 44,
+ "bottom": 34,
+ "left": 0,
+ "right": 0
+ }
+}
+```
+
+---
+
+## 🖥️ ۴. Design Sizes (Figma)
+
+```dart
+// lib/core/util/design_size.dart
+static const Size mobileSize = Size(375, 814);
+static const Size tabletSize = Size(834, 1194);
+```
+
+### ✅ باید به WebView ارسال شود:
+
+```json
+{
+ "designSize": {
+ "mobile": { "width": 375, "height": 814 },
+ "tablet": { "width": 834, "height": 1194 }
+ }
+}
+```
+
+**استفاده در React:**
+- برای محاسبه scaling factor
+- Responsive font sizes
+- Component sizing
+
+```tsx
+const scaleFactor = window.innerWidth / (isMobile ? 375 : 834);
+const fontSize = 16 * scaleFactor;
+```
+
+---
+
+## 🎯 ۵. Platform & Environment Info
+
+```dart
+// اطلاعات پلتفرم
+Platform.isIOS
+Platform.isAndroid
+```
+
+### ✅ باید به WebView ارسال شود:
+
+```json
+{
+ "platform": {
+ "os": "ios" | "android" | "web",
+ "version": "17.2",
+ "model": "iPhone 14 Pro",
+ "isPhysicalDevice": true,
+ "hasNotch": true,
+ "hasDynamicIsland": true
+ }
+}
+```
+
+### 📱 تاثیر در UI:
+
+- iOS: Cupertino-style transitions
+- Android: Material ripples
+- Safe area handling مختلف
+- Font rendering
+
+---
+
+## 📦 ۶. JSON کامل Initial Config
+
+این JSON باید **یکبار هنگام load WebView** ارسال شود:
+
+```json
+{
+ "version": "1.0.0",
+ "timestamp": 1234567890,
+
+ "layout": {
+ "breakpoint": "mobile",
+ "screenWidth": 375,
+ "screenHeight": 812,
+ "isMobile": true,
+ "isTablet": false,
+ "isDesktop": false
+ },
+
+ "safeArea": {
+ "top": 44,
+ "bottom": 34,
+ "left": 0,
+ "right": 0
+ },
+
+ "viewInsets": {
+ "top": 0,
+ "bottom": 0,
+ "left": 0,
+ "right": 0
+ },
+
+ "designSize": {
+ "mobile": { "width": 375, "height": 814 },
+ "tablet": { "width": 834, "height": 1194 }
+ },
+
+ "platform": {
+ "os": "ios",
+ "version": "17.2",
+ "model": "iPhone 14 Pro",
+ "isPhysicalDevice": true,
+ "hasNotch": true,
+ "hasDynamicIsland": true
+ },
+
+ "locale": {
+ "languageCode": "en",
+ "countryCode": "US",
+ "isRtl": false
+ },
+
+ "theme": {
+ "mode": "light",
+ "primaryColor": "#FFB236"
+ }
+}
+```
+
+---
+
+## 🔄 ۷. Events که باید به WebView ارسال شوند
+
+### ۱. Layout Changed
+```json
+{
+ "type": "LAYOUT_CHANGED",
+ "payload": {
+ "breakpoint": "tablet",
+ "screenWidth": 820,
+ "isMobile": false
+ }
+}
+```
+
+### ۲. Keyboard Changed
+```json
+{
+ "type": "KEYBOARD_CHANGED",
+ "payload": {
+ "visible": true,
+ "height": 336,
+ "animationDuration": 250
+ }
+}
+```
+
+### ۳. Safe Area Changed
+```json
+{
+ "type": "SAFE_AREA_CHANGED",
+ "payload": {
+ "top": 0,
+ "bottom": 0,
+ "left": 0,
+ "right": 0
+ }
+}
+```
+
+### ۴. Orientation Changed
+```json
+{
+ "type": "ORIENTATION_CHANGED",
+ "payload": {
+ "orientation": "landscape",
+ "screenWidth": 812,
+ "screenHeight": 375
+ }
+}
+```
+
+---
+
+## 💻 کد Flutter برای ارسال اطلاعات به WebView
+
+```dart
+import 'dart:convert';
+import 'package:flutter/material.dart';
+import 'package:webview_flutter/webview_flutter.dart';
+
+class TalkWebViewBridge {
+ final WebViewController controller;
+ BuildContext context;
+
+ TalkWebViewBridge(this.controller, this.context) {
+ _sendInitialConfig();
+ _listenToChanges();
+ }
+
+ // ارسال اولیه config
+ void _sendInitialConfig() {
+ final config = jsonEncode({
+ 'version': '1.0.0',
+ 'timestamp': DateTime.now().millisecondsSinceEpoch,
+
+ 'layout': {
+ 'breakpoint': _getBreakpoint(),
+ 'screenWidth': MediaQuery.of(context).size.width,
+ 'screenHeight': MediaQuery.of(context).size.height,
+ 'isMobile': context.isMobile,
+ 'isTablet': context.isTablet,
+ 'isDesktop': context.isDesktop,
+ },
+
+ 'safeArea': {
+ 'top': MediaQuery.of(context).padding.top,
+ 'bottom': MediaQuery.of(context).padding.bottom,
+ 'left': MediaQuery.of(context).padding.left,
+ 'right': MediaQuery.of(context).padding.right,
+ },
+
+ 'viewInsets': {
+ 'top': MediaQuery.of(context).viewInsets.top,
+ 'bottom': MediaQuery.of(context).viewInsets.bottom,
+ 'left': MediaQuery.of(context).viewInsets.left,
+ 'right': MediaQuery.of(context).viewInsets.right,
+ },
+
+ 'designSize': {
+ 'mobile': {'width': 375, 'height': 814},
+ 'tablet': {'width': 834, 'height': 1194},
+ },
+
+ 'platform': {
+ 'os': Platform.operatingSystem,
+ 'version': Platform.operatingSystemVersion,
+ },
+
+ 'locale': {
+ 'languageCode': context.languageCode,
+ 'isRtl': context.isRtl,
+ },
+ });
+
+ controller.runJavaScript('''
+ window.dispatchEvent(new CustomEvent('flutterConfig', {
+ detail: $config
+ }));
+ ''');
+ }
+
+ // لیسن به تغییرات keyboard
+ void _listenToChanges() {
+ // در هر frame چک میکنیم که آیا viewInsets تغییر کرده
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ final currentInsets = MediaQuery.of(context).viewInsets;
+
+ if (_lastInsets?.bottom != currentInsets.bottom) {
+ _sendKeyboardChanged(currentInsets.bottom);
+ _lastInsets = currentInsets;
+ }
+
+ _listenToChanges(); // تکرار
+ });
+ }
+
+ EdgeInsets? _lastInsets;
+
+ void _sendKeyboardChanged(double height) {
+ final event = jsonEncode({
+ 'type': 'KEYBOARD_CHANGED',
+ 'payload': {
+ 'visible': height > 0,
+ 'height': height,
+ 'animationDuration': 250,
+ },
+ });
+
+ controller.runJavaScript('''
+ window.dispatchEvent(new CustomEvent('flutterEvent', {
+ detail: $event
+ }));
+ ''');
+ }
+
+ String _getBreakpoint() {
+ if (context.isMobile) return 'mobile';
+ if (context.isTablet) return 'tablet';
+ return 'desktop';
+ }
+}
+```
+
+---
+
+## 💻 کد React برای دریافت اطلاعات
+
+```tsx
+// hooks/useFlutterLayout.ts
+import { useEffect, useState } from 'react';
+
+interface FlutterLayout {
+ breakpoint: 'mobile' | 'tablet' | 'desktop';
+ screenWidth: number;
+ screenHeight: number;
+ isMobile: boolean;
+ isTablet: boolean;
+ safeArea: {
+ top: number;
+ bottom: number;
+ left: number;
+ right: number;
+ };
+ viewInsets: {
+ bottom: number;
+ };
+}
+
+export function useFlutterLayout() {
+ const [layout, setLayout] = useState
({
+ breakpoint: 'mobile',
+ screenWidth: 375,
+ screenHeight: 812,
+ isMobile: true,
+ isTablet: false,
+ safeArea: { top: 0, bottom: 0, left: 0, right: 0 },
+ viewInsets: { bottom: 0 },
+ });
+
+ useEffect(() => {
+ // دریافت config اولیه
+ const handleConfig = (event: CustomEvent) => {
+ const config = event.detail;
+ setLayout({
+ breakpoint: config.layout.breakpoint,
+ screenWidth: config.layout.screenWidth,
+ screenHeight: config.layout.screenHeight,
+ isMobile: config.layout.isMobile,
+ isTablet: config.layout.isTablet,
+ safeArea: config.safeArea,
+ viewInsets: config.viewInsets,
+ });
+ };
+
+ // دریافت events
+ const handleEvent = (event: CustomEvent) => {
+ const { type, payload } = event.detail;
+
+ if (type === 'KEYBOARD_CHANGED') {
+ setLayout(prev => ({
+ ...prev,
+ viewInsets: {
+ ...prev.viewInsets,
+ bottom: payload.height,
+ },
+ }));
+ }
+
+ if (type === 'SAFE_AREA_CHANGED') {
+ setLayout(prev => ({
+ ...prev,
+ safeArea: payload,
+ }));
+ }
+ };
+
+ window.addEventListener('flutterConfig', handleConfig as EventListener);
+ window.addEventListener('flutterEvent', handleEvent as EventListener);
+
+ return () => {
+ window.removeEventListener('flutterConfig', handleConfig as EventListener);
+ window.removeEventListener('flutterEvent', handleEvent as EventListener);
+ };
+ }, []);
+
+ return layout;
+}
+```
+
+### استفاده در Component:
+
+```tsx
+function ChatPage() {
+ const layout = useFlutterLayout();
+
+ return (
+
+ {/* محتوای چت */}
+
+ );
+}
+```
+
+---
+
+## 📋 Checklist پیادهسازی
+
+### Flutter Side:
+- [ ] ایجاد `TalkWebViewBridge` class
+- [ ] ارسال initial config هنگام load WebView
+- [ ] لیسن به `MediaQuery.of(context).viewInsets` برای keyboard
+- [ ] لیسن به `MediaQuery.of(context).size` برای rotation
+- [ ] ارسال events به WebView از طریق `runJavaScript`
+
+### React Side:
+- [ ] ایجاد `useFlutterLayout` hook
+- [ ] لیسن به `flutterConfig` event
+- [ ] لیسن به `flutterEvent` event
+- [ ] استفاده از layout info در components
+- [ ] تست در موبایل/تبلت
+- [ ] تست با keyboard باز/بسته
+- [ ] تست در landscape/portrait
+
+---
+
+## 🎯 خلاصه
+
+### اطلاعاتی که **یکبار** ارسال میشود:
+1. ✅ Breakpoint (mobile/tablet)
+2. ✅ Screen size
+3. ✅ Safe area
+4. ✅ Design sizes
+5. ✅ Platform info
+6. ✅ Locale/direction
+
+### اطلاعاتی که **real-time** ارسال میشود:
+1. 🔄 Keyboard height (viewInsets.bottom)
+2. 🔄 Safe area changes
+3. 🔄 Screen size changes (rotation)
+4. 🔄 Breakpoint changes
+
+### تاثیر اصلی در UI:
+- **Horizontal padding**: mobile=16px, tablet=64px
+- **Bottom padding**: safeArea.bottom + viewInsets.bottom
+- **Chat TextField**: adjust for keyboard
+- **Floating buttons**: offset by safe area
diff --git a/src/app/intro/page.tsx b/src/app/intro/page.tsx
index f4517f2..6de5094 100644
--- a/src/app/intro/page.tsx
+++ b/src/app/intro/page.tsx
@@ -115,7 +115,14 @@ export default function Intro() {
}
}
- const profileResponse = profile ?? (await refetch()).data;
+ // Fetch profile (query is initialized with enabled: false, so refetch is needed)
+ const { data: freshProfile } = await refetch();
+ const profileResponse = freshProfile ?? profile;
+
+ if (!profileResponse) {
+ console.warn("Could not load profile data – using default path");
+ }
+
const nextPath = localizePath(getSubmitPath(profileResponse), locale);
router.push(nextPath);
} catch (error) {
diff --git a/src/components/questions/question-file.tsx b/src/components/questions/question-file.tsx
index d5e04c6..db58ffd 100644
--- a/src/components/questions/question-file.tsx
+++ b/src/components/questions/question-file.tsx
@@ -1,9 +1,10 @@
"use client";
import Image from "next/image";
-import { useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media";
+import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
@@ -13,12 +14,36 @@ type QuestionFileProps = {
disabled?: boolean;
};
+/** Map question file extensions to upload_file mediaType. */
+function resolveMediaType(
+ extensions: string[],
+): "image" | "video" | "audio" | "file" {
+ const lower = extensions.map((e) => e.replace(/^\./, "").toLowerCase());
+ const allImages = lower.every((e) =>
+ ["jpg", "jpeg", "png", "webp", "gif", "bmp", "svg"].includes(e),
+ );
+ if (allImages) return "image";
+
+ const allVideo = lower.every((e) =>
+ ["mp4", "mov", "avi", "mkv", "webm"].includes(e),
+ );
+ if (allVideo) return "video";
+
+ const allAudio = lower.every((e) =>
+ ["mp3", "m4a", "wav", "aac", "ogg"].includes(e),
+ );
+ if (allAudio) return "audio";
+
+ return "file";
+}
+
export function QuestionFile({
question,
questionIndex,
disabled,
}: QuestionFileProps) {
const [selectedFileName, setSelectedFileName] = useState(null);
+ const [isFlutterPicking, setIsFlutterPicking] = useState(false);
const acceptedFiles = question.extras.options
.map((option) => option.replace(/^\./, ""))
.join(", ");
@@ -32,7 +57,85 @@ export function QuestionFile({
},
});
- function handleFileChange(files: FileList | null) {
+ const isPending = uploadTmpMediaMutation.isPending || isFlutterPicking;
+
+ // Listen for upload_file responses from Flutter
+ useEffect(() => {
+ if (!isInFlutterWebView()) return;
+
+ const unsubscribe = window.addFlutterResponseListener?.((event) => {
+ if (event.action !== "upload_file") return;
+
+ switch (event.status) {
+ case "picking":
+ setIsFlutterPicking(true);
+ break;
+ case "picked":
+ if (event.data?.files?.[0]) {
+ setSelectedFileName(event.data.files[0].name ?? null);
+ }
+ break;
+ case "progress":
+ // Could show progress bar – for now just keep pending state
+ break;
+ case "completed": {
+ setIsFlutterPicking(false);
+ const file = event.data?.files?.[0];
+ if (file?.url) {
+ // Flutter uploaded the file – use the returned URL
+ setAnswerValue(question, questionIndex, file.url);
+ setSelectedFileName(file.name ?? "uploaded");
+ } else if (file?.base64) {
+ // base64 mode – convert to File and use existing mutation
+ fetch(file.base64)
+ .then((res) => res.blob())
+ .then((blob) => {
+ const f = new File([blob], file.name ?? "upload", {
+ type: blob.type,
+ });
+ setSelectedFileName(f.name);
+ uploadTmpMediaMutation.mutate(f);
+ });
+ }
+ break;
+ }
+ case "cancelled":
+ setIsFlutterPicking(false);
+ break;
+ case "failed":
+ setIsFlutterPicking(false);
+ console.error("upload_file failed:", event.message);
+ break;
+ }
+ });
+
+ return () => {
+ unsubscribe?.();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [question, questionIndex]);
+
+ /** Handle file pick in Flutter WebView via upload_file action. */
+ const handleFlutterPick = useCallback(() => {
+ const extensions = question.extras.options.map((o) =>
+ o.replace(/^\./, "").toLowerCase(),
+ );
+ const mediaType = resolveMediaType(extensions);
+
+ uploadFile({
+ mediaType,
+ source: "gallery",
+ returnAs: "upload",
+ uploadUrl: `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/upload-tmp-media/`,
+ fieldName: "file",
+ allowedExtensions: extensions,
+ maxBytes: 10_485_760, // 10 MB
+ title: typeof question.title === "string" ? question.title : "Upload File",
+ });
+ }, [question]);
+
+ /** Handle file pick via browser (fallback). */
+ function handleBrowserFileChange(files: FileList | null) {
const file = files?.[0];
if (!file) {
@@ -45,6 +148,8 @@ export function QuestionFile({
uploadTmpMediaMutation.mutate(file);
}
+ const inWebView = isInFlutterWebView();
+
return (
-
- handleFileChange(event.target.files)}
- className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
- />
+ {
+ if (e.key === "Enter" || e.key === " ") handleFlutterPick();
+ }
+ : undefined
+ }
+ >
+ {/* Fallback: browser file input (hidden in WebView) */}
+ {!inWebView && (
+ handleBrowserFileChange(event.target.files)}
+ className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
+ />
+ )}
- {uploadTmpMediaMutation.isPending
+ {isPending
? "uploading..."
- : (selectedFileName ?? "uplaod certifacates")}
+ : (selectedFileName ?? "upload certificates")}
{uploadTmpMediaMutation.isError ? (
@@ -87,3 +207,4 @@ export function QuestionFile({
}
export default QuestionFile;
+
diff --git a/src/components/ui/report-actions-sheet.tsx b/src/components/ui/report-actions-sheet.tsx
index d9575ba..d959a0d 100644
--- a/src/components/ui/report-actions-sheet.tsx
+++ b/src/components/ui/report-actions-sheet.tsx
@@ -3,6 +3,12 @@
import { useEffect, useState } from "react";
import Button from "@/components/ui/button";
import { useFlutterBridge } from "@/hooks/useFlutterBridge";
+import {
+ downloadFile,
+ copyToClipboard,
+ openExternalUrl,
+ isInFlutterWebView,
+} from "@/lib/webview-actions";
const EXIT_ANIMATION_MS = 220;
@@ -50,6 +56,37 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
console.log("👨⚕️ REQUEST_CONSULTANT ارسال شد");
};
+ // ✅ تست download_file
+ const handleTestDownload = () => {
+ const sent = downloadFile({
+ url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
+ fileName: "test-document.pdf",
+ title: "Test PDF Document",
+ mimeType: "application/pdf",
+ fileType: "document",
+ });
+ console.log(sent ? "📥 download_file ارسال شد" : "⚠️ Flutter WebView یافت نشد");
+ };
+
+ // ✅ تست copy_to_clipboard
+ const handleTestCopy = async () => {
+ const success = await copyToClipboard(
+ "سلام! این یک متن تست برای کپی است. 🎉",
+ "test_copy",
+ );
+ console.log(success ? "📋 copy_to_clipboard ارسال شد" : "⚠️ کپی انجام نشد");
+ };
+
+ // ✅ تست open_external_url
+ const handleTestOpenUrl = () => {
+ const sent = openExternalUrl({
+ url: "https://habibapp.com",
+ mode: "externalApplication",
+ title: "test_open_url",
+ });
+ console.log(sent ? "🔗 open_external_url ارسال شد" : "⚠️ Flutter WebView یافت نشد");
+ };
+
useEffect(() => {
const frameId = window.requestAnimationFrame(() => {
setIsEntering(false);
@@ -84,6 +121,17 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
const message = `Location: ${event.data.latitude}, ${event.data.longitude}`;
alert(message);
}
+
+ // Log responses for download/clipboard/external actions
+ if (event.action === "download_file") {
+ console.log(`📥 download_file response: ${event.status}`, event);
+ }
+ if (event.action === "copy_to_clipboard") {
+ console.log(`📋 copy_to_clipboard response: ${event.success}`, event);
+ }
+ if (event.action === "open_external_url") {
+ console.log(`🔗 open_external_url response: ${event.status}`, event);
+ }
};
const unsubscribe = window.addFlutterResponseListener?.(handleFlutterResponse);
@@ -146,6 +194,23 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
+ {/* دکمههای تست WebView Actions */}
+
+
+ 🧪 تست WebView Utility Actions:
+
+
+
+
+
+
+ {!isInFlutterWebView() && (
+
+ ⚠️ خارج از WebView هستید — copy_to_clipboard با fallback مرورگر کار میکند، بقیه نیاز به Flutter دارند.
+
+ )}
+
+
{/* دکمه تست WEB_READY */}
diff --git a/src/lib/webview-actions.ts b/src/lib/webview-actions.ts
new file mode 100644
index 0000000..1a34ee7
--- /dev/null
+++ b/src/lib/webview-actions.ts
@@ -0,0 +1,146 @@
+/**
+ * WebView Utility Actions
+ *
+ * Helper module for sending utility actions to Flutter via HabibApp channel.
+ * Actions: download_file, copy_to_clipboard, open_external_url, upload_file
+ *
+ * Docs: WEBVIEW_ACTION_DOWNLOAD.md
+ */
+
+// ─── Helpers ─────────────────────────────────────────────
+
+/** Check if running inside Flutter WebView (HabibApp channel available). */
+export function isInFlutterWebView(): boolean {
+ return typeof window !== "undefined" && !!window.HabibApp?.postMessage;
+}
+
+/**
+ * Low-level: send a JSON message to Flutter via HabibApp.postMessage.
+ * Returns `true` if the message was dispatched, `false` if channel unavailable.
+ */
+export function postActionToFlutter(
+ action: string,
+ data?: Record,
+): boolean {
+ if (!isInFlutterWebView()) return false;
+
+ const message = data !== undefined ? { action, data } : { action };
+ window.HabibApp!.postMessage(JSON.stringify(message));
+ return true;
+}
+
+// ─── download_file ───────────────────────────────────────
+
+export interface DownloadFileOptions {
+ url: string;
+ fileName?: string;
+ title?: string;
+ mimeType?: string;
+ fileType?: "image" | "video" | "audio" | "document" | "archive" | "other";
+ size?: number;
+ metadata?: Record;
+}
+
+/**
+ * Ask Flutter to download a file natively.
+ *
+ * ```ts
+ * downloadFile({ url: 'https://…/doc.pdf', fileName: 'doc.pdf', mimeType: 'application/pdf', fileType: 'document' });
+ * ```
+ */
+export function downloadFile(options: DownloadFileOptions): boolean {
+ return postActionToFlutter("download_file", options as Record);
+}
+
+// ─── copy_to_clipboard ──────────────────────────────────
+
+export interface CopyToClipboardOptions {
+ text: string;
+ label?: string;
+ showToast?: boolean;
+}
+
+/**
+ * Copy text to the device clipboard via Flutter.
+ * Falls back to `navigator.clipboard.writeText` if not in WebView.
+ */
+export async function copyToClipboard(
+ text: string,
+ label?: string,
+): Promise {
+ if (isInFlutterWebView()) {
+ return postActionToFlutter("copy_to_clipboard", {
+ text,
+ label,
+ showToast: true,
+ });
+ }
+
+ // Fallback for normal browser
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// ─── open_external_url ──────────────────────────────────
+
+export interface OpenExternalUrlOptions {
+ url: string;
+ mode?: "externalApplication" | "platformDefault";
+ title?: string;
+ showErrorToast?: boolean;
+}
+
+/**
+ * Ask Flutter to open a URL in the external browser / app.
+ *
+ * ```ts
+ * openExternalUrl({ url: 'https://habibapp.com' });
+ * ```
+ */
+export function openExternalUrl(options: OpenExternalUrlOptions): boolean {
+ return postActionToFlutter("open_external_url", {
+ mode: "externalApplication",
+ showErrorToast: true,
+ ...options,
+ } as Record);
+}
+
+// ─── upload_file ─────────────────────────────────────────
+
+export interface UploadFileOptions {
+ mediaType: "image" | "video" | "image+video" | "audio" | "file";
+ source?: "gallery" | "camera" | "any";
+ multiple?: boolean;
+ returnAs?: "upload" | "base64";
+ uploadUrl?: string;
+ fieldName?: string;
+ uploadMethod?: string;
+ headers?: Record;
+ maxBytes?: number;
+ allowedExtensions?: string[];
+ maxWidth?: number;
+ maxHeight?: number;
+ imageQuality?: number;
+ title?: string;
+}
+
+/**
+ * Ask Flutter to pick (and optionally upload) a file from the device.
+ *
+ * ```ts
+ * uploadFile({
+ * mediaType: 'image',
+ * source: 'gallery',
+ * returnAs: 'upload',
+ * uploadUrl: '/hussainya/dashboard/upload-media/',
+ * maxBytes: 5_242_880,
+ * });
+ * ```
+ */
+export function uploadFile(options: UploadFileOptions): boolean {
+ return postActionToFlutter("upload_file", options as Record);
+}
diff --git a/src/types/window.d.ts b/src/types/window.d.ts
index 8a1cff3..15cef63 100644
--- a/src/types/window.d.ts
+++ b/src/types/window.d.ts
@@ -9,6 +9,10 @@ declare global {
interface FlutterResponseEvent {
action: string;
success: boolean;
+ /** Top-level status for multi-step actions (download_file, upload_file, …) */
+ status?: string;
+ /** Top-level error/info message */
+ message?: string;
data?: {
// get_location
latitude?: number;
@@ -42,6 +46,28 @@ declare global {
tablet?: { width?: number; height?: number };
platform?: { os?: string; version?: string };
locale?: { languageCode?: string; isRTL?: boolean; isRtl?: boolean };
+ // download_file / upload_file progress
+ progress?: number;
+ received?: number;
+ sent?: number;
+ total?: number;
+ fileName?: string;
+ fileType?: string;
+ mimeType?: string;
+ // upload_file
+ mediaType?: string;
+ source?: string;
+ files?: Array<{
+ url?: string;
+ base64?: string;
+ name?: string;
+ size?: number;
+ mimeType?: string;
+ }>;
+ // copy_to_clipboard
+ label?: string;
+ // open_external_url
+ url?: string;
};
}