Browse Source

feat: implement cross-platform file upload bridge for Flutter WebView integration

master
mortezaei 3 weeks ago
parent
commit
da88b08edc
  1. 983
      WEBVIEW_ACTION_DOWNLOAD.md
  2. 940
      WEBVIEW_EVENTS.md
  3. 783
      WEBVIEW_LAYOUT.md
  4. 652
      WEBVIEW_LAYOUT_BRIDGE.md
  5. 9
      src/app/intro/page.tsx
  6. 145
      src/components/questions/question-file.tsx
  7. 65
      src/components/ui/report-actions-sheet.tsx
  8. 146
      src/lib/webview-actions.ts
  9. 26
      src/types/window.d.ts

983
WEBVIEW_ACTION_DOWNLOAD.md

@ -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 در مرورگر یا اپ خارجی |

940
WEBVIEW_EVENTS.md

@ -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,
};
}
```

783
WEBVIEW_LAYOUT.md

@ -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) │
└─────────────────────┴───────────────────┴────────────────────────────────────────────────┘

652
WEBVIEW_LAYOUT_BRIDGE.md

@ -0,0 +1,652 @@
# 📐 اطلاعات Layout و Responsive که باید از Flutter به WebView ارسال شود
## 🎯 خلاصه اجرایی
این سند تمام اطلاعاتی که Flutter باید به WebView ارسال کند تا صفحه به‌درستی نمایش داده شود را مستند می‌کند.
---
## 📊 ۱. Responsive Breakpoints
### تعریف در Flutter:
```dart
// lib/app/application.dart (خط 123-126)
ResponsiveBreakpoints.builder(
breakpoints: const [
Breakpoint(start: 0, end: 800, name: MOBILE),
Breakpoint(start: 800, end: 1600, name: TABLET),
],
)
```
### استفاده در کد:
```dart
// lib/core/util/extensions/context_extensions.dart
bool get isMobile => breakpoints.isMobile; // width < 800
bool get isTablet => breakpoints.isTablet; // width >= 800
bool get isDesktop => breakpoints.isDesktop; // width >= 1600 (غیرفعال)
double get screenWidth => breakpoints.screenWidth;
```
### ✅ باید به WebView ارسال شود:
```json
{
"layout": {
"breakpoint": "mobile" | "tablet" | "desktop",
"screenWidth": 375,
"isMobile": true,
"isTablet": false
}
}
```
### 📱 تاثیر در UI:
| بخش | Mobile (< 800px) | Tablet (>= 800px) |
|-----|-----------------|-------------------|
| **Padding افقی** | `16px` | `64px` |
| **Search Bar** | عرض کامل | عرض محدود با padding 64px |
| **Chat List** | Single column | Single column با padding 64px |
| **TabBar** | Centered، غیر اسکرول | Left-aligned، scrollable |
| **Calendar Grid** | `width / 120` columns | `width / 120` columns |
| **Chat TextField** | padding: 16px | padding: 64px horizontal |
---
## 📏 ۲. Safe Area & System Insets
### تعریف در Flutter:
```dart
// lib/core/util/extensions/context_extensions.dart
MediaQueryData get mq => MediaQuery.of(context);
EdgeInsets get insets => mq.viewInsets; // Keyboard, etc
EdgeInsets get padding => mq.viewPadding; // Status bar, notch, home indicator
double get navigationBarHeight => max(insets.bottom, padding.bottom);
```
### ✅ باید به WebView ارسال شود:
```json
{
"safeArea": {
"top": 44, // Status bar / notch
"bottom": 34, // Home indicator (iPhone X+)
"left": 0,
"right": 0
},
"viewInsets": {
"top": 0,
"bottom": 336, // Keyboard height (تغییر می‌کند)
"left": 0,
"right": 0
},
"navigationBarHeight": 34 // max(viewInsets.bottom, safeArea.bottom)
}
```
### 📱 تاثیر در UI:
```dart
// مثال‌های استفاده در کد:
// 1. Chat Page - Floating Action Button
bottom: 16 + context.padding.bottom, // +34px on iPhone X
right: context.isMobile ? 16 : 64,
// 2. Chat TextField - Bottom padding
EdgeInsets.only(
bottom: 16 + max(insets.bottom, padding.bottom),
)
// 3. Reviews Tab - List padding
padding.addMore(bottom: context.padding.bottom),
// 4. Call Screen - Bottom spacing
SizedBox(height: 16 + context.padding.bottom),
// 5. Become Provider Page - Top padding
SizedBox(height: context.padding.top + 8),
```
### 🎨 پیاده‌سازی در React:
```tsx
// استفاده از CSS env() برای safe-area
<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

9
src/app/intro/page.tsx

@ -115,7 +115,14 @@ export default function Intro() {
}
}
const profileResponse = profile ?? (await refetch()).data;
// Fetch profile (query is initialized with enabled: false, so refetch is needed)
const { data: freshProfile } = await refetch();
const profileResponse = freshProfile ?? profile;
if (!profileResponse) {
console.warn("Could not load profile data – using default path");
}
const nextPath = localizePath(getSubmitPath(profileResponse), locale);
router.push(nextPath);
} catch (error) {

145
src/components/questions/question-file.tsx

@ -1,9 +1,10 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media";
import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
@ -13,12 +14,36 @@ type QuestionFileProps = {
disabled?: boolean;
};
/** Map question file extensions to upload_file mediaType. */
function resolveMediaType(
extensions: string[],
): "image" | "video" | "audio" | "file" {
const lower = extensions.map((e) => e.replace(/^\./, "").toLowerCase());
const allImages = lower.every((e) =>
["jpg", "jpeg", "png", "webp", "gif", "bmp", "svg"].includes(e),
);
if (allImages) return "image";
const allVideo = lower.every((e) =>
["mp4", "mov", "avi", "mkv", "webm"].includes(e),
);
if (allVideo) return "video";
const allAudio = lower.every((e) =>
["mp3", "m4a", "wav", "aac", "ogg"].includes(e),
);
if (allAudio) return "audio";
return "file";
}
export function QuestionFile({
question,
questionIndex,
disabled,
}: QuestionFileProps) {
const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
const [isFlutterPicking, setIsFlutterPicking] = useState(false);
const acceptedFiles = question.extras.options
.map((option) => option.replace(/^\./, ""))
.join(", ");
@ -32,7 +57,85 @@ export function QuestionFile({
},
});
function handleFileChange(files: FileList | null) {
const isPending = uploadTmpMediaMutation.isPending || isFlutterPicking;
// Listen for upload_file responses from Flutter
useEffect(() => {
if (!isInFlutterWebView()) return;
const unsubscribe = window.addFlutterResponseListener?.((event) => {
if (event.action !== "upload_file") return;
switch (event.status) {
case "picking":
setIsFlutterPicking(true);
break;
case "picked":
if (event.data?.files?.[0]) {
setSelectedFileName(event.data.files[0].name ?? null);
}
break;
case "progress":
// Could show progress bar – for now just keep pending state
break;
case "completed": {
setIsFlutterPicking(false);
const file = event.data?.files?.[0];
if (file?.url) {
// Flutter uploaded the file – use the returned URL
setAnswerValue(question, questionIndex, file.url);
setSelectedFileName(file.name ?? "uploaded");
} else if (file?.base64) {
// base64 mode – convert to File and use existing mutation
fetch(file.base64)
.then((res) => res.blob())
.then((blob) => {
const f = new File([blob], file.name ?? "upload", {
type: blob.type,
});
setSelectedFileName(f.name);
uploadTmpMediaMutation.mutate(f);
});
}
break;
}
case "cancelled":
setIsFlutterPicking(false);
break;
case "failed":
setIsFlutterPicking(false);
console.error("upload_file failed:", event.message);
break;
}
});
return () => {
unsubscribe?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [question, questionIndex]);
/** Handle file pick in Flutter WebView via upload_file action. */
const handleFlutterPick = useCallback(() => {
const extensions = question.extras.options.map((o) =>
o.replace(/^\./, "").toLowerCase(),
);
const mediaType = resolveMediaType(extensions);
uploadFile({
mediaType,
source: "gallery",
returnAs: "upload",
uploadUrl: `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/upload-tmp-media/`,
fieldName: "file",
allowedExtensions: extensions,
maxBytes: 10_485_760, // 10 MB
title: typeof question.title === "string" ? question.title : "Upload File",
});
}, [question]);
/** Handle file pick via browser <input type="file"> (fallback). */
function handleBrowserFileChange(files: FileList | null) {
const file = files?.[0];
if (!file) {
@ -45,6 +148,8 @@ export function QuestionFile({
uploadTmpMediaMutation.mutate(file);
}
const inWebView = isInFlutterWebView();
return (
<div
className={[
@ -53,14 +158,29 @@ export function QuestionFile({
].join(" ")}
>
<QuestionTitle question={question} />
<span className="relative flex aspect-[727/330] min-h-[156px] w-full cursor-pointer flex-col items-center justify-center rounded-[29px] border-2 border-dashed border-[#8D8D8D] bg-[#F7F7F7] text-center transition-colors duration-200 focus-within:outline-2 focus-within:outline-offset-4 focus-within:outline-[#6F6F6F] hover:border-[#777777]">
<input
type="file"
accept={question.extras.options.join(",")}
disabled={disabled || uploadTmpMediaMutation.isPending}
onChange={(event) => handleFileChange(event.target.files)}
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
/>
<span
className="relative flex aspect-[727/330] min-h-[156px] w-full cursor-pointer flex-col items-center justify-center rounded-[29px] border-2 border-dashed border-[#8D8D8D] bg-[#F7F7F7] text-center transition-colors duration-200 focus-within:outline-2 focus-within:outline-offset-4 focus-within:outline-[#6F6F6F] hover:border-[#777777]"
role={inWebView ? "button" : undefined}
tabIndex={inWebView ? 0 : undefined}
onClick={inWebView ? handleFlutterPick : undefined}
onKeyDown={
inWebView
? (e) => {
if (e.key === "Enter" || e.key === " ") handleFlutterPick();
}
: undefined
}
>
{/* Fallback: browser file input (hidden in WebView) */}
{!inWebView && (
<input
type="file"
accept={question.extras.options.join(",")}
disabled={disabled || isPending}
onChange={(event) => handleBrowserFileChange(event.target.files)}
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
/>
)}
<Image
src="/assets/images/Image.svg"
alt="Upload"
@ -68,9 +188,9 @@ export function QuestionFile({
height={24}
/>
<span className="mt-3 block text-sm leading-none font-normal text-[#111111]">
{uploadTmpMediaMutation.isPending
{isPending
? "uploading..."
: (selectedFileName ?? "uplaod certifacates")}
: (selectedFileName ?? "upload certificates")}
</span>
{uploadTmpMediaMutation.isError ? (
<span className="mt-2 block text-[10px] leading-none font-bold text-[#D44747]">
@ -87,3 +207,4 @@ export function QuestionFile({
}
export default QuestionFile;

65
src/components/ui/report-actions-sheet.tsx

@ -3,6 +3,12 @@
import { useEffect, useState } from "react";
import Button from "@/components/ui/button";
import { useFlutterBridge } from "@/hooks/useFlutterBridge";
import {
downloadFile,
copyToClipboard,
openExternalUrl,
isInFlutterWebView,
} from "@/lib/webview-actions";
const EXIT_ANIMATION_MS = 220;
@ -50,6 +56,37 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
console.log("👨‍⚕️ REQUEST_CONSULTANT ارسال شد");
};
// ✅ تست download_file
const handleTestDownload = () => {
const sent = downloadFile({
url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
fileName: "test-document.pdf",
title: "Test PDF Document",
mimeType: "application/pdf",
fileType: "document",
});
console.log(sent ? "📥 download_file ارسال شد" : "⚠️ Flutter WebView یافت نشد");
};
// ✅ تست copy_to_clipboard
const handleTestCopy = async () => {
const success = await copyToClipboard(
"سلام! این یک متن تست برای کپی است. 🎉",
"test_copy",
);
console.log(success ? "📋 copy_to_clipboard ارسال شد" : "⚠️ کپی انجام نشد");
};
// ✅ تست open_external_url
const handleTestOpenUrl = () => {
const sent = openExternalUrl({
url: "https://habibapp.com",
mode: "externalApplication",
title: "test_open_url",
});
console.log(sent ? "🔗 open_external_url ارسال شد" : "⚠️ Flutter WebView یافت نشد");
};
useEffect(() => {
const frameId = window.requestAnimationFrame(() => {
setIsEntering(false);
@ -84,6 +121,17 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
const message = `Location: ${event.data.latitude}, ${event.data.longitude}`;
alert(message);
}
// Log responses for download/clipboard/external actions
if (event.action === "download_file") {
console.log(`📥 download_file response: ${event.status}`, event);
}
if (event.action === "copy_to_clipboard") {
console.log(`📋 copy_to_clipboard response: ${event.success}`, event);
}
if (event.action === "open_external_url") {
console.log(`🔗 open_external_url response: ${event.status}`, event);
}
};
const unsubscribe = window.addFlutterResponseListener?.(handleFlutterResponse);
@ -146,6 +194,23 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
<Button onClick={handleOpenConsultant}>👨 مشاوره با حبیب</Button>
</div>
{/* دکمه‌های تست WebView Actions */}
<div className="bg-purple-50 rounded-lg p-3 border border-purple-200">
<p className="text-xs text-purple-800 mb-2 font-semibold">
🧪 تست WebView Utility Actions:
</p>
<div className="flex flex-col gap-2">
<Button onClick={handleTestDownload}>📥 download_file</Button>
<Button onClick={handleTestCopy}>📋 copy_to_clipboard</Button>
<Button onClick={handleTestOpenUrl}>🔗 open_external_url</Button>
</div>
{!isInFlutterWebView() && (
<p className="text-[10px] text-purple-600 mt-2">
خارج از WebView هستید copy_to_clipboard با fallback مرورگر کار میکند، بقیه نیاز به Flutter دارند.
</p>
)}
</div>
{/* دکمه تست WEB_READY */}
<div className="bg-blue-50 rounded-lg p-3 border border-blue-200">
<p className="text-xs text-blue-800 mb-2 font-semibold">

146
src/lib/webview-actions.ts

@ -0,0 +1,146 @@
/**
* WebView Utility Actions
*
* Helper module for sending utility actions to Flutter via HabibApp channel.
* Actions: download_file, copy_to_clipboard, open_external_url, upload_file
*
* Docs: WEBVIEW_ACTION_DOWNLOAD.md
*/
// ─── Helpers ─────────────────────────────────────────────
/** Check if running inside Flutter WebView (HabibApp channel available). */
export function isInFlutterWebView(): boolean {
return typeof window !== "undefined" && !!window.HabibApp?.postMessage;
}
/**
* Low-level: send a JSON message to Flutter via HabibApp.postMessage.
* Returns `true` if the message was dispatched, `false` if channel unavailable.
*/
export function postActionToFlutter(
action: string,
data?: Record<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>);
}

26
src/types/window.d.ts

@ -9,6 +9,10 @@ declare global {
interface FlutterResponseEvent {
action: string;
success: boolean;
/** Top-level status for multi-step actions (download_file, upload_file, …) */
status?: string;
/** Top-level error/info message */
message?: string;
data?: {
// get_location
latitude?: number;
@ -42,6 +46,28 @@ declare global {
tablet?: { width?: number; height?: number };
platform?: { os?: string; version?: string };
locale?: { languageCode?: string; isRTL?: boolean; isRtl?: boolean };
// download_file / upload_file progress
progress?: number;
received?: number;
sent?: number;
total?: number;
fileName?: string;
fileType?: string;
mimeType?: string;
// upload_file
mediaType?: string;
source?: string;
files?: Array<{
url?: string;
base64?: string;
name?: string;
size?: number;
mimeType?: string;
}>;
// copy_to_clipboard
label?: string;
// open_external_url
url?: string;
};
}

Loading…
Cancel
Save