Browse Source
docs(xmind): add Flutter WebView & PWA integration guide for XMind mind-map page
master
docs(xmind): add Flutter WebView & PWA integration guide for XMind mind-map page
master
1 changed files with 387 additions and 0 deletions
@ -0,0 +1,387 @@ |
|||||
|
# 🧠 راهنمای جامع اتصال و پیادهسازی صفحه XMind (Mind-Map) در اپلیکیشن Flutter |
||||
|
|
||||
|
این مستند فنی برای برنامهنویسان فلاتر (Flutter) آماده شده است تا بتوانند صفحه نمایش نمودار درختی و مایندمپ احادیث و ارگومانها (`/xmind/<category_slug>`) را با بهترین عملکرد، ظاهر بومی (Native-like) و قابلیتهای PWA/WebView در اپلیکیشن پیادهسازی کنند (مشابه معماری و استانداردی که برای صفحه دستیار هوشمند / Agent پیادهسازی شد). |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ۱. معماری و چرایی استفاده از WebView/PWA برای این بخش |
||||
|
صفحه XMind شامل یک گراف تعاملی سنگین و پویا (پیادهسازیشده با موتور گرافی ReactFlow) است که قابلیتهای زیر را ارائه میدهد: |
||||
|
* رندر دوطرفه نمودار درختی (Root Node دستهبندی و شاخههای چپ و راست احادیث) |
||||
|
* زوم، جابهجایی (Pan & Zoom) و فوکوس با انیمیشن روان و ژستهای لمسی |
||||
|
* جستجوی لحظهای روی گرهها و هایلایت کردن خودکار |
||||
|
* تولید فایل استاندارد `.xmind` بر مبنای ZIP و ساختار XML در لحظه (Client-Side Generation) |
||||
|
* کارت اطلاعات تفصیلی ارگومانها با تصاویر نسخ خطی و آدرسها |
||||
|
|
||||
|
برای حفظ یکپارچگی، سرعت توسعه و امکان بهروزرسانی زنده نمودارها بدون نیاز به انتشار نسخه جدید اپ، این صفحه از طریق **WebView پیشرفته همراه با JavaScript Bridge دوطرفه (کانال `HabibApp`)** در فلاتر تعبیه میشود. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ۲. مشخصات URL و پارامترهای ارسالی |
||||
|
|
||||
|
آدرس پایه لود صفحه: |
||||
|
```text |
||||
|
https://dovodi.newhorizonco.uk/xmind/{category_slug}?token={AUTH_TOKEN}&lang={LANG}&theme={THEME}&platform=flutter |
||||
|
``` |
||||
|
|
||||
|
### جدول Query Parameters: |
||||
|
| پارامتر | اجباری | توضیحات و مقادیر | |
||||
|
| :--- | :---: | :--- | |
||||
|
| `category_slug` | بله | اسلاگ دستهبندی موضوعی ارگومانها (مثلاً `prophetic-traditions-3`) | |
||||
|
| `token` | خیر | توکن احراز هویت Bearer کاربر در صورت لاگین بودن جهت نشانگذاری (Bookmark) و دسترسیها | |
||||
|
| `lang` | خیر | زبان فعلی اپلیکیشن (`fa`، `ar`، `en`، `ru`) | |
||||
|
| `theme` | خیر | تم فعال اپ (`light` یا `dark`) | |
||||
|
| `platform` | بله | مقدار ثابت `flutter` جهت مخفیسازی هدر و فوتر عمومی سایت و فعالسازی پل ارتباطی | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ۳. ارسال تنظیمات اولیه و ابعاد صفحه (`flutterConfig`) |
||||
|
|
||||
|
به محض پایان بارگذاری صفحه (`onPageFinished`)، فلاتر باید مشخصات Safe Area و ابعاد صفحه را از طریق رویداد سفارشی `flutterConfig` به WebView ارسال کند تا المانها زیر ناچ گوشی یا دکمههای ناوبری سیستم قرار نگیرند. |
||||
|
|
||||
|
### نمونه کد اجرای جاوااسکریپت در فلاتر: |
||||
|
```dart |
||||
|
void sendInitialConfig(WebViewController controller, BuildContext context) { |
||||
|
final mediaQuery = MediaQuery.of(context); |
||||
|
final isDark = Theme.of(context).brightness == Brightness.dark; |
||||
|
|
||||
|
final config = { |
||||
|
"version": "1.0.0", |
||||
|
"platform": { |
||||
|
"os": Platform.isAndroid ? "android" : "ios", |
||||
|
"isFlutter": true, |
||||
|
}, |
||||
|
"theme": { |
||||
|
"mode": isDark ? "dark" : "light", |
||||
|
}, |
||||
|
"safeArea": { |
||||
|
"top": mediaQuery.padding.top, |
||||
|
"bottom": mediaQuery.padding.bottom, |
||||
|
"left": mediaQuery.padding.left, |
||||
|
"right": mediaQuery.padding.right, |
||||
|
}, |
||||
|
"screen": { |
||||
|
"width": mediaQuery.size.width, |
||||
|
"height": mediaQuery.size.height, |
||||
|
"orientation": mediaQuery.orientation == Orientation.portrait ? "portrait" : "landscape", |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
final jsonStr = jsonEncode(config); |
||||
|
controller.runJavaScript(''' |
||||
|
window.dispatchEvent(new CustomEvent('flutterConfig', { |
||||
|
detail: $jsonStr |
||||
|
})); |
||||
|
'''); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ۴. پل ارتباطی دوطرفه (JavaScript Bridge - `HabibApp`) |
||||
|
|
||||
|
مشابه صفحه Agent، فلاتر یک کانال جاوااسکریپت با نام `HabibApp` (یا `FlutterChannel`) روی WebView رجیستر میکند تا رویدادها و درخواستهای بومی سمت وب را دریافت نماید: |
||||
|
|
||||
|
```dart |
||||
|
final channel = JavaScriptChannel( |
||||
|
'HabibApp', |
||||
|
onMessageReceived: (JavaScriptMessage message) { |
||||
|
handleWebMessage(message.message); |
||||
|
}, |
||||
|
); |
||||
|
``` |
||||
|
|
||||
|
### ساختار استاندارد پیامهای ارسالی از وب به فلاتر: |
||||
|
```json |
||||
|
{ |
||||
|
"action": "close_webview | download_file | copy_to_clipboard | open_external_url", |
||||
|
"data": { ... } |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### جدول اکشنهای ارسالی از وب به فلاتر: |
||||
|
|
||||
|
| نام Action | توضیحات | ساختار Data | عملکرد مورد انتظار در فلاتر | |
||||
|
| :--- | :--- | :--- | :--- | |
||||
|
| `close_webview` | کاربر روی دکمه خروج / بازگشت در نوار ابزار مایندمپ کلیک کرد | `{}` | بستن صفحه و بازگشت به صفحه قبل (`Navigator.pop(context)`) | |
||||
|
| `download_file` | کاربر دکمه دانلود فایل مایندمپ `.xmind` را زد | `{"url": "...", "fileName": "MindMap.xmind", "mimeType": "application/zip", "fileType": "archive"}` | دانلود و ذخیره محلی فایل در پوشه Downloads گوشی | |
||||
|
| `copy_to_clipboard` | کپی متن یا آدرس حدیث | `{"text": "...", "label": "...", "showToast": true}` | کپی در کلیپبورد گوشی + نمایش فیدبک لمسی (HapticFeedback) | |
||||
|
| `open_external_url` | کلیک روی لینکهای خارجی منبع | `{"url": "...", "mode": "externalApplication"}` | باز کردن لینک در مرورگر خارجی یا تب جدید | |
||||
|
| `share_content` | اشتراکگذاری مایندمپ یا متن حدیث | `{"title": "...", "text": "...", "url": "..."}` | باز کردن دیالوگ اشتراکگذاری بومی سیستمعامل (`share_plus`) | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ۵. رویدادهای ارسالی از فلاتر به وب (`flutterEvent`) |
||||
|
|
||||
|
هر زمان که وضعیت سیستم تغییر کرد (مثل چرخش گوشی یا تغییر تم)، فلاتر متد زیر را روی کنترلر اجرا میکند: |
||||
|
|
||||
|
```dart |
||||
|
void sendEventToWeb(WebViewController controller, String type, Map<String, dynamic> payload) { |
||||
|
final event = { |
||||
|
"type": type, |
||||
|
"payload": payload, |
||||
|
}; |
||||
|
final jsonStr = jsonEncode(event); |
||||
|
controller.runJavaScript(''' |
||||
|
window.dispatchEvent(new CustomEvent('flutterEvent', { |
||||
|
detail: $jsonStr |
||||
|
})); |
||||
|
'''); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### نمونه رویدادها: |
||||
|
1. **چرخش گوشی (`ORIENTATION_CHANGED`):** |
||||
|
```dart |
||||
|
sendEventToWeb(controller, "ORIENTATION_CHANGED", { |
||||
|
"orientation": isPortrait ? "portrait" : "landscape", |
||||
|
"width": size.width, |
||||
|
"height": size.height, |
||||
|
}); |
||||
|
``` |
||||
|
2. **تغییر تم اپ (`THEME_CHANGED`):** |
||||
|
```dart |
||||
|
sendEventToWeb(controller, "THEME_CHANGED", { |
||||
|
"mode": isDark ? "dark" : "light", |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ۶. پیشنهادات UX و چرخش صفحه در فلاتر |
||||
|
|
||||
|
> [!TIP] |
||||
|
> **پیشنهاد بهینهسازی تجربه کاربری (Orientation):** |
||||
|
> با توجه به اینکه نمودارهای درختی و Mind-Map در حالت افقی (Landscape) بیشترین فضای نمایش را دارند، پیشنهاد میشود هنگام ورود به این صفحه: |
||||
|
> 1. جهت صفحه به صورت آزاد (هم Portrait و هم Landscape) مجاز باشد: |
||||
|
> ```dart |
||||
|
> SystemChrome.setPreferredOrientations([ |
||||
|
> DeviceOrientation.portraitUp, |
||||
|
> DeviceOrientation.landscapeLeft, |
||||
|
> DeviceOrientation.landscapeRight, |
||||
|
> ]); |
||||
|
> ``` |
||||
|
> 2. هنگام خروج از صفحه (`dispose`)، تنظیمات جهت صفحه به حالت پیشفرض اپلیکیشن بازگردد. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ۷. نمونه کد کامل پیادهسازی صفحه در Flutter (`webview_flutter`) |
||||
|
|
||||
|
```dart |
||||
|
import 'dart:convert'; |
||||
|
import 'dart:io'; |
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
import 'package:webview_flutter/webview_flutter.dart'; |
||||
|
import 'package:share_plus/share_plus.dart'; |
||||
|
|
||||
|
class XMindMindMapScreen extends StatefulWidget { |
||||
|
final String categorySlug; |
||||
|
final String? authToken; |
||||
|
final String currentLang; |
||||
|
|
||||
|
const XMindMindMapScreen({ |
||||
|
Key? key, |
||||
|
required this.categorySlug, |
||||
|
this.authToken, |
||||
|
this.currentLang = 'fa', |
||||
|
}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
State<XMindMindMapScreen> createState() => _XMindMindMapScreenState(); |
||||
|
} |
||||
|
|
||||
|
class _XMindMindMapScreenState extends State<XMindMindMapScreen> { |
||||
|
late final WebViewController _controller; |
||||
|
bool _isLoading = true; |
||||
|
double _loadingProgress = 0; |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
// Allow both portrait and landscape orientations for better mindmap view |
||||
|
SystemChrome.setPreferredOrientations([ |
||||
|
DeviceOrientation.portraitUp, |
||||
|
DeviceOrientation.landscapeLeft, |
||||
|
DeviceOrientation.landscapeRight, |
||||
|
]); |
||||
|
_initWebView(); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void dispose() { |
||||
|
// Reset back to portrait when leaving the screen |
||||
|
SystemChrome.setPreferredOrientations([ |
||||
|
DeviceOrientation.portraitUp, |
||||
|
]); |
||||
|
super.dispose(); |
||||
|
} |
||||
|
|
||||
|
void _initWebView() { |
||||
|
final isDark = false; // Or fetch from ThemeProvider |
||||
|
final baseUri = Uri.parse('https://dovodi.newhorizonco.uk/xmind/${widget.categorySlug}'); |
||||
|
|
||||
|
final queryParams = <String, String>{ |
||||
|
'platform': 'flutter', |
||||
|
'lang': widget.currentLang, |
||||
|
'theme': isDark ? 'dark' : 'light', |
||||
|
}; |
||||
|
if (widget.authToken != null && widget.authToken!.isNotEmpty) { |
||||
|
queryParams['token'] = widget.authToken!; |
||||
|
} |
||||
|
|
||||
|
final finalUri = baseUri.replace(queryParameters: queryParams); |
||||
|
|
||||
|
_controller = WebViewController() |
||||
|
..setJavaScriptMode(JavaScriptMode.unrestricted) |
||||
|
..setBackgroundColor(const Color(0xFFF8FAFC)) |
||||
|
..addJavaScriptChannel( |
||||
|
'HabibApp', |
||||
|
onMessageReceived: (JavaScriptMessage message) { |
||||
|
_handleWebMessage(message.message); |
||||
|
}, |
||||
|
) |
||||
|
..setNavigationDelegate( |
||||
|
NavigationDelegate( |
||||
|
onProgress: (int progress) { |
||||
|
setState(() { |
||||
|
_loadingProgress = progress / 100.0; |
||||
|
}); |
||||
|
}, |
||||
|
onPageStarted: (String url) { |
||||
|
setState(() => _isLoading = true); |
||||
|
}, |
||||
|
onPageFinished: (String url) { |
||||
|
setState(() => _isLoading = false); |
||||
|
_sendInitialConfig(); |
||||
|
}, |
||||
|
), |
||||
|
) |
||||
|
..loadRequest(finalUri); |
||||
|
} |
||||
|
|
||||
|
void _sendInitialConfig() { |
||||
|
final mediaQuery = MediaQuery.of(context); |
||||
|
final isDark = Theme.of(context).brightness == Brightness.dark; |
||||
|
|
||||
|
final config = { |
||||
|
"version": "1.0.0", |
||||
|
"platform": { |
||||
|
"os": Platform.isAndroid ? "android" : "ios", |
||||
|
"isFlutter": true, |
||||
|
}, |
||||
|
"theme": { |
||||
|
"mode": isDark ? "dark" : "light", |
||||
|
}, |
||||
|
"safeArea": { |
||||
|
"top": mediaQuery.padding.top, |
||||
|
"bottom": mediaQuery.padding.bottom, |
||||
|
"left": mediaQuery.padding.left, |
||||
|
"right": mediaQuery.padding.right, |
||||
|
}, |
||||
|
"screen": { |
||||
|
"width": mediaQuery.size.width, |
||||
|
"height": mediaQuery.size.height, |
||||
|
"orientation": mediaQuery.orientation == Orientation.portrait ? "portrait" : "landscape", |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
_controller.runJavaScript(''' |
||||
|
window.dispatchEvent(new CustomEvent('flutterConfig', { |
||||
|
detail: ${jsonEncode(config)} |
||||
|
})); |
||||
|
'''); |
||||
|
} |
||||
|
|
||||
|
void _handleWebMessage(String rawJson) { |
||||
|
try { |
||||
|
final data = jsonDecode(rawJson) as Map<String, dynamic>; |
||||
|
final action = data['action'] as String?; |
||||
|
final payload = (data['data'] ?? data['payload'] ?? {}) as Map<String, dynamic>; |
||||
|
|
||||
|
switch (action) { |
||||
|
case 'close_webview': |
||||
|
case 'CLOSE_XMIND_VIEW': |
||||
|
Navigator.of(context).pop(); |
||||
|
break; |
||||
|
|
||||
|
case 'share_content': |
||||
|
case 'SHARE_CONTENT': |
||||
|
final text = payload['text'] as String? ?? ''; |
||||
|
final url = payload['url'] as String? ?? ''; |
||||
|
final title = payload['title'] as String? ?? ''; |
||||
|
Share.share('$title\n$text\n$url'); |
||||
|
break; |
||||
|
|
||||
|
case 'copy_to_clipboard': |
||||
|
case 'COPY_TO_CLIPBOARD': |
||||
|
final text = payload['text'] as String? ?? ''; |
||||
|
if (text.isNotEmpty) { |
||||
|
Clipboard.setData(ClipboardData(text: text)); |
||||
|
HapticFeedback.lightImpact(); |
||||
|
ScaffoldMessenger.of(context).showSnackBar( |
||||
|
const SnackBar(content: Text('کپی شد')), |
||||
|
); |
||||
|
} |
||||
|
break; |
||||
|
|
||||
|
case 'download_file': |
||||
|
case 'DOWNLOAD_XMIND': |
||||
|
final url = payload['url'] as String? ?? ''; |
||||
|
final fileName = payload['fileName'] as String? ?? 'MindMap.xmind'; |
||||
|
ScaffoldMessenger.of(context).showSnackBar( |
||||
|
SnackBar(content: Text('در حال دانلود $fileName...')), |
||||
|
); |
||||
|
break; |
||||
|
|
||||
|
default: |
||||
|
debugPrint('[WebViewBridge] Unhandled action: $action'); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
debugPrint('[WebViewBridge] Error parsing message: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return WillPopScope( |
||||
|
onWillPop: () async { |
||||
|
if (await _controller.canGoBack()) { |
||||
|
_controller.goBack(); |
||||
|
return false; |
||||
|
} |
||||
|
return true; |
||||
|
}, |
||||
|
child: Scaffold( |
||||
|
backgroundColor: const Color(0xFFF8FAFC), |
||||
|
body: SafeArea( |
||||
|
bottom: false, |
||||
|
child: Stack( |
||||
|
children: [ |
||||
|
WebViewWidget(controller: _controller), |
||||
|
if (_isLoading) |
||||
|
LinearProgressIndicator( |
||||
|
value: _loadingProgress, |
||||
|
backgroundColor: Colors.transparent, |
||||
|
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFF5172E1)), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## ۸. چکلیست نهایی تست برای توسعهدهنده فلاتر |
||||
|
|
||||
|
- [ ] ارسال پارامتر `platform=flutter` در URL برای پنهان کردن خودکار هدر و فوتر عمومی سایت. |
||||
|
- [ ] تست باز شدن مایندمپ و پیمایش لمسی (Pinch to Zoom و Pan). |
||||
|
- [ ] تست چرخش صفحه به حالت افقی (Landscape) جهت مشاهده بهینه شاخههای درختی. |
||||
|
- [ ] تست دریافت رویداد `close_webview` و برگشت سریع به صفحه دستهبندی در فلاتر. |
||||
|
- [ ] تست اشتراکگذاری محتوای کارت با `share_content`. |
||||
|
- [ ] تست ارسال توکن لاگین جهت فعال بودن نشانگذاریها (Bookmarks). |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue