15 KiB
🧠 راهنمای جامع اتصال و پیادهسازی صفحه 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 و پارامترهای ارسالی
آدرس پایه لود صفحه:
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 ارسال کند تا المانها زیر ناچ گوشی یا دکمههای ناوبری سیستم قرار نگیرند.
نمونه کد اجرای جاوااسکریپت در فلاتر:
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 رجیستر میکند تا رویدادها و درخواستهای بومی سمت وب را دریافت نماید:
final channel = JavaScriptChannel(
'HabibApp',
onMessageReceived: (JavaScriptMessage message) {
handleWebMessage(message.message);
},
);
ساختار استاندارد پیامهای ارسالی از وب به فلاتر:
{
"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)
هر زمان که وضعیت سیستم تغییر کرد (مثل چرخش گوشی یا تغییر تم)، فلاتر متد زیر را روی کنترلر اجرا میکند:
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
}));
''');
}
نمونه رویدادها:
- چرخش گوشی (
ORIENTATION_CHANGED):sendEventToWeb(controller, "ORIENTATION_CHANGED", { "orientation": isPortrait ? "portrait" : "landscape", "width": size.width, "height": size.height, }); - تغییر تم اپ (
THEME_CHANGED):sendEventToWeb(controller, "THEME_CHANGED", { "mode": isDark ? "dark" : "light", });
۶. پیشنهادات UX و چرخش صفحه در فلاتر
[!TIP] پیشنهاد بهینهسازی تجربه کاربری (Orientation): با توجه به اینکه نمودارهای درختی و Mind-Map در حالت افقی (Landscape) بیشترین فضای نمایش را دارند، پیشنهاد میشود هنگام ورود به این صفحه:
- جهت صفحه به صورت آزاد (هم Portrait و هم Landscape) مجاز باشد:
SystemChrome.setPreferredOrientations([ DeviceOrientation.portraitUp, DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ]);- هنگام خروج از صفحه (
dispose)، تنظیمات جهت صفحه به حالت پیشفرض اپلیکیشن بازگردد.
۷. نمونه کد کامل پیادهسازی صفحه در Flutter (webview_flutter)
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).