interface ViewPaddings { top: number; bottom: number; left: number; right: number; } interface LayoutInfo { breakpoint: string; screenWidth: number; screenHeight: number; isMobile: boolean; isTablet: boolean; isDesktop: boolean; } interface PlatformInfo { os: string; version: string; } interface LocaleInfo { languageCode: string; isRTL: boolean; } interface InitialConfig { paddings: ViewPaddings; viewInsets: ViewPaddings; safeArea: ViewPaddings; keyboardHeight: number; layout: LayoutInfo | null; platform: PlatformInfo | null; locale: LocaleInfo | null; } type EdgeSource = | { top?: number; bottom?: number; left?: number; right?: number; } | null | undefined; function num(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? value : 0; } function safeParse(value: string): unknown { try { return JSON.parse(value); } catch { return null; } } function getEventData(event: unknown): Record | null { if (!event || typeof event !== "object") return null; const envelope = event as Record; const rawData = envelope.data ?? envelope.payload; if (!rawData || typeof rawData !== "object") return null; return rawData as Record; } function readEdges(source: EdgeSource): ViewPaddings { return { top: num(source?.top), bottom: num(source?.bottom), left: num(source?.left), right: num(source?.right), }; } /** * مقادیر safe-area در `initial_config` (فیلد safeArea) از فلاتر در واحد منطقی * (CSS px / dp) می‌آیند و مستقیماً برای CSS درست‌اند. اما مسیر قدیمی * `get_view_paddings` مقادیر را در پیکسل فیزیکی (× devicePixelRatio) می‌فرستد؛ * این تابع آن‌ها را به px منطقی برمی‌گرداند تا با CSS هم‌خوان شوند. */ function toLogical(edges: ViewPaddings): ViewPaddings { const ratio = typeof window !== "undefined" && window.devicePixelRatio > 0 ? window.devicePixelRatio : 1; return { top: edges.top / ratio, bottom: edges.bottom / ratio, left: edges.left / ratio, right: edges.right / ratio, }; } class ViewPaddingsBridge { private paddings: ViewPaddings = { top: 0, bottom: 0, left: 0, right: 0 }; private config: InitialConfig = { paddings: { top: 0, bottom: 0, left: 0, right: 0 }, viewInsets: { top: 0, bottom: 0, left: 0, right: 0 }, safeArea: { top: 0, bottom: 0, left: 0, right: 0 }, keyboardHeight: 0, layout: null, platform: null, locale: null, }; private listeners: Array<(paddings: ViewPaddings) => void> = []; private configListeners: Array<(config: InitialConfig) => void> = []; private hasInitialConfig = false; constructor() { if (typeof window !== "undefined" && window.__HABIB_BOOTSTRAP__) { this.applyInitialConfig(window.__HABIB_BOOTSTRAP__); this.hasInitialConfig = true; } this.init(); } private init() { if (typeof window === "undefined") return; this.setupFlutterListener(); this.setupConfigEventListener(); } // مسیر جایگزین: برخی نسخه‌های اپ به‌جای onFlutterResponse، کانفیگ را با // CustomEvent('flutterConfig') یا postMessage می‌فرستند (طبق سند پل). // payload در این مسیر همان بدنه‌ی initial_config است (بدون پوشش action/data). private setupConfigEventListener() { const handle = (raw: unknown) => { if (!raw || typeof raw !== "object") return; const envelope = raw as Record; const data = (envelope.payload ?? envelope.data ?? envelope) as Record< string, any >; if (this.hasInitialConfig) { if (data.locale) this.applyLocale(data.locale); return; } if (data.safeArea || data.viewInsets) { this.applyInitialConfig(data); this.hasInitialConfig = true; } else if (data.locale) { this.applyLocale(data.locale); } }; window.addEventListener("flutterConfig", (event) => { handle((event as CustomEvent).detail); }); window.addEventListener("message", (event) => { const data = typeof event.data === "string" ? safeParse(event.data) : event.data; handle(data); }); } private setupFlutterListener() { const win = window as Window & { addFlutterResponseListener?: ( listener: (event: any) => void, ) => () => void; }; const attach = () => { if (typeof win.addFlutterResponseListener !== "function") return false; win.addFlutterResponseListener((event) => { if (!event || event.success === false) return; // Flutter clients use both `data` and `payload` for bridge responses. // In particular, the uppercase INITIAL_CONFIG protocol uses `payload`. // Keep the locale in that response so the route can be localized as soon // as the WebView opens. const data = getEventData(event); if (!data) return; switch (event.action) { case "initial_config": case "INITIAL_CONFIG": this.applyInitialConfig(data); this.hasInitialConfig = true; return; case "locale_changed": case "language_changed": case "LOCALE_CHANGED": case "LANGUAGE_CHANGED": { this.applyLocale(data?.locale ?? data); return; } // به‌روزرسانی فضای امن هنگام چرخش/تغییر notch (px منطقی). case "safe_area_changed": this.applySafeArea(readEdges(data)); return; // ارتفاع کیبورد به‌صورت زنده (px منطقی). case "keyboard_changed": { const height = num(data.height); this.config = { ...this.config, keyboardHeight: height }; this.applyKeyboard(height); this.notifyConfigListeners(); return; } // سازگاری عقب‌رو: پاسخ ساده‌ی get_view_paddings (px فیزیکی، فقط لبه‌ها). // اگر initial_config رسیده باشد، آن مرجع است و این نادیده گرفته می‌شود. case "get_view_paddings": if (this.hasInitialConfig) return; this.applyEdges(toLogical(readEdges(data))); return; default: return; } }); return true; }; if (!attach()) { const interval = setInterval(() => { if (attach()) clearInterval(interval); }, 50); } } private applyInitialConfig(data: any) { const viewInsets = readEdges(data?.viewInsets); const safeArea = readEdges(data?.safeArea); // فیلد safeArea از فلاتر در px منطقی است و واحد درست برای CSS؛ همین مبنا // برای --safe-* است. (فیلد viewInsets در این payload در px فیزیکی و عملاً // تکرار همان viewPadding است، پس در محاسبه‌ی فضای امن استفاده نمی‌شود.) const paddings = safeArea; this.config = { paddings, viewInsets, safeArea, keyboardHeight: 0, layout: data?.layout ? { breakpoint: String(data.layout.breakpoint ?? ""), screenWidth: num(data.layout.screenWidth), screenHeight: num(data.layout.screenHeight), isMobile: Boolean(data.layout.isMobile), isTablet: Boolean(data.layout.isTablet), isDesktop: Boolean(data.layout.isDesktop), } : null, platform: data?.platform ? { os: String(data.platform.os ?? ""), version: String(data.platform.version ?? ""), } : null, locale: data?.locale ? { languageCode: String(data.locale.languageCode ?? ""), isRTL: Boolean(data.locale.isRTL ?? data.locale.isRtl), } : null, }; this.applyEdges(paddings); this.applyKeyboard(0); this.notifyConfigListeners(); } private applySafeArea(safeArea: ViewPaddings) { this.config = { ...this.config, safeArea, paddings: safeArea, }; this.applyEdges(safeArea); this.notifyConfigListeners(); } private applyLocale(locale: any) { if (!locale) return; this.config = { ...this.config, locale: { languageCode: String(locale.languageCode ?? locale.language_code ?? ""), isRTL: Boolean(locale.isRTL ?? locale.isRtl ?? locale.is_rtl), }, }; this.notifyConfigListeners(); } private applyEdges(paddings: ViewPaddings) { this.paddings = paddings; this.applyPaddings(); this.notifyListeners(); } private applyPaddings() { if (typeof document === "undefined") return; const root = document.documentElement.style; root.setProperty("--safe-top", `${this.paddings.top}px`); root.setProperty("--safe-bottom", `${this.paddings.bottom}px`); root.setProperty("--safe-left", `${this.paddings.left}px`); root.setProperty("--safe-right", `${this.paddings.right}px`); } private applyKeyboard(height: number) { if (typeof document === "undefined") return; document.documentElement.style.setProperty("--kb-height", `${height}px`); } private notifyListeners() { this.listeners.forEach((listener) => { listener(this.paddings); }); } private notifyConfigListeners() { this.configListeners.forEach((listener) => { listener(this.getConfig()); }); } public getPaddings(): ViewPaddings { return { ...this.paddings }; } public getConfig(): InitialConfig { return { ...this.config, paddings: { ...this.config.paddings }, viewInsets: { ...this.config.viewInsets }, safeArea: { ...this.config.safeArea }, }; } public subscribe(listener: (paddings: ViewPaddings) => void): () => void { this.listeners.push(listener); return () => { const index = this.listeners.indexOf(listener); if (index >= 0) this.listeners.splice(index, 1); }; } public subscribeConfig( listener: (config: InitialConfig) => void, ): () => void { this.configListeners.push(listener); return () => { const index = this.configListeners.indexOf(listener); if (index >= 0) this.configListeners.splice(index, 1); }; } } export type { ViewPaddings, InitialConfig, LayoutInfo, PlatformInfo, LocaleInfo, }; export const viewPaddingsBridge = new ViewPaddingsBridge();