You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

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
    }));
  ''');
}

نمونه رویدادها:

  1. چرخش گوشی (ORIENTATION_CHANGED):
    sendEventToWeb(controller, "ORIENTATION_CHANGED", {
      "orientation": isPortrait ? "portrait" : "landscape",
      "width": size.width,
      "height": size.height,
    });
    
  2. تغییر تم اپ (THEME_CHANGED):
    sendEventToWeb(controller, "THEME_CHANGED", {
      "mode": isDark ? "dark" : "light",
    });
    

۶. پیشنهادات UX و چرخش صفحه در فلاتر

[!TIP] پیشنهاد بهینه‌سازی تجربه کاربری (Orientation): با توجه به اینکه نمودارهای درختی و Mind-Map در حالت افقی (Landscape) بیشترین فضای نمایش را دارند، پیشنهاد می‌شود هنگام ورود به این صفحه:

  1. جهت صفحه به صورت آزاد (هم Portrait و هم Landscape) مجاز باشد:
    SystemChrome.setPreferredOrientations([
      DeviceOrientation.portraitUp,
      DeviceOrientation.landscapeLeft,
      DeviceOrientation.landscapeRight,
    ]);
    
  2. هنگام خروج از صفحه (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).