Browse Source
feat: implement cross-platform file upload bridge for Flutter WebView integration
master
feat: implement cross-platform file upload bridge for Flutter WebView integration
master
9 changed files with 3736 additions and 13 deletions
-
983WEBVIEW_ACTION_DOWNLOAD.md
-
940WEBVIEW_EVENTS.md
-
783WEBVIEW_LAYOUT.md
-
652WEBVIEW_LAYOUT_BRIDGE.md
-
9src/app/intro/page.tsx
-
135src/components/questions/question-file.tsx
-
65src/components/ui/report-actions-sheet.tsx
-
146src/lib/webview-actions.ts
-
26src/types/window.d.ts
@ -0,0 +1,983 @@ |
|||||
|
# WebView Utility Actions |
||||
|
|
||||
|
این سند ساختار actionهای عمومی WebView به Flutter را تعریف میکند. این actionها برای کارهایی هستند که بهتر است بهصورت native توسط Flutter انجام شوند: دانلود فایل، آپلود فایل (انتخاب فایل/تصویر/ویدیو از دستگاه)، کپی متن در clipboard دستگاه، و باز کردن لینک در مرورگر/اپ خارجی کاربر. |
||||
|
|
||||
|
## کانال ارتباطی |
||||
|
|
||||
|
وب باید از کانال فعلی WebView استفاده کند: |
||||
|
|
||||
|
```js |
||||
|
HabibApp.postMessage(JSON.stringify(message)); |
||||
|
``` |
||||
|
|
||||
|
Flutter هم مثل سایر actionها پاسخ را از این مسیر به Web برمیگرداند: |
||||
|
|
||||
|
```js |
||||
|
window.onFlutterResponse(response); |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ۱. دانلود فایل |
||||
|
|
||||
|
### هدف |
||||
|
|
||||
|
وب نباید خودش فایل را داخل WebView دانلود کند. وقتی کاربر روی دکمه دانلود هر فایلی کلیک کرد، وب باید یک پیام JSON به Flutter بفرستد تا Flutter دانلود native را برای کاربر انجام دهد. |
||||
|
|
||||
|
### Action اصلی |
||||
|
|
||||
|
نام action باید عمومی باشد: |
||||
|
|
||||
|
```text |
||||
|
download_file |
||||
|
``` |
||||
|
|
||||
|
از `download_audio` استفاده نشود، چون فقط یک نوع فایل را پوشش میدهد. |
||||
|
|
||||
|
### پیام ارسالی از Web به Flutter |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"action": "download_file", |
||||
|
"data": { |
||||
|
"url": "https://example.com/files/sample.pdf", |
||||
|
"fileName": "sample.pdf", |
||||
|
"title": "Sample File", |
||||
|
"mimeType": "application/pdf", |
||||
|
"fileType": "document" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### فیلدهای پیام |
||||
|
|
||||
|
| Field | Required | Type | توضیح | |
||||
|
|---|---:|---|---| |
||||
|
| `action` | بله | string | مقدار ثابت: `download_file` | |
||||
|
| `data.url` | بله | string | لینک مستقیم دانلود فایل. باید `https` یا `http` باشد | |
||||
|
| `data.fileName` | پیشنهاد میشود | string | نام فایل ذخیرهشده روی دستگاه، همراه extension | |
||||
|
| `data.title` | اختیاری | string | عنوان قابل نمایش در UI/Notification | |
||||
|
| `data.mimeType` | پیشنهاد میشود | string | MIME type فایل، مثل `image/jpeg`, `video/mp4`, `audio/mpeg`, `application/pdf` | |
||||
|
| `data.fileType` | اختیاری | string | دستهبندی ساده برای UI، مثل `image`, `video`, `audio`, `document`, `archive`, `other` | |
||||
|
| `data.size` | اختیاری | number | حجم فایل به byte اگر از قبل مشخص است | |
||||
|
| `data.metadata` | اختیاری | object | اطلاعات اضافه، مثل `artist`, `duration`, `thumbnailUrl` | |
||||
|
|
||||
|
### fileTypeهای پیشنهادی |
||||
|
|
||||
|
| fileType | نمونه mimeType | نمونه extension | |
||||
|
|---|---|---| |
||||
|
| `image` | `image/jpeg`, `image/png`, `image/webp` | `.jpg`, `.png`, `.webp` | |
||||
|
| `video` | `video/mp4`, `video/quicktime` | `.mp4`, `.mov` | |
||||
|
| `audio` | `audio/mpeg`, `audio/mp4`, `audio/wav` | `.mp3`, `.m4a`, `.wav` | |
||||
|
| `document` | `application/pdf`, `text/plain` | `.pdf`, `.txt` | |
||||
|
| `archive` | `application/zip`, `application/x-rar-compressed` | `.zip`, `.rar` | |
||||
|
| `other` | هر MIME type دیگر | هر extension معتبر | |
||||
|
|
||||
|
### مثالها |
||||
|
|
||||
|
### دانلود تصویر |
||||
|
|
||||
|
```js |
||||
|
function downloadImage(image) { |
||||
|
window.HabibApp?.postMessage(JSON.stringify({ |
||||
|
action: 'download_file', |
||||
|
data: { |
||||
|
url: image.url, |
||||
|
fileName: `${image.slug}.jpg`, |
||||
|
title: image.title, |
||||
|
mimeType: 'image/jpeg', |
||||
|
fileType: 'image' |
||||
|
} |
||||
|
})); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### دانلود ویدیو |
||||
|
|
||||
|
```js |
||||
|
function downloadVideo(video) { |
||||
|
window.HabibApp?.postMessage(JSON.stringify({ |
||||
|
action: 'download_file', |
||||
|
data: { |
||||
|
url: video.downloadUrl, |
||||
|
fileName: `${video.slug}.mp4`, |
||||
|
title: video.title, |
||||
|
mimeType: 'video/mp4', |
||||
|
fileType: 'video', |
||||
|
metadata: { |
||||
|
duration: video.duration, |
||||
|
thumbnailUrl: video.thumbnailUrl |
||||
|
} |
||||
|
} |
||||
|
})); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### دانلود فایل صوتی |
||||
|
|
||||
|
```js |
||||
|
function downloadAudio(song) { |
||||
|
window.HabibApp?.postMessage(JSON.stringify({ |
||||
|
action: 'download_file', |
||||
|
data: { |
||||
|
url: song.downloadUrl, |
||||
|
fileName: `${song.slug}.mp3`, |
||||
|
title: song.title, |
||||
|
mimeType: 'audio/mpeg', |
||||
|
fileType: 'audio', |
||||
|
metadata: { |
||||
|
artist: song.artist, |
||||
|
duration: song.duration |
||||
|
} |
||||
|
} |
||||
|
})); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### دانلود PDF |
||||
|
|
||||
|
```js |
||||
|
function downloadPdf(book) { |
||||
|
window.HabibApp?.postMessage(JSON.stringify({ |
||||
|
action: 'download_file', |
||||
|
data: { |
||||
|
url: book.pdfUrl, |
||||
|
fileName: `${book.slug}.pdf`, |
||||
|
title: book.title, |
||||
|
mimeType: 'application/pdf', |
||||
|
fileType: 'document' |
||||
|
} |
||||
|
})); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### نمونه استفاده روی دکمه دانلود |
||||
|
|
||||
|
```jsx |
||||
|
<button onClick={() => downloadVideo(video)}> |
||||
|
Download |
||||
|
</button> |
||||
|
``` |
||||
|
|
||||
|
### شروع دانلود |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"action": "download_file", |
||||
|
"success": true, |
||||
|
"status": "started", |
||||
|
"data": { |
||||
|
"fileName": "sample.pdf", |
||||
|
"fileType": "document", |
||||
|
"mimeType": "application/pdf" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### پیشرفت دانلود |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"action": "download_file", |
||||
|
"success": true, |
||||
|
"status": "progress", |
||||
|
"data": { |
||||
|
"progress": 42, |
||||
|
"received": 420000, |
||||
|
"total": 1000000, |
||||
|
"fileName": "sample.pdf" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### اتمام دانلود |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"action": "download_file", |
||||
|
"success": true, |
||||
|
"status": "completed", |
||||
|
"data": { |
||||
|
"fileName": "sample.pdf", |
||||
|
"fileType": "document", |
||||
|
"mimeType": "application/pdf" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### خطا در دانلود |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"action": "download_file", |
||||
|
"success": false, |
||||
|
"status": "failed", |
||||
|
"message": "Invalid download url" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### مدیریت Response در Web |
||||
|
|
||||
|
```js |
||||
|
window.onFlutterResponse = function (response) { |
||||
|
if (response.action !== 'download_file') return; |
||||
|
|
||||
|
if (response.status === 'started') { |
||||
|
// UI را به حالت downloading ببرید |
||||
|
} |
||||
|
|
||||
|
if (response.status === 'progress') { |
||||
|
// response.data.progress عدد 0 تا 100 است |
||||
|
} |
||||
|
|
||||
|
if (response.status === 'completed') { |
||||
|
// پیام موفقیت نمایش دهید |
||||
|
} |
||||
|
|
||||
|
if (response.status === 'failed') { |
||||
|
// پیام خطا نمایش دهید |
||||
|
} |
||||
|
}; |
||||
|
``` |
||||
|
|
||||
|
### نکات مهم برای Web |
||||
|
|
||||
|
- لینک `data.url` باید مستقیم به فایل برسد، نه صفحه HTML. |
||||
|
- `data.fileName` باید extension داشته باشد، مثل `.jpg`, `.mp4`, `.mp3`, `.pdf`, `.zip`. |
||||
|
- اگر `fileName` ارسال نشود، Flutter میتواند نام فایل را از URL استخراج کند، اما ارسال `fileName` بهتر است. |
||||
|
- اگر فایل نیاز به authorization دارد، بهتر است لینک دانلود signed/temporary باشد یا Flutter بتواند با کوکی/توکن فعلی آن را دریافت کند. |
||||
|
- `mimeType` و `fileType` برای UI، notification و انتخاب روش ذخیرهسازی مفیدند؛ بهتر است ارسال شوند. |
||||
|
- وب فقط درخواست دانلود را میفرستد؛ ذخیره فایل، permission، notification، انتخاب مسیر و مدیریت platform بر عهده Flutter است. |
||||
|
|
||||
|
### سازگاری با نام قدیمی |
||||
|
|
||||
|
اگر قبلا در Web از `download_audio` استفاده شده، بهتر است به `download_file` مهاجرت کند. |
||||
|
|
||||
|
پیشنهاد برای Flutter: |
||||
|
|
||||
|
```dart |
||||
|
case 'download_file': |
||||
|
return _handleDownloadFile(data); |
||||
|
``` |
||||
|
|
||||
|
در صورت نیاز موقت به backward compatibility: |
||||
|
|
||||
|
```dart |
||||
|
case 'download_audio': |
||||
|
case 'download_file': |
||||
|
return _handleDownloadFile(data); |
||||
|
``` |
||||
|
|
||||
|
اما قرارداد نهایی و مستند باید `download_file` باشد. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ۲. کپی متن در Clipboard کاربر |
||||
|
|
||||
|
### هدف |
||||
|
|
||||
|
وب میتواند `navigator.clipboard.writeText(...)` را امتحان کند، اما در WebView مخصوصاً روی iOS/Android همیشه قابل اتکا نیست و ممکن است به user gesture، permission یا secure context وابسته باشد. برای رفتار مطمئن داخل اپ، Web باید متن را با action به Flutter بدهد و Flutter متن را در clipboard دستگاه کپی کند. |
||||
|
|
||||
|
### Action اصلی |
||||
|
|
||||
|
```text |
||||
|
copy_to_clipboard |
||||
|
``` |
||||
|
|
||||
|
### پیام ارسالی از Web به Flutter |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"action": "copy_to_clipboard", |
||||
|
"data": { |
||||
|
"text": "متنی که باید کپی شود", |
||||
|
"label": "invite_code" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### فیلدهای پیام |
||||
|
|
||||
|
| Field | Required | Type | توضیح | |
||||
|
|---|---:|---|---| |
||||
|
| `action` | بله | string | مقدار ثابت: `copy_to_clipboard` | |
||||
|
| `data.text` | بله | string | متن قابل کپی | |
||||
|
| `data.label` | اختیاری | string | نام یا context برای analytics/debug، مثل `invite_code`, `share_link` | |
||||
|
| `data.showToast` | اختیاری | boolean | اگر `true` باشد Flutter میتواند پیام موفقیت نشان دهد. مقدار پیشفرض پیشنهادی: `true` | |
||||
|
|
||||
|
### مثال JavaScript |
||||
|
|
||||
|
```js |
||||
|
function copyText(text) { |
||||
|
window.HabibApp?.postMessage(JSON.stringify({ |
||||
|
action: 'copy_to_clipboard', |
||||
|
data: { |
||||
|
text, |
||||
|
label: 'share_text', |
||||
|
showToast: true |
||||
|
} |
||||
|
})); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### نمونه استفاده روی دکمه |
||||
|
|
||||
|
```jsx |
||||
|
<button onClick={() => copyText(inviteCode)}> |
||||
|
Copy Code |
||||
|
</button> |
||||
|
``` |
||||
|
|
||||
|
### Response موفق |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"action": "copy_to_clipboard", |
||||
|
"success": true, |
||||
|
"status": "completed", |
||||
|
"data": { |
||||
|
"label": "share_text" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Response خطا |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"action": "copy_to_clipboard", |
||||
|
"success": false, |
||||
|
"status": "failed", |
||||
|
"message": "Text is empty" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### مدیریت Response در Web |
||||
|
|
||||
|
```js |
||||
|
window.onFlutterResponse = function (response) { |
||||
|
if (response.action !== 'copy_to_clipboard') return; |
||||
|
|
||||
|
if (response.success) { |
||||
|
// UI کپی موفق را نمایش دهید |
||||
|
} else { |
||||
|
// پیام خطا نمایش دهید |
||||
|
} |
||||
|
}; |
||||
|
``` |
||||
|
|
||||
|
### پیشنهاد fallback سمت Web |
||||
|
|
||||
|
اگر Web خارج از اپ هم اجرا میشود، میتواند helper عمومی داشته باشد: |
||||
|
|
||||
|
```js |
||||
|
async function copyToClipboard(text) { |
||||
|
if (window.HabibApp) { |
||||
|
window.HabibApp.postMessage(JSON.stringify({ |
||||
|
action: 'copy_to_clipboard', |
||||
|
data: { text, showToast: true } |
||||
|
})); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
await navigator.clipboard.writeText(text); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### نکات مهم برای Web |
||||
|
|
||||
|
- `data.text` نباید خالی باشد. |
||||
|
- برای متنهای حساس مثل token یا اطلاعات شخصی، قبل از ارسال به clipboard حتما UX واضح داشته باشید. |
||||
|
- اگر Web داخل مرورگر عادی اجرا شود و `HabibApp` وجود نداشته باشد، از Clipboard API مرورگر استفاده شود. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ۳. باز کردن لینک در مرورگر یا اپ خارجی کاربر |
||||
|
|
||||
|
### هدف |
||||
|
|
||||
|
اگر داخل WebView دکمهای وجود دارد که باید یک آدرس را خارج از WebView باز کند، Web باید به Flutter پیام بدهد تا Flutter با `url_launcher` لینک را در مرورگر/اپ مناسب باز کند. این برای لینکهای خارجی، پرداخت، نقشه، تلگرام، واتساپ، YouTube و صفحات وب عمومی کاربرد دارد. |
||||
|
|
||||
|
### Action اصلی |
||||
|
|
||||
|
```text |
||||
|
open_external_url |
||||
|
``` |
||||
|
|
||||
|
### پیام ارسالی از Web به Flutter |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"action": "open_external_url", |
||||
|
"data": { |
||||
|
"url": "https://example.com/page", |
||||
|
"mode": "externalApplication", |
||||
|
"title": "Open Website" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### فیلدهای پیام |
||||
|
|
||||
|
| Field | Required | Type | توضیح | |
||||
|
|---|---:|---|---| |
||||
|
| `action` | بله | string | مقدار ثابت: `open_external_url` | |
||||
|
| `data.url` | بله | string | آدرس مقصد | |
||||
|
| `data.mode` | اختیاری | string | روش باز کردن. مقدار پیشنهادی: `externalApplication` | |
||||
|
| `data.title` | اختیاری | string | عنوان برای analytics/debug یا UI | |
||||
|
| `data.showErrorToast` | اختیاری | boolean | اگر باز کردن لینک fail شد Flutter پیام خطا نشان دهد | |
||||
|
|
||||
|
### URL schemeهای قابل قبول |
||||
|
|
||||
|
| Scheme | کاربرد | |
||||
|
|---|---| |
||||
|
| `https` / `http` | باز کردن صفحه وب در مرورگر | |
||||
|
| `mailto` | باز کردن email client | |
||||
|
| `tel` | باز کردن dialer | |
||||
|
| `sms` | باز کردن SMS | |
||||
|
| `geo` | باز کردن نقشه روی Android | |
||||
|
| custom schemes | مثل `whatsapp://`, `tg://` در صورت پشتیبانی دستگاه | |
||||
|
|
||||
|
### مثال JavaScript برای لینک وب |
||||
|
|
||||
|
```js |
||||
|
function openInBrowser(url) { |
||||
|
window.HabibApp?.postMessage(JSON.stringify({ |
||||
|
action: 'open_external_url', |
||||
|
data: { |
||||
|
url, |
||||
|
mode: 'externalApplication', |
||||
|
showErrorToast: true |
||||
|
} |
||||
|
})); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### نمونه استفاده روی دکمه |
||||
|
|
||||
|
```jsx |
||||
|
<button onClick={() => openInBrowser('https://habibapp.com/marriage/plans')}> |
||||
|
Open in Browser |
||||
|
</button> |
||||
|
``` |
||||
|
|
||||
|
### مثال برای واتساپ |
||||
|
|
||||
|
```js |
||||
|
function openWhatsapp(phone) { |
||||
|
window.HabibApp?.postMessage(JSON.stringify({ |
||||
|
action: 'open_external_url', |
||||
|
data: { |
||||
|
url: `https://wa.me/${phone}`, |
||||
|
mode: 'externalApplication', |
||||
|
title: 'whatsapp_contact' |
||||
|
} |
||||
|
})); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### مثال برای تماس تلفنی |
||||
|
|
||||
|
```js |
||||
|
function callPhone(phone) { |
||||
|
window.HabibApp?.postMessage(JSON.stringify({ |
||||
|
action: 'open_external_url', |
||||
|
data: { |
||||
|
url: `tel:${phone}`, |
||||
|
mode: 'externalApplication', |
||||
|
title: 'phone_call' |
||||
|
} |
||||
|
})); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Response موفق |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"action": "open_external_url", |
||||
|
"success": true, |
||||
|
"status": "opened", |
||||
|
"data": { |
||||
|
"url": "https://example.com/page" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Response خطا |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"action": "open_external_url", |
||||
|
"success": false, |
||||
|
"status": "failed", |
||||
|
"message": "Cannot launch url" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### مدیریت Response در Web |
||||
|
|
||||
|
```js |
||||
|
window.onFlutterResponse = function (response) { |
||||
|
if (response.action !== 'open_external_url') return; |
||||
|
|
||||
|
if (!response.success) { |
||||
|
// پیام خطا یا fallback نمایش دهید |
||||
|
} |
||||
|
}; |
||||
|
``` |
||||
|
|
||||
|
### نکات مهم برای Web |
||||
|
|
||||
|
- `data.url` باید valid باشد. |
||||
|
- برای لینکهای بیرونی بهتر است `https` استفاده شود. |
||||
|
- اگر هدف، باز کردن صفحه داخلی همین سرویس WebView است، از navigation داخلی Web استفاده کنید، نه `open_external_url`. |
||||
|
- اگر هدف، باز کردن سرویس دیگر داخل اپ است، باید از action navigation/deep-link داخلی استفاده شود، نه مرورگر خارجی. |
||||
|
- این action برای خروج از WebView و باز کردن مرورگر/اپ خارجی است. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ۴. آپلود فایل (انتخاب فایل/تصویر/ویدیو از دستگاه) |
||||
|
|
||||
|
### هدف |
||||
|
|
||||
|
وقتی داخل WebView دکمهای مثل «آپلود» / «انتخاب تصویر» / «انتخاب ویدیو» کلیک میشود، وب نباید از `<input type="file">` خود مرورگر استفاده کند چون داخل WebView (بهویژه روی iOS و حتی روی برخی نسخههای Android) رفتار یکسان و قابل اتکایی ندارد و ممکن است انتخاب دوربین/گالری، دسترسی به فایل و eventهای change با شکست مواجه شود. بهجای آن Web یک پیام JSON به Flutter میفرستد تا Flutter با `image_picker` / `file_picker` انتخاب native را انجام دهد و سپس نتیجه را به Web برگرداند. |
||||
|
|
||||
|
دو حالت پاسخ وجود دارد: |
||||
|
|
||||
|
1. **حالت آپلود (پیشنهادی، پیشفرض):** Flutter فایل را انتخاب کرده و خودش با multipart POST به `uploadUrl` میفرستد و فقط URL نهاییِ سرور را به Web برمیگرداند. این حالت برای تصویر، ویدیو و فایلهای بزرگ بهترین گزینه است چون دادهٔ حجیم از کانال JS عبور نمیکند. |
||||
|
2. **حالت `base64`:** Flutter فایل را انتخاب کرده و محتوای آن را بهصورت `data:` URL به Web برمیگرداند. فقط برای تصویرهای کوچک (مثل avatar) مناسب است. |
||||
|
|
||||
|
### Action اصلی |
||||
|
|
||||
|
```text |
||||
|
upload_file |
||||
|
``` |
||||
|
|
||||
|
از نامهای خاص مثل `upload_image` یا `pick_video` استفاده نشود؛ `upload_file` با فیلد `mediaType` همهٔ نوعها را پوشش میدهد. |
||||
|
|
||||
|
### پیام ارسالی از Web به Flutter |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"action": "upload_file", |
||||
|
"data": { |
||||
|
"mediaType": "image", |
||||
|
"source": "gallery", |
||||
|
"multiple": false, |
||||
|
"returnAs": "upload", |
||||
|
"uploadUrl": "https://api.example.com/hussainya/dashboard/upload-media/", |
||||
|
"fieldName": "file", |
||||
|
"maxBytes": 10485760, |
||||
|
"allowedExtensions": ["jpg", "png", "webp"], |
||||
|
"title": "Profile Avatar" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### فیلدهای پیام |
||||
|
|
||||
|
| Field | Required | Type | توضیح | |
||||
|
|---|---:|---|---| |
||||
|
| `action` | بله | string | مقدار ثابت: `upload_file` | |
||||
|
| `data.mediaType` | بله | string | نوع رسانه. مقدارهای مجاز: `image`, `video`, `image+video`, `audio`, `file` | |
||||
|
| `data.source` | اختیاری | string | منبع انتخاب. مقدارهای مجاز: `gallery` (پیشفرض), `camera`, `any` | |
||||
|
| `data.multiple` | اختیاری | boolean | انتخاب چند فایل همزمان. پیشفرض: `false` | |
||||
|
| `data.returnAs` | اختیاری | string | روش بازگشت نتیجه: `upload` (پیشفرض) یا `base64` | |
||||
|
| `data.uploadUrl` | شرطی | string | در حالت `upload` اجباری است. endpoint ای که Flutter فایل را با multipart به آن میفرستد | |
||||
|
| `data.fieldName` | اختیاری | string | نام فیلد form-data. پیشفرض: `file` | |
||||
|
| `data.uploadMethod` | اختیاری | string | متد HTTP آپلود. پیشفرض: `POST` | |
||||
|
| `data.headers` | اختیاری | object | هدرهای اضافه برای درخواست آپلود (مثل `Authorization`)؛ Flutter میتواند توکن خود را هم تزریق کند | |
||||
|
| `data.maxBytes` | اختیاری | number | حداکثر حجم مجاز هر فایل به byte؛ Flutter فایل بزرگتر را رد میکند | |
||||
|
| `data.allowedExtensions` | اختیاری | string[] | فیلتر extension در حالت `file`/`audio`، مثل `["mp3", "wav", "pdf"]` | |
||||
|
| `data.maxWidth` / `data.maxHeight` | اختیاری | number | برای `image`: حداکثر ابعاد تصویر قبل از ارسال (Flutter تصویر را resize میکند) | |
||||
|
| `data.imageQuality` | اختیاری | number | 0 تا 100 برای فشردهسازی تصویر | |
||||
|
| `data.title` | اختیاری | string | عنوان برای UI/dialog/analytics | |
||||
|
|
||||
|
### نگاشت mediaType به picker در Flutter |
||||
|
|
||||
|
| mediaType | Flutter picker پیشنهادی | |
||||
|
|---|---| |
||||
|
| `image` | `ImagePicker().pickImage(source: gallery\|camera, maxWidth, maxHeight, imageQuality)` | |
||||
|
| `video` | `ImagePicker().pickVideo(source: gallery\|camera, maxDuration)` | |
||||
|
| `image+video` | `ImagePicker().pickMedia(...)` یا دو گزینه از sheet | |
||||
|
| `audio` | `FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['mp3',...])` | |
||||
|
| `file` | `FilePicker.platform.pickFiles(type: FileType.any \| custom, allowedExtensions)` | |
||||
|
|
||||
|
### مثالها |
||||
|
|
||||
|
### انتخاب و آپلود تصویر (حالت پیشفرض upload) |
||||
|
|
||||
|
```js |
||||
|
function pickAndUploadAvatar() { |
||||
|
window.HabibApp?.postMessage(JSON.stringify({ |
||||
|
action: 'upload_file', |
||||
|
data: { |
||||
|
mediaType: 'image', |
||||
|
source: 'gallery', |
||||
|
returnAs: 'upload', |
||||
|
uploadUrl: 'https://api.example.com/hussainya/dashboard/upload-media/', |
||||
|
fieldName: 'file', |
||||
|
maxBytes: 5242880, |
||||
|
maxWidth: 1024, |
||||
|
imageQuality: 85, |
||||
|
title: 'Profile Avatar' |
||||
|
} |
||||
|
})); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### انتخاب از دوربین (سلفی/گواهی) |
||||
|
|
||||
|
```js |
||||
|
function takePhoto() { |
||||
|
window.HabibApp?.postMessage(JSON.stringify({ |
||||
|
action: 'upload_file', |
||||
|
data: { |
||||
|
mediaType: 'image', |
||||
|
source: 'camera', |
||||
|
returnAs: 'upload', |
||||
|
uploadUrl: 'https://api.example.com/hussainya/dashboard/upload-media/', |
||||
|
fieldName: 'file' |
||||
|
} |
||||
|
})); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### انتخاب ویدیو و آپلود |
||||
|
|
||||
|
```js |
||||
|
function pickAndUploadVideo() { |
||||
|
window.HabibApp?.postMessage(JSON.stringify({ |
||||
|
action: 'upload_file', |
||||
|
data: { |
||||
|
mediaType: 'video', |
||||
|
source: 'gallery', |
||||
|
returnAs: 'upload', |
||||
|
uploadUrl: 'https://api.example.com/hussainya/dashboard/upload-media/', |
||||
|
fieldName: 'file', |
||||
|
title: 'Upload Video' |
||||
|
} |
||||
|
})); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### انتخاب فایل صوتی (mp3) |
||||
|
|
||||
|
```js |
||||
|
function pickAudioFile() { |
||||
|
window.HabibApp?.postMessage(JSON.stringify({ |
||||
|
action: 'upload_file', |
||||
|
data: { |
||||
|
mediaType: 'audio', |
||||
|
source: 'file', |
||||
|
returnAs: 'upload', |
||||
|
uploadUrl: 'https://api.example.com/hussainya/dashboard/upload-media/', |
||||
|
allowedExtensions: ['mp3'], |
||||
|
maxBytes: 26214400, |
||||
|
title: 'Upload Audio' |
||||
|
} |
||||
|
})); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### حالت base64 (فقط برای تصویر کوچک مثل avatar) |
||||
|
|
||||
|
```js |
||||
|
function pickAvatarAsBase64() { |
||||
|
window.HabibApp?.postMessage(JSON.stringify({ |
||||
|
action: 'upload_file', |
||||
|
data: { |
||||
|
mediaType: 'image', |
||||
|
source: 'gallery', |
||||
|
returnAs: 'base64', |
||||
|
maxBytes: 1048576, |
||||
|
maxWidth: 256, |
||||
|
imageQuality: 80 |
||||
|
} |
||||
|
})); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### نمونه استفاده روی دکمه آپلود |
||||
|
|
||||
|
```jsx |
||||
|
<button onClick={() => pickAndUploadAvatar()}> |
||||
|
Upload Avatar |
||||
|
</button> |
||||
|
``` |
||||
|
|
||||
|
### جریان Response |
||||
|
|
||||
|
در حالت `upload`، Flutter چندین response پشت سر هم میفرستد (درست مثل `download_file`): |
||||
|
|
||||
|
### شروع انتخاب |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"action": "upload_file", |
||||
|
"success": true, |
||||
|
"status": "picking", |
||||
|
"data": { |
||||
|
"mediaType": "image", |
||||
|
"source": "gallery" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### فایل انتخاب شد (در حال آپلود) |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"action": "upload_file", |
||||
|
"success": true, |
||||
|
"status": "picked", |
||||
|
"data": { |
||||
|
"files": [ |
||||
|
{ |
||||
|
"name": "IMG_1234.jpg", |
||||
|
"size": 842310, |
||||
|
"mimeType": "image/jpeg" |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### پیشرفت آپلود (فقط حالت `upload`) |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"action": "upload_file", |
||||
|
"success": true, |
||||
|
"status": "progress", |
||||
|
"data": { |
||||
|
"progress": 64, |
||||
|
"sent": 539000, |
||||
|
"total": 842310, |
||||
|
"fileName": "IMG_1234.jpg" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### اتمام موفق (حالت `upload`) |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"action": "upload_file", |
||||
|
"success": true, |
||||
|
"status": "completed", |
||||
|
"data": { |
||||
|
"files": [ |
||||
|
{ |
||||
|
"url": "https://cdn.example.com/media/IMG_1234.jpg", |
||||
|
"name": "IMG_1234.jpg", |
||||
|
"size": 842310, |
||||
|
"mimeType": "image/jpeg" |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### اتمام موفق (حالت `base64`) |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"action": "upload_file", |
||||
|
"success": true, |
||||
|
"status": "completed", |
||||
|
"data": { |
||||
|
"files": [ |
||||
|
{ |
||||
|
"base64": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...", |
||||
|
"name": "avatar.jpg", |
||||
|
"size": 102400, |
||||
|
"mimeType": "image/jpeg" |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### کنسل شدن توسط کاربر |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"action": "upload_file", |
||||
|
"success": false, |
||||
|
"status": "cancelled", |
||||
|
"message": "User cancelled file selection" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### خطا (حجم زیاد / عدم دسترسی / خطای شبکه) |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"action": "upload_file", |
||||
|
"success": false, |
||||
|
"status": "failed", |
||||
|
"message": "File exceeds maxBytes (5242880)" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### مدیریت Response در Web |
||||
|
|
||||
|
وب نباید `window.onFlutterResponse` را بازنویسی کند؛ در این پروژه `index.html` یک registry بهاسم `window.addFlutterResponseListener` تعریف کرده که پاسخها را به همهٔ لیسنرها فوروارد میکند. پس Web باید فقط لیسنر ثبت کند: |
||||
|
|
||||
|
```js |
||||
|
const unsubscribe = window.addFlutterResponseListener?.((response) => { |
||||
|
if (response.action !== 'upload_file') return; |
||||
|
|
||||
|
switch (response.status) { |
||||
|
case 'picking': |
||||
|
// UI را به حالت «در حال انتخاب» ببرید (loading روی دکمه آپلود) |
||||
|
break; |
||||
|
case 'picked': |
||||
|
// فایل انتخاب شد؛ نام/حجم را نمایش دهید. در حالت upload آپلود شروع میشود. |
||||
|
break; |
||||
|
case 'progress': |
||||
|
// response.data.progress عدد 0 تا 100 است؛ نوار پیشرفت را آپدیت کنید |
||||
|
break; |
||||
|
case 'completed': { |
||||
|
// response.data.files آرایهای از فایلهاست |
||||
|
const file = response.data.files[0]; |
||||
|
const fileUrl = file.url ?? file.base64; |
||||
|
// اگر returnAs=upload بود file.url یک URL سرور است؛ |
||||
|
// اگر returnAs=base64 بود file.base64 یک data URL است. |
||||
|
saveField(fileUrl); |
||||
|
break; |
||||
|
} |
||||
|
case 'cancelled': |
||||
|
// کاربر انتخاب را لغو کرد؛ UI را به حالت اولیه برگردانید |
||||
|
break; |
||||
|
case 'failed': |
||||
|
// response.message را نمایش دهید |
||||
|
showError(response.message); |
||||
|
break; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// برای unsubscribe (مثلاً در useEffect پاکسازی): |
||||
|
// unsubscribe?.(); |
||||
|
``` |
||||
|
|
||||
|
### تبدیل data URL به File در حالت base64 (اختیاری) |
||||
|
|
||||
|
اگر Web در حالت `base64` همان رفتار سرویس `uploadMedia` را میخواهد، میتواند data URL را به `File` تبدیل کند و به سرویس موجود بدهد: |
||||
|
|
||||
|
```js |
||||
|
async function dataUrlToFile(dataUrl, fileName) { |
||||
|
const res = await fetch(dataUrl); |
||||
|
const blob = await res.blob(); |
||||
|
return new File([blob], fileName, { type: blob.type }); |
||||
|
} |
||||
|
|
||||
|
// در listener حالت completed با returnAs=base64: |
||||
|
const file = await dataUrlToFile(response.data.files[0].base64, response.data.files[0].name); |
||||
|
const result = await uploadMedia(file); // سرویس موجود در src/services/upload.ts |
||||
|
``` |
||||
|
|
||||
|
### نکات مهم برای Web |
||||
|
|
||||
|
- داخل WebView واقعی همیشه `window.HabibApp` وجود دارد؛ برای تشخیص محیط از helper موجود `isInFlutterWebView()` در `src/services/auth-token.ts` استفاده کنید و در خارج از WebView به `<input type="file">` بهعنوان fallback روی بیایید. |
||||
|
- برای فایلهای بزرگ (ویدیو/صوت) حتماً از `returnAs: 'upload'` استفاده کنید تا داده از کانال JS عبور نکند؛ `base64` فقط برای تصویر کوچک مناسب است. |
||||
|
- `uploadUrl` باید همان endpointی باشد که بکاند فایل را قبول میکند (در این پروژه `/hussainya/dashboard/upload-media/` با فیلد `file` — مطابق `src/services/upload.ts`). |
||||
|
- اگر endpoint نیاز به توکن دارد، یا `headers` را ارسال کنید یا به Flutter اجازه دهید توکن/کوکی فعلی را خودش تزریق کند. |
||||
|
- `maxBytes` را همیشه بفرستید تا Flutter قبل از شروع آپلود فایل بزرگ را رد کند و پهنای باند هدر نرود. |
||||
|
- وب فقط درخواست میفرستد؛ انتخاب فایل، permission دوربین/گالری، resize تصویر، multipart upload، progress و مدیریت platform بر عهده Flutter است. |
||||
|
- اگر چند فایل با `multiple: true` انتخاب شود، `response.data.files` آرایهای با چند عضو خواهد بود و برای هر فایل یک `progress` جدا ارسال میشود (با `fileName` متمایز). |
||||
|
|
||||
|
### پیشنهاد پیادهسازی سمت Flutter |
||||
|
|
||||
|
الگوی dispatch و response دقیقاً مثل `get_location` در `lib/features/web_app/web_app_screen.dart` است: |
||||
|
|
||||
|
```dart |
||||
|
return switch (data['action'].toString().toLowerCase()) { |
||||
|
// ... اکشنهای موجود |
||||
|
'upload_file' => _handleUploadFile(data['data']), |
||||
|
_ => null, |
||||
|
}; |
||||
|
``` |
||||
|
|
||||
|
الگوی هندلر (بهعنوان مرجع، مطابق الگوی `_handleGetLocation` و `upload_song_page.dart`): |
||||
|
|
||||
|
```dart |
||||
|
void _handleUploadFile(dynamic payload) async { |
||||
|
final data = Map<String, dynamic>.from(payload as Map); |
||||
|
final mediaType = data['mediaType']?.toString() ?? 'file'; |
||||
|
final source = data['source']?.toString() ?? 'gallery'; |
||||
|
final returnAs = data['returnAs']?.toString() ?? 'upload'; |
||||
|
|
||||
|
try { |
||||
|
_sendResponseToWeb({'action': 'upload_file', 'success': true, 'status': 'picking'}); |
||||
|
|
||||
|
// 1) انتخاب فایل با image_picker / file_picker طبق mediaType |
||||
|
final List<File> files = await _pickFiles(mediaType, source, data); |
||||
|
|
||||
|
_sendResponseToWeb({ |
||||
|
'action': 'upload_file', |
||||
|
'success': true, |
||||
|
'status': 'picked', |
||||
|
'data': {'files': files.map(_fileInfo).toList()}, |
||||
|
}); |
||||
|
|
||||
|
// 2) اگر حالت upload بود، با dio/multipart به uploadUrl بفرست |
||||
|
if (returnAs == 'upload') { |
||||
|
final uploadUrl = data['uploadUrl'].toString(); |
||||
|
final results = await _uploadFilesWithProgress( |
||||
|
files, |
||||
|
uploadUrl: uploadUrl, |
||||
|
fieldName: data['fieldName']?.toString() ?? 'file', |
||||
|
onProgress: (p, sent, total, name) { |
||||
|
_sendResponseToWeb({ |
||||
|
'action': 'upload_file', |
||||
|
'success': true, |
||||
|
'status': 'progress', |
||||
|
'data': {'progress': p, 'sent': sent, 'total': total, 'fileName': name}, |
||||
|
}); |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
_sendResponseToWeb({ |
||||
|
'action': 'upload_file', |
||||
|
'success': true, |
||||
|
'status': 'completed', |
||||
|
'data': {'files': results}, |
||||
|
}); |
||||
|
} else { |
||||
|
// حالت base64 (فقط image) |
||||
|
_sendResponseToWeb({ |
||||
|
'action': 'upload_file', |
||||
|
'success': true, |
||||
|
'status': 'completed', |
||||
|
'data': {'files': await _filesToBase64(files)}, |
||||
|
}); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
final cancelled = error is _PickerCancelledException; |
||||
|
_sendResponseToWeb({ |
||||
|
'action': 'upload_file', |
||||
|
'success': false, |
||||
|
'status': cancelled ? 'cancelled' : 'failed', |
||||
|
'message': cancelled ? 'User cancelled file selection' : error.toString(), |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### وابستگیها |
||||
|
|
||||
|
`image_picker` و `file_picker` از قبل در `pubspec.yaml` موجودند و نیازی به افزودن پکیج جدید نیست؛ برای آپلود در سمت Flutter از `dio` (موجود) با `MultipartFile` و `onSendProgress` استفاده شود. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## جمعبندی actionها |
||||
|
|
||||
|
| Action | کاربرد | |
||||
|
|---|---| |
||||
|
| `download_file` | دانلود native هر نوع فایل | |
||||
|
| `upload_file` | انتخاب فایل/تصویر/ویدیو از دستگاه و آپلود به سرور (یا بازگرداندن base64) | |
||||
|
| `copy_to_clipboard` | کپی متن در clipboard دستگاه | |
||||
|
| `open_external_url` | باز کردن URL در مرورگر یا اپ خارجی | |
||||
@ -0,0 +1,940 @@ |
|||||
|
|
||||
|
## 📊 نحوه Navigation به هر سرویس |
||||
|
|
||||
|
### 1️⃣ **قرآن (Quran)** |
||||
|
|
||||
|
#### **A. باز کردن سرویس قرآن (صفحه اصلی):** |
||||
|
|
||||
|
```dart |
||||
|
Navigator.pushNamed(context, RoutesName.quran); |
||||
|
``` |
||||
|
|
||||
|
```tsx |
||||
|
// از WebView: |
||||
|
window.FlutterChannel?.postMessage(JSON.stringify({ |
||||
|
type: 'NAVIGATE', |
||||
|
payload: { |
||||
|
route: '/quran' |
||||
|
} |
||||
|
})); |
||||
|
``` |
||||
|
|
||||
|
#### **B. باز کردن صفحه خاص (آیه):** |
||||
|
|
||||
|
```dart |
||||
|
// Navigation در کد فلاتر: |
||||
|
context.pushPage(QuranAyasScreen(ayaId)); |
||||
|
|
||||
|
// مثالها: |
||||
|
context.pushPage(QuranAyasScreen(123)); // آیه 123 |
||||
|
context.pushPage(QuranAyasScreen(null)); // از اول |
||||
|
context.pushPage(QuranAyasScreen( |
||||
|
surah.startAt, // آیه اول سوره |
||||
|
)); |
||||
|
``` |
||||
|
|
||||
|
**ساختار QuranAyasScreen:** |
||||
|
|
||||
|
```dart |
||||
|
class QuranAyasScreen extends StatefulWidget { |
||||
|
final int? startAya; // ⭐ ID آیه |
||||
|
final String? autoPlayReciter; // ⭐ پخش خودکار (اختیاری) |
||||
|
|
||||
|
const QuranAyasScreen( |
||||
|
this.startAya, |
||||
|
{this.autoPlayReciter, super.key} |
||||
|
); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
#### **JSON برای WebView:** |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"type": "NAVIGATE_QURAN", |
||||
|
"payload": { |
||||
|
"ayaId": 123, |
||||
|
"autoPlay": true, |
||||
|
"reciterId": "abdul-basit" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**یا سادهتر:** |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"type": "NAVIGATE", |
||||
|
"payload": { |
||||
|
"route": "/quran", |
||||
|
"params": { |
||||
|
"ayaId": 123, |
||||
|
"autoPlay": true |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### 2️⃣ **مفاتیح (Mafatih)** |
||||
|
|
||||
|
#### **A. باز کردن سرویس مفاتیح (صفحه اصلی):** |
||||
|
|
||||
|
```dart |
||||
|
Navigator.pushNamed(context, RoutesName.mafatih); |
||||
|
``` |
||||
|
|
||||
|
```tsx |
||||
|
// از WebView: |
||||
|
window.FlutterChannel?.postMessage(JSON.stringify({ |
||||
|
type: 'NAVIGATE', |
||||
|
payload: { |
||||
|
route: '/mafatih' |
||||
|
} |
||||
|
})); |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
#### **B. باز کردن دعای خاص:** |
||||
|
|
||||
|
```dart |
||||
|
// Navigation در کد فلاتر: |
||||
|
context.pushPage(MafatihDuasScreen(duaId)); |
||||
|
|
||||
|
// مثال: |
||||
|
context.pushPage(MafatihDuasScreen(45)); // دعای عهد |
||||
|
context.pushPage(MafatihDuasScreen(1)); // دعای اول |
||||
|
``` |
||||
|
|
||||
|
**ساختار MafatihDuasScreen:** |
||||
|
|
||||
|
```dart |
||||
|
class MafatihDuasScreen extends StatefulWidget { |
||||
|
final int? startDuaId; // ⭐ ID دعا |
||||
|
final int? startDuaPartId; // ⭐ بخش خاص دعا (اختیاری) |
||||
|
|
||||
|
const MafatihDuasScreen( |
||||
|
this.startDuaId, |
||||
|
{this.startDuaPartId, super.key} |
||||
|
); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
#### **JSON برای WebView:** |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"type": "NAVIGATE_MAFATIH", |
||||
|
"payload": { |
||||
|
"duaId": 45, |
||||
|
"duaPartId": 2, |
||||
|
"autoPlay": true |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 3️⃣ **کتابخانه (Library)** |
||||
|
|
||||
|
#### **A. باز کردن سرویس کتابخانه (صفحه اصلی):** |
||||
|
|
||||
|
```dart |
||||
|
Navigator.pushNamed(context, RoutesName.library); |
||||
|
``` |
||||
|
|
||||
|
```tsx |
||||
|
// از WebView: |
||||
|
window.FlutterChannel?.postMessage(JSON.stringify({ |
||||
|
type: 'NAVIGATE', |
||||
|
payload: { |
||||
|
route: '/library' |
||||
|
} |
||||
|
})); |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
#### **B. باز کردن کتاب خاص:** |
||||
|
|
||||
|
```dart |
||||
|
// Navigation در کد فلاتر: |
||||
|
context.pushPage(BookPage(bookSlug)); |
||||
|
|
||||
|
// یا با named route: |
||||
|
Navigator.pushNamed( |
||||
|
context, |
||||
|
RoutesName.librarySinglePage, |
||||
|
arguments: bookSlug, |
||||
|
); |
||||
|
``` |
||||
|
|
||||
|
**JSON برای WebView:** |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"type": "NAVIGATE_LIBRARY", |
||||
|
"payload": { |
||||
|
"slug": "kitab-al-tawheed" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**توضیحات:** |
||||
|
- `slug`: شناسه یکتای متنی کتاب (مثال: `"kitab-al-tawheed"`) |
||||
|
- API Endpoint: `GET /library/v2/books/{slug}/` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### 4️⃣ **حسینیه (Hossienieh)** |
||||
|
|
||||
|
#### **A. باز کردن سرویس حسینیه:** |
||||
|
|
||||
|
```dart |
||||
|
Navigator.pushNamed(context, RoutesName.hosseinieh); |
||||
|
``` |
||||
|
|
||||
|
```tsx |
||||
|
// از WebView: |
||||
|
window.FlutterChannel?.postMessage(JSON.stringify({ |
||||
|
type: 'NAVIGATE', |
||||
|
payload: { |
||||
|
route: '/hosseinieh' |
||||
|
} |
||||
|
})); |
||||
|
``` |
||||
|
#### **B. باز کردن پلیر با آهنگ:** |
||||
|
|
||||
|
```dart |
||||
|
// Navigation در کد فلاتر: |
||||
|
context.read<MusicPlayerCubit>().openAudio( |
||||
|
audios: [song1, song2, song3], |
||||
|
songModel: currentSong, |
||||
|
showSmallPlayer: true, |
||||
|
); |
||||
|
|
||||
|
// با تنظیمات پیشرفته (پیشنهادی جدید): |
||||
|
context.read<MusicPlayerCubit>().openHosseiniehPlaylist( |
||||
|
HosseiniehPlaylist( |
||||
|
audios: audios, |
||||
|
startIndex: 0, |
||||
|
startDuration: 120000, // شروع از دقیقه 2 (میلیثانیه) |
||||
|
playbackSpeed: 2.0, // سرعت 2 برابر |
||||
|
), |
||||
|
showSmallPlayer: true, |
||||
|
); |
||||
|
``` |
||||
|
|
||||
|
#### **JSON برای WebView (پیشنهاد جدید):** |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"type": "PLAY_HOSSEINIEH", |
||||
|
"payload": { |
||||
|
"songs": [ |
||||
|
{ |
||||
|
"slug": "song-123", |
||||
|
"title": "نوحه محرم", |
||||
|
"singers": [{"name": "حاج محمود کریمی"}], |
||||
|
"file": {"audio": "https://example.com/audio.mp3"}, |
||||
|
"thumbnail": {"sm": "https://example.com/thumb.jpg"} |
||||
|
} |
||||
|
], |
||||
|
"currentSongSlug": "song-123", |
||||
|
"showSmallPlayer": true, |
||||
|
"config": { |
||||
|
"startTime": 120000, |
||||
|
"playbackSpeed": 2.0, |
||||
|
"loopMode": "single", |
||||
|
"autoPlay": true, |
||||
|
"fadeInDuration": 1000, |
||||
|
"fadeOutDuration": 1000 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
#### **توضیح فیلدهای config:** |
||||
|
|
||||
|
| فیلد | نوع | پیشفرض | توضیح | |
||||
|
|------|-----|---------|-------| |
||||
|
| `startTime` | `number` | `0` | شروع از زمان مشخص (میلیثانیه) - مثل مفاتیح | |
||||
|
| `playbackSpeed` | `number` | `1.0` | سرعت پخش (0.5x تا 2.0x) | |
||||
|
| `loopMode` | `"none"` \| `"single"` \| `"playlist"` | `"none"` | حالت تکرار | |
||||
|
| `autoPlay` | `boolean` | `true` | شروع خودکار پخش | |
||||
|
| `fadeInDuration` | `number?` | - | مدت زمان fade-in (میلیثانیه) | |
||||
|
| `fadeOutDuration` | `number?` | - | مدت زمان fade-out (میلیثانیه) | |
||||
|
| `pitch` | `number?` | `1.0` | تغییر pitch (اختیاری) | |
||||
|
| `volume` | `number?` | `1.0` | حجم صدا (0.0 تا 1.0) | |
||||
|
|
||||
|
#### **مثالهای کاربردی:** |
||||
|
|
||||
|
**۱. شروع از دقیقه 2 با سرعت 2 برابر:** |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"type": "PLAY_HOSSEINIEH", |
||||
|
"payload": { |
||||
|
"songs": [...], |
||||
|
"currentSongSlug": "song-123", |
||||
|
"config": { |
||||
|
"startTime": 120000, |
||||
|
"playbackSpeed": 2.0 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**۲. پخش کامل با fade-in:** |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"type": "PLAY_HOSSEINIEH", |
||||
|
"payload": { |
||||
|
"songs": [...], |
||||
|
"currentSongSlug": "song-123", |
||||
|
"config": { |
||||
|
"fadeInDuration": 2000, |
||||
|
"loopMode": "single" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**۳. شروع از انتخاب کاربر با تنظیمات پیشرفته:** |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"type": "PLAY_HOSSEINIEH", |
||||
|
"payload": { |
||||
|
"songs": [...], |
||||
|
"currentSongSlug": "song-456", |
||||
|
"config": { |
||||
|
"startTime": 0, |
||||
|
"playbackSpeed": 1.5, |
||||
|
"volume": 0.8, |
||||
|
"autoPlay": true |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### 6️⃣ **تاک (Talk)** |
||||
|
|
||||
|
#### **A. باز کردن صفحه اصلی Talk (لیست مشاورین):** |
||||
|
|
||||
|
```dart |
||||
|
// Navigation در کد فلاتر: |
||||
|
Navigator.pushNamed(context, RoutesName.talk); |
||||
|
``` |
||||
|
|
||||
|
**JSON برای WebView:** |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"type": "NAVIGATE_TALK" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**توضیحات:** |
||||
|
- باز میکند صفحه اصلی سرویس Talk |
||||
|
- برای کاربر عادی: لیست مشاورین نمایش داده میشود |
||||
|
- برای مشاور: داشبورد مشاور نمایش داده میشود |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
#### **B. باز کردن صفحه جزئیات مشاور:** |
||||
|
|
||||
|
```dart |
||||
|
// Navigation در کد فلاتر: |
||||
|
context.pushPage(ConsultantInfoPage(consultant)); |
||||
|
|
||||
|
// با استفاده از notification: |
||||
|
Navigator.pushNamed( |
||||
|
context, |
||||
|
'/TalkConsultantPage', |
||||
|
arguments: consultantJson, |
||||
|
); |
||||
|
``` |
||||
|
|
||||
|
**JSON برای WebView:** |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"type": "NAVIGATE_TALK", |
||||
|
"payload": { |
||||
|
"page": "consultant", |
||||
|
"username": "consultant_username" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**توضیحات:** |
||||
|
- `username`: نام کاربری یکتای مشاور (مثال: `"ahmad_consultant"`) |
||||
|
- صفحه جزئیات شامل: بیوگرافی، نظرات، زمانبندی و انواع تماس |
||||
|
- از این صفحه کاربر میتواند درخواست چت یا تماس بدهد |
||||
|
|
||||
|
**نمونه Consultant JSON کامل:** |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"username": "consultant_username", |
||||
|
"fullname": "احمد محمدی", |
||||
|
"avatar_url": "https://example.com/avatar.jpg", |
||||
|
"slogan": "مشاور خانواده و ازدواج", |
||||
|
"bio": "توضیحات کامل مشاور...", |
||||
|
"status": "online", |
||||
|
"unread_count": 0, |
||||
|
"avg_rate": 4.5, |
||||
|
"languages": ["fa", "en"], |
||||
|
"contact_type": ["chat", "voice", "video"], |
||||
|
"categories": [], |
||||
|
"topics": ["ازدواج", "خانواده"], |
||||
|
"session_duration": 30, |
||||
|
"video_call_cost": 50000, |
||||
|
"voice_call_cost": 30000, |
||||
|
"first_message": "سلام، چطور میتونم کمکتون کنم؟", |
||||
|
"is_ai": false |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
#### **C. باز کردن صفحه چت با مشاور:** |
||||
|
|
||||
|
```dart |
||||
|
// Navigation در کد فلاتر: |
||||
|
context.pushPage(TalkChatPage(event: callEvent)); |
||||
|
|
||||
|
// با استفاده از notification: |
||||
|
Navigator.pushNamed( |
||||
|
context, |
||||
|
'/TalkChatPage', |
||||
|
arguments: callEventJson, |
||||
|
); |
||||
|
``` |
||||
|
|
||||
|
**JSON برای WebView:** |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"type": "NAVIGATE_TALK", |
||||
|
"payload": { |
||||
|
"page": "chat", |
||||
|
"call_id": 12345, |
||||
|
"uuid": "consultant_username", |
||||
|
"call_type": "chat", |
||||
|
"from_user_username": "consultant_username", |
||||
|
"from_user_fullname": "احمد محمدی", |
||||
|
"from_user_avatar": "https://example.com/avatar.jpg", |
||||
|
"session_duration": 30, |
||||
|
"price": 0, |
||||
|
"is_from_user": true |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**توضیحات:** |
||||
|
- `call_id`: شناسه یکتای چت/تماس (اختیاری) |
||||
|
- `uuid`: نام کاربری مشاور |
||||
|
- `call_type`: نوع تماس (`"chat"`, `"voice"`, یا `"video"`) |
||||
|
- `from_user_*`: اطلاعات فرستنده پیام |
||||
|
- `session_duration`: مدت زمان جلسه به دقیقه |
||||
|
- `price`: هزینه تماس (برای چت معمولاً 0) |
||||
|
- `is_from_user`: آیا از طرف کاربر است یا مشاور |
||||
|
|
||||
|
**نمونه CallEvent JSON کامل:** |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"act": "", |
||||
|
"call_id": 12345, |
||||
|
"uuid": "consultant_username", |
||||
|
"call_type": "chat", |
||||
|
"session_duration": 30, |
||||
|
"call_direction": "outgoing", |
||||
|
"is_from_user": true, |
||||
|
"price": 0, |
||||
|
"me": { |
||||
|
"name": "محمد رضایی", |
||||
|
"username": "user_username", |
||||
|
"avatar": "https://example.com/user-avatar.jpg" |
||||
|
}, |
||||
|
"contact": { |
||||
|
"name": "احمد محمدی", |
||||
|
"username": "consultant_username", |
||||
|
"avatar": "https://example.com/consultant-avatar.jpg", |
||||
|
"extra": "مشاور خانواده" |
||||
|
}, |
||||
|
"chat_data": { |
||||
|
"init_text": "سلام، نیاز به مشاوره دارم", |
||||
|
"first_message": "سلام، چطور میتونم کمکتون کنم؟" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
#### **D. نکات مهم برای دو طرفه بودن Navigation:** |
||||
|
|
||||
|
**1. از کاربر به مشاور (User → Consultant):** |
||||
|
- کاربر از لیست مشاورین (`UserHomePage`) میتواند: |
||||
|
- به صفحه جزئیات مشاور (`ConsultantInfoPage`) برود |
||||
|
- از آنجا درخواست چت بدهد و به صفحه چت (`TalkChatPage`) برود |
||||
|
|
||||
|
**2. از مشاور به کاربر (Consultant → User):** |
||||
|
- مشاور از داشبورد خود (`ConsultantHomePage`) میتواند: |
||||
|
- لیست چتها را ببیند (`ChatsPage`) |
||||
|
- روی هر چت کلیک کند و به صفحه چت (`TalkChatPage`) برود |
||||
|
- درخواستهای booking را ببیند (`BookingPage`) |
||||
|
|
||||
|
**3. نوتیفیکیشنها:** |
||||
|
- هر دو طرف از طریق notification میتوانند مستقیماً به چت بروند |
||||
|
- Navigation routes: |
||||
|
- `/TalkChatPage` → باز کردن صفحه چت |
||||
|
- `/TalkConsultantPage` → باز کردن صفحه جزئیات مشاور |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### 7️⃣ **پلیر کنترل (Player Control Events)** |
||||
|
|
||||
|
#### **کنترلهای استاندارد:** |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"type": "PLAYER_PAUSE" |
||||
|
} |
||||
|
|
||||
|
{ |
||||
|
"type": "PLAYER_RESUME" |
||||
|
} |
||||
|
|
||||
|
{ |
||||
|
"type": "PLAYER_STOP" |
||||
|
} |
||||
|
|
||||
|
{ |
||||
|
"type": "PLAYER_SEEK", |
||||
|
"payload": { |
||||
|
"position": 60000 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
{ |
||||
|
"type": "PLAYER_SET_SPEED", |
||||
|
"payload": { |
||||
|
"speed": 1.5 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
{ |
||||
|
"type": "PLAYER_SET_VOLUME", |
||||
|
"payload": { |
||||
|
"volume": 0.7 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
{ |
||||
|
"type": "PLAYER_NEXT" |
||||
|
} |
||||
|
|
||||
|
{ |
||||
|
"type": "PLAYER_PREVIOUS" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### 9️⃣ **Cross-Service Navigation (نویگیشن بین سرویسها)** |
||||
|
|
||||
|
این event برای انتقال از یک سرویس به سرویس دیگر استفاده میشود. هم برای Native Services و هم WebView Services کار میکند. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
#### **A. Navigation به سرویس Native:** |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"type": "NAVIGATE_TO_SERVICE", |
||||
|
"payload": { |
||||
|
"service": "quran", |
||||
|
"params": { |
||||
|
"ayaId": 123, |
||||
|
"autoPlay": true |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**سرویسهای Native موجود:** |
||||
|
- `quran` → سرویس قرآن |
||||
|
- `mafatih` → سرویس مفاتیح |
||||
|
- `library` → کتابخانه |
||||
|
- `hosseinieh` → حسینیه |
||||
|
- `talk` → Talk (مشاوره) |
||||
|
- `meet` → Meet |
||||
|
- `habit` → عادات |
||||
|
- `ahkaam` → احکام |
||||
|
- `tafsir` → تفسیر |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
#### **B. Navigation به WebView Service با URL کامل:** |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"type": "NAVIGATE_TO_SERVICE", |
||||
|
"payload": { |
||||
|
"service": "marriage", |
||||
|
"url": "https://marriage.habibapp.com", |
||||
|
"path": "/games", |
||||
|
"params": { |
||||
|
"level": 5, |
||||
|
"mode": "challenge" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**توضیحات:** |
||||
|
- `service`: نام سرویس (برای لاگ و tracking) |
||||
|
- `url`: آدرس پایه WebView |
||||
|
- `path`: مسیر درون سرویس (اختیاری) |
||||
|
- `params`: پارامترهای query string (اختیاری) |
||||
|
|
||||
|
**URL نهایی:** |
||||
|
``` |
||||
|
https://marriage.habibapp.com/games?level=5&mode=challenge |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
#### **C. Navigation به WebView با Service Name (از Config):** |
||||
|
|
||||
|
اگر سرویس از قبل در `ServiceConfigs` تعریف شده باشد: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"type": "NAVIGATE_TO_SERVICE", |
||||
|
"payload": { |
||||
|
"service": "marriage", |
||||
|
"path": "/games", |
||||
|
"params": { |
||||
|
"level": 5 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**توضیحات:** |
||||
|
- Flutter از `ServiceConfigs` آدرس پایه را میخواند |
||||
|
- `path` و `params` به آدرس پایه اضافه میشوند |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
#### **D. مثالهای کاربردی:** |
||||
|
|
||||
|
**۱. از سرویس Marriage به Talk:** |
||||
|
```json |
||||
|
{ |
||||
|
"type": "NAVIGATE_TO_SERVICE", |
||||
|
"payload": { |
||||
|
"service": "talk", |
||||
|
"params": { |
||||
|
"page": "consultant", |
||||
|
"username": "marriage_consultant" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**۲. از Talk به بازی Marriage:** |
||||
|
```json |
||||
|
{ |
||||
|
"type": "NAVIGATE_TO_SERVICE", |
||||
|
"payload": { |
||||
|
"service": "marriage", |
||||
|
"path": "/games/compatibility", |
||||
|
"params": { |
||||
|
"consultantId": "ahmad_consultant" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**۳. از Quran به Library (کتاب تفسیر):** |
||||
|
```json |
||||
|
{ |
||||
|
"type": "NAVIGATE_TO_SERVICE", |
||||
|
"payload": { |
||||
|
"service": "library", |
||||
|
"params": { |
||||
|
"slug": "tafsir-al-mizan", |
||||
|
"chapter": 5 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**۴. از Library به Mafatih:** |
||||
|
```json |
||||
|
{ |
||||
|
"type": "NAVIGATE_TO_SERVICE", |
||||
|
"payload": { |
||||
|
"service": "mafatih", |
||||
|
"params": { |
||||
|
"duaId": 45 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**۵. از WebView به WebView دیگر:** |
||||
|
```json |
||||
|
{ |
||||
|
"type": "NAVIGATE_TO_SERVICE", |
||||
|
"payload": { |
||||
|
"service": "health", |
||||
|
"url": "https://health.habibapp.com", |
||||
|
"path": "/consultation", |
||||
|
"params": { |
||||
|
"type": "mental_health", |
||||
|
"referrer": "marriage" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
#### **E. پیادهسازی در Flutter:** |
||||
|
|
||||
|
```dart |
||||
|
Future<void> handleNavigateToService(Map<String, dynamic> payload) async { |
||||
|
final serviceName = payload['service'] as String; |
||||
|
final params = payload['params'] as Map<String, dynamic>?; |
||||
|
final path = payload['path'] as String?; |
||||
|
final url = payload['url'] as String?; |
||||
|
|
||||
|
// Native Services |
||||
|
if (_isNativeService(serviceName)) { |
||||
|
return _navigateToNativeService(serviceName, params); |
||||
|
} |
||||
|
|
||||
|
// WebView Services |
||||
|
final serviceUrl = url ?? _getServiceUrlFromConfig(serviceName); |
||||
|
if (serviceUrl != null) { |
||||
|
final fullUrl = _buildWebViewUrl(serviceUrl, path, params); |
||||
|
return _navigateToWebView(serviceName, fullUrl); |
||||
|
} |
||||
|
|
||||
|
// Service not found |
||||
|
throw Exception('Service "$serviceName" not found'); |
||||
|
} |
||||
|
|
||||
|
String _buildWebViewUrl( |
||||
|
String baseUrl, |
||||
|
String? path, |
||||
|
Map<String, dynamic>? params, |
||||
|
) { |
||||
|
final uri = Uri.parse(baseUrl); |
||||
|
final pathSegment = path ?? ''; |
||||
|
|
||||
|
final queryParams = params?.map( |
||||
|
(key, value) => MapEntry(key, value.toString()), |
||||
|
) ?? {}; |
||||
|
|
||||
|
return uri.replace( |
||||
|
path: pathSegment, |
||||
|
queryParameters: queryParams.isEmpty ? null : queryParams, |
||||
|
).toString(); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
#### **F. استفاده در React/TypeScript:** |
||||
|
|
||||
|
```typescript |
||||
|
// utils/navigation.ts |
||||
|
interface NavigateToServicePayload { |
||||
|
service: string; |
||||
|
url?: string; |
||||
|
path?: string; |
||||
|
params?: Record<string, any>; |
||||
|
} |
||||
|
|
||||
|
export function navigateToService(payload: NavigateToServicePayload) { |
||||
|
window.FlutterChannel?.postMessage(JSON.stringify({ |
||||
|
type: 'NAVIGATE_TO_SERVICE', |
||||
|
payload, |
||||
|
})); |
||||
|
} |
||||
|
|
||||
|
// مثال استفاده: |
||||
|
import { navigateToService } from '@/utils/navigation'; |
||||
|
|
||||
|
// Navigate to Talk |
||||
|
navigateToService({ |
||||
|
service: 'talk', |
||||
|
params: { |
||||
|
page: 'consultant', |
||||
|
username: 'consultant_username', |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
// Navigate to another WebView service |
||||
|
navigateToService({ |
||||
|
service: 'marriage', |
||||
|
path: '/games', |
||||
|
params: { level: 5 }, |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
#### **G. Schema کامل:** |
||||
|
|
||||
|
```typescript |
||||
|
interface NavigateToServiceEvent { |
||||
|
type: 'NAVIGATE_TO_SERVICE'; |
||||
|
payload: { |
||||
|
// Required: نام سرویس |
||||
|
service: string; |
||||
|
|
||||
|
// Optional: برای WebView services |
||||
|
url?: string; // آدرس کامل پایه |
||||
|
path?: string; // مسیر داخل سرویس |
||||
|
params?: Record<string, any>; // Query parameters |
||||
|
|
||||
|
// Optional: تنظیمات اضافی |
||||
|
options?: { |
||||
|
replaceCurrentRoute?: boolean; // جایگزین route فعلی |
||||
|
clearStack?: boolean; // پاک کردن history |
||||
|
animation?: 'slide' | 'fade' | 'none'; |
||||
|
}; |
||||
|
}; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
#### **H. نکات مهم:** |
||||
|
|
||||
|
1. **Service Name:** |
||||
|
- باید lowercase باشد |
||||
|
- فقط حروف، اعداد و underscore |
||||
|
- مثال: `marriage`, `health_tips`, `quran_v2` |
||||
|
|
||||
|
2. **URL Building:** |
||||
|
- اگر `url` وجود نداشته باشد، از `ServiceConfigs` خوانده میشود |
||||
|
- `path` باید با `/` شروع شود |
||||
|
- `params` به صورت خودکار به query string تبدیل میشود |
||||
|
|
||||
|
3. **Security:** |
||||
|
- فقط URLهای مجاز (`habibapp.com`) قابل باز شدن هستند |
||||
|
- پارامترها باید sanitize شوند |
||||
|
|
||||
|
4. **History Management:** |
||||
|
- هر navigation به history stack اضافه میشود |
||||
|
- کاربر میتواند با دکمه back برگردد |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
#### **Eventهای ارسال شده از Flutter:** |
||||
|
|
||||
|
```typescript |
||||
|
// types/player-events.ts |
||||
|
interface PlayerStateEvent { |
||||
|
type: 'PLAYER_STATE_CHANGED'; |
||||
|
payload: { |
||||
|
isPlaying: boolean; |
||||
|
currentAudio: { |
||||
|
id: string; |
||||
|
title: string; |
||||
|
artist?: string; |
||||
|
duration: number; |
||||
|
thumbnail?: string; |
||||
|
}; |
||||
|
currentPosition: number; |
||||
|
duration: number; |
||||
|
playbackSpeed: number; |
||||
|
volume: number; |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
interface PlayerProgressEvent { |
||||
|
type: 'PLAYER_PROGRESS'; |
||||
|
payload: { |
||||
|
position: number; |
||||
|
duration: number; |
||||
|
buffered: number; |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
interface PlayerErrorEvent { |
||||
|
type: 'PLAYER_ERROR'; |
||||
|
payload: { |
||||
|
code: string; |
||||
|
message: string; |
||||
|
}; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
#### **دریافت در React:** |
||||
|
|
||||
|
```tsx |
||||
|
// hooks/usePlayerState.ts |
||||
|
import { useEffect, useState } from 'react'; |
||||
|
|
||||
|
export function usePlayerState() { |
||||
|
const [isPlaying, setIsPlaying] = useState(false); |
||||
|
const [currentAudio, setCurrentAudio] = useState<any>(null); |
||||
|
const [position, setPosition] = useState(0); |
||||
|
const [duration, setDuration] = useState(0); |
||||
|
const [speed, setSpeed] = useState(1.0); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
const handlePlayerState = (event: CustomEvent) => { |
||||
|
const { type, payload } = event.detail; |
||||
|
|
||||
|
switch (type) { |
||||
|
case 'PLAYER_STATE_CHANGED': |
||||
|
setIsPlaying(payload.isPlaying); |
||||
|
setCurrentAudio(payload.currentAudio); |
||||
|
setSpeed(payload.playbackSpeed); |
||||
|
break; |
||||
|
|
||||
|
case 'PLAYER_PROGRESS': |
||||
|
setPosition(payload.position); |
||||
|
setDuration(payload.duration); |
||||
|
break; |
||||
|
|
||||
|
case 'PLAYER_ERROR': |
||||
|
console.error('Player error:', payload.message); |
||||
|
break; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
window.addEventListener('playerState', handlePlayerState as EventListener); |
||||
|
|
||||
|
return () => { |
||||
|
window.removeEventListener('playerState', handlePlayerState as EventListener); |
||||
|
}; |
||||
|
}, []); |
||||
|
|
||||
|
return { |
||||
|
isPlaying, |
||||
|
currentAudio, |
||||
|
position, |
||||
|
duration, |
||||
|
speed, |
||||
|
}; |
||||
|
} |
||||
|
``` |
||||
@ -0,0 +1,783 @@ |
|||||
|
|
||||
|
### 📱 تاثیر در 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'; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
📱 توضیح کامل با مثالهای واقعی |
||||
|
|
||||
|
🎯 Initial Config - ارسال یکبار هنگام Load |
||||
|
|
||||
|
این JSON فقط یکبار زمانی که WebView لود میشود ارسال میشود تا React بداند در چه محیطی اجرا میشود. |
||||
|
|
||||
|
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── |
||||
|
|
||||
|
1️⃣ layout - اطلاعات صفحه |
||||
|
|
||||
|
"layout": { |
||||
|
"breakpoint": "mobile", // نوع دستگاه |
||||
|
"screenWidth": 375, // عرض صفحه (پیکسل) |
||||
|
"screenHeight": 812, // ارتفاع صفحه (پیکسل) |
||||
|
"isMobile": true, |
||||
|
"isTablet": false, |
||||
|
"isDesktop": false |
||||
|
} |
||||
|
|
||||
|
📱 مثال عملی: |
||||
|
|
||||
|
// در React استفاده میشود برای: |
||||
|
|
||||
|
function ChatPage({ layout }) { |
||||
|
return ( |
||||
|
<div style={{ |
||||
|
padding: layout.isMobile ? '16px' : '64px' // موبایل = 16، تبلت = 64 |
||||
|
}}> |
||||
|
{/* محتوای چت */} |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// نتیجه: |
||||
|
// iPhone (mobile): padding = 16px |
||||
|
// iPad (tablet): padding = 64px |
||||
|
|
||||
|
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── |
||||
|
|
||||
|
2️⃣ safeArea - فضای امن صفحه |
||||
|
|
||||
|
"safeArea": { |
||||
|
"top": 44, // ارتفاع status bar / notch |
||||
|
"bottom": 34, // ارتفاع home indicator (خط سفید iPhone X) |
||||
|
"left": 0, |
||||
|
"right": 0 |
||||
|
} |
||||
|
|
||||
|
📱 چرا مهم است؟ |
||||
|
|
||||
|
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 600'%3E%3Crect width='300' height='600' fill='%23f5f5f5'/%3E%3Crect y='0' width='300' height='44' fill='%23000' |
||||
|
opacity='0.3'/%3E%3Ctext x='150' y='27' text-anchor='middle' fill='white' font-size='12'%3EStatus Bar (44px)%3C/text%3E%3Crect y='556' width='300' height='44' fill='%23000' opacity='0.3'/%3E%3Ctext x='150' y='582' |
||||
|
text-anchor='middle' fill='white' font-size='12'%3EHome Indicator (34px)%3C/text%3E%3C/svg%3E" width="150" /> |
||||
|
|
||||
|
مثال عملی: |
||||
|
|
||||
|
// ❌ بدون safe area |
||||
|
<button style={{ position: 'fixed', bottom: '16px' }}> |
||||
|
Send Message |
||||
|
</button> |
||||
|
// نتیجه: دکمه زیر home indicator مخفی میشود! |
||||
|
|
||||
|
// ✅ با safe area |
||||
|
<button style={{ |
||||
|
position: 'fixed', |
||||
|
bottom: `${16 + safeArea.bottom}px` // 16 + 34 = 50px |
||||
|
}}> |
||||
|
Send Message |
||||
|
</button> |
||||
|
// نتیجه: دکمه بالای home indicator نمایش داده میشود ✅ |
||||
|
|
||||
|
مثال واقعی از کد فلاتر: |
||||
|
|
||||
|
// فلاتر: |
||||
|
bottom: 16 + context.padding.bottom, // 16 + 34 = 50px |
||||
|
|
||||
|
// باید در React شود: |
||||
|
bottom: 16 + safeArea.bottom // 16 + 34 = 50px |
||||
|
|
||||
|
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── |
||||
|
|
||||
|
3️⃣ viewInsets - فضای اشغال شده توسط سیستم |
||||
|
|
||||
|
"viewInsets": { |
||||
|
"top": 0, |
||||
|
"bottom": 0, // این عدد تغییر میکند! |
||||
|
"left": 0, |
||||
|
"right": 0 |
||||
|
} |
||||
|
|
||||
|
📱 کاربرد اصلی: Keyboard Height |
||||
|
|
||||
|
┌─────────────────┐ |
||||
|
│ Chat Messages │ ← محتوا |
||||
|
│ │ |
||||
|
│ │ |
||||
|
├─────────────────┤ |
||||
|
│ TextField │ ← ورودی متن |
||||
|
├─────────────────┤ |
||||
|
│ │ |
||||
|
│ Keyboard │ ← viewInsets.bottom = 336px |
||||
|
│ ⌨️ │ |
||||
|
└─────────────────┘ |
||||
|
|
||||
|
مثال عملی: |
||||
|
|
||||
|
// هنگام load اولیه: |
||||
|
viewInsets.bottom = 0 // کیبورد بسته است |
||||
|
|
||||
|
// کاربر روی TextField کلیک میکند: |
||||
|
// Flutter event میفرستد: |
||||
|
{ |
||||
|
"type": "KEYBOARD_CHANGED", |
||||
|
"payload": { "bottom": 336 } |
||||
|
} |
||||
|
|
||||
|
// React باید TextField را بالا ببرد: |
||||
|
<div style={{ |
||||
|
paddingBottom: Math.max(16, viewInsets.bottom) // 16 یا 336 |
||||
|
}}> |
||||
|
|
||||
|
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── |
||||
|
|
||||
|
4️⃣ designSize - اندازه طراحی Figma |
||||
|
|
||||
|
"designSize": { |
||||
|
"mobile": { "width": 375, "height": 814 }, |
||||
|
"tablet": { "width": 834, "height": 1194 } |
||||
|
} |
||||
|
|
||||
|
📱 کاربرد: Responsive Scaling |
||||
|
|
||||
|
// اگر طراح در Figma با عرض 375 طراحی کرده: |
||||
|
const figmaWidth = 375; |
||||
|
const figmaFontSize = 16; |
||||
|
|
||||
|
// دستگاه واقعی: |
||||
|
const deviceWidth = 414; // iPhone 14 Pro Max |
||||
|
|
||||
|
// محاسبه font size: |
||||
|
const scaleFactor = deviceWidth / figmaWidth; // 414 / 375 = 1.104 |
||||
|
const actualFontSize = figmaFontSize * scaleFactor; // 16 * 1.104 = 17.7px |
||||
|
|
||||
|
// نتیجه: فونت در گوشی بزرگتر به نسبت اندازه صفحه بزرگتر میشود |
||||
|
|
||||
|
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── |
||||
|
|
||||
|
5️⃣ platform - اطلاعات دستگاه |
||||
|
|
||||
|
"platform": { |
||||
|
"os": "ios", // ios | android |
||||
|
"version": "17.2", |
||||
|
"model": "iPhone 14 Pro", |
||||
|
"isPhysicalDevice": true, // true = گوشی واقعی، false = emulator |
||||
|
"hasNotch": true, // دارای notch |
||||
|
"hasDynamicIsland": true // دارای Dynamic Island |
||||
|
} |
||||
|
|
||||
|
📱 کاربرد: |
||||
|
|
||||
|
// مثال 1: تنظیم safe area مختلف |
||||
|
if (platform.hasNotch) { |
||||
|
safeAreaTop = 44; |
||||
|
} else { |
||||
|
safeAreaTop = 20; |
||||
|
} |
||||
|
|
||||
|
// مثال 2: استایل مختلف برای iOS/Android |
||||
|
<button className={platform.os === 'ios' ? 'ios-style' : 'material-style'}> |
||||
|
Send |
||||
|
</button> |
||||
|
|
||||
|
// مثال 3: تشخیص Dynamic Island |
||||
|
{platform.hasDynamicIsland && ( |
||||
|
<div style={{ paddingTop: '59px' }}> {/* فضا برای Dynamic Island */} |
||||
|
{/* محتوا */} |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── |
||||
|
|
||||
|
6️⃣ locale - تنظیمات زبان |
||||
|
|
||||
|
"locale": { |
||||
|
"languageCode": "en", // en | fa | ar |
||||
|
"countryCode": "US", |
||||
|
"isRtl": false // راست به چپ؟ |
||||
|
} |
||||
|
|
||||
|
📱 کاربرد: |
||||
|
|
||||
|
// مثال 1: تنظیم direction |
||||
|
<div dir={locale.isRtl ? 'rtl' : 'ltr'}> |
||||
|
{/* محتوا */} |
||||
|
</div> |
||||
|
|
||||
|
// مثال 2: متن مناسب زبان |
||||
|
const messages = { |
||||
|
en: "Hello", |
||||
|
fa: "سلام", |
||||
|
ar: "مرحبا" |
||||
|
}; |
||||
|
<p>{messages[locale.languageCode]}</p> |
||||
|
|
||||
|
// مثال 3: فرمت تاریخ |
||||
|
const date = new Date(); |
||||
|
date.toLocaleDateString(locale.languageCode); // en-US | fa-IR |
||||
|
|
||||
|
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── |
||||
|
|
||||
|
7️⃣ theme - تنظیمات تم |
||||
|
|
||||
|
"theme": { |
||||
|
"mode": "light", // light | dark |
||||
|
"primaryColor": "#FFB236" |
||||
|
} |
||||
|
|
||||
|
📱 کاربرد: |
||||
|
|
||||
|
// تنظیم رنگها |
||||
|
const colors = { |
||||
|
light: { |
||||
|
bg: '#F5F5F5', |
||||
|
text: '#202227' |
||||
|
}, |
||||
|
dark: { |
||||
|
bg: '#1A1A1A', |
||||
|
text: '#FFFFFF' |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
<div style={{ |
||||
|
backgroundColor: colors[theme.mode].bg, |
||||
|
color: colors[theme.mode].text |
||||
|
}}> |
||||
|
|
||||
|
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── |
||||
|
|
||||
|
🔄 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: |
||||
|
<div style={{ |
||||
|
paddingBottom: `${16 + keyboardHeight}px`, |
||||
|
transition: 'padding-bottom 250ms ease-out' |
||||
|
}}> |
||||
|
|
||||
|
مثال واقعی: |
||||
|
|
||||
|
کاربر روی 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) │ |
||||
|
└─────────────────────┴───────────────────┴────────────────────────────────────────────────┘ |
||||
@ -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 |
||||
|
<div |
||||
|
style={{ |
||||
|
paddingTop: `max(16px, env(safe-area-inset-top))`, |
||||
|
paddingBottom: `max(16px, env(safe-area-inset-bottom))`, |
||||
|
paddingLeft: `env(safe-area-inset-left)`, |
||||
|
paddingRight: `env(safe-area-inset-right)`, |
||||
|
}} |
||||
|
> |
||||
|
``` |
||||
|
|
||||
|
**اما بهتر است از 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<FlutterLayout>({ |
||||
|
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 ( |
||||
|
<div |
||||
|
style={{ |
||||
|
paddingTop: layout.safeArea.top, |
||||
|
paddingBottom: Math.max(16, layout.safeArea.bottom, layout.viewInsets.bottom), |
||||
|
paddingLeft: layout.isMobile ? 16 : 64, |
||||
|
paddingRight: layout.isMobile ? 16 : 64, |
||||
|
}} |
||||
|
> |
||||
|
{/* محتوای چت */} |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📋 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 |
||||
@ -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<string, unknown>, |
||||
|
): 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<string, unknown>; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 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<string, unknown>); |
||||
|
} |
||||
|
|
||||
|
// ─── 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<boolean> { |
||||
|
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<string, unknown>); |
||||
|
} |
||||
|
|
||||
|
// ─── 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<string, string>; |
||||
|
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<string, unknown>); |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue