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