diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9ebb39a..6b7b0ea 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -147,6 +147,7 @@ export default function RootLayout({ dangerouslySetInnerHTML={{ __html: ` if (typeof window !== 'undefined' && window.HabibApp) { + window.HabibApp.postMessage(JSON.stringify({ action: 'web_ready' })); window.HabibApp.postMessage(JSON.stringify({ action: 'get_view_paddings' })); } `, diff --git a/src/hooks/use-view-paddings.ts b/src/hooks/use-view-paddings.ts index c14167e..07124b6 100644 --- a/src/hooks/use-view-paddings.ts +++ b/src/hooks/use-view-paddings.ts @@ -1,10 +1,12 @@ "use client"; import { useEffect, useState } from "react"; -import { viewPaddingsBridge } from "@/lib/view-paddings"; +import { type InitialConfig, viewPaddingsBridge } from "@/lib/view-paddings"; export function useViewPaddings() { - const [paddings, setPaddings] = useState(() => viewPaddingsBridge.getPaddings()); + const [paddings, setPaddings] = useState(() => + viewPaddingsBridge.getPaddings(), + ); useEffect(() => { return viewPaddingsBridge.subscribe(setPaddings); @@ -12,3 +14,13 @@ export function useViewPaddings() { return paddings; } + +export function useFlutterConfig(): InitialConfig { + const [config, setConfig] = useState(() => viewPaddingsBridge.getConfig()); + + useEffect(() => { + return viewPaddingsBridge.subscribeConfig(setConfig); + }, []); + + return config; +} diff --git a/src/lib/view-paddings.ts b/src/lib/view-paddings.ts index e388928..8aa7145 100644 --- a/src/lib/view-paddings.ts +++ b/src/lib/view-paddings.ts @@ -5,9 +5,83 @@ interface ViewPaddings { 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 readEdges(source: EdgeSource): ViewPaddings { + return { + top: num(source?.top), + bottom: num(source?.bottom), + left: num(source?.left), + right: num(source?.right), + }; +} + +/** + * فضای امن هر ضلع = بیشینه‌ی viewInsets و safeArea همان ضلع. + * منطبق بر منطق مستند: navigationBarHeight = max(insets, padding). + * هیچ عدد ثابتی استفاده نمی‌شود؛ همه‌چیز از دیتای دریافتی محاسبه می‌شود. + */ +function mergeEdges(insets: ViewPaddings, safe: ViewPaddings): ViewPaddings { + return { + top: Math.max(insets.top, safe.top), + bottom: Math.max(insets.bottom, safe.bottom), + left: Math.max(insets.left, safe.left), + right: Math.max(insets.right, safe.right), + }; +} + 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 flutterUnsubscribe?: () => void; constructor() { @@ -18,7 +92,7 @@ class ViewPaddingsBridge { if (typeof window === "undefined") return; this.setupFlutterListener(); - this.requestPaddings(); + this.requestConfig(); } private setupFlutterListener() { @@ -32,15 +106,16 @@ class ViewPaddingsBridge { if (typeof win.addFlutterResponseListener !== "function") return false; this.flutterUnsubscribe = win.addFlutterResponseListener((event) => { - if (event.action === "get_view_paddings" && event.success && event.data) { - this.paddings = { - top: event.data.top || 0, - bottom: event.data.bottom || 0, - left: event.data.left || 0, - right: event.data.right || 0, - }; - this.applyPaddings(); - this.notifyListeners(); + if (!event || event.success === false || !event.data) return; + + if (event.action === "initial_config") { + this.applyInitialConfig(event.data); + return; + } + + // سازگاری عقب‌رو: پاسخ ساده‌ی get_view_paddings که فقط لبه‌ها را دارد + if (event.action === "get_view_paddings") { + this.applyEdges(readEdges(event.data)); } }); @@ -54,32 +129,99 @@ class ViewPaddingsBridge { } } - private requestPaddings() { + private requestConfig() { if (typeof window === "undefined") return; const app = (window as any).HabibApp; if (app?.postMessage) { + // اعلام آمادگی وب؛ فلاتر با initial_config پاسخ می‌دهد. + app.postMessage(JSON.stringify({ action: "web_ready" })); + // درخواست سازگاری عقب‌رو برای نسخه‌های قدیمی‌تر اپ. app.postMessage(JSON.stringify({ action: "get_view_paddings" })); } } + private applyInitialConfig(data: any) { + const viewInsets = readEdges(data?.viewInsets); + const safeArea = readEdges(data?.safeArea); + const paddings = mergeEdges(viewInsets, safeArea); + + this.config = { + paddings, + viewInsets, + safeArea, + keyboardHeight: viewInsets.bottom, + 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(viewInsets.bottom); + this.notifyConfigListeners(); + } + + private applyEdges(paddings: ViewPaddings) { + this.paddings = paddings; + this.applyPaddings(); + this.notifyListeners(); + } + private applyPaddings() { if (typeof document === "undefined") return; - document.documentElement.style.setProperty("--safe-top", `${this.paddings.top}px`); - document.documentElement.style.setProperty("--safe-bottom", `${this.paddings.bottom}px`); - document.documentElement.style.setProperty("--safe-left", `${this.paddings.left}px`); - document.documentElement.style.setProperty("--safe-right", `${this.paddings.right}px`); + 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.config)); + } + 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 () => { @@ -87,6 +229,17 @@ class ViewPaddingsBridge { 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(); diff --git a/src/types/window.d.ts b/src/types/window.d.ts index b3f4630..caf17f7 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -1,16 +1,42 @@ declare global { + interface FlutterEdgeInsets { + top?: number; + bottom?: number; + left?: number; + right?: number; + } + interface FlutterResponseEvent { action: string; success: boolean; - data?: { - latitude?: number; + data?: { + // get_location + latitude?: number; longitude?: number; + // get_view_paddings (flat edges) top?: number; bottom?: number; left?: number; right?: number; message?: string; timestamp?: number; + // initial_config + version?: string; + buildNumber?: string; + viewInsets?: FlutterEdgeInsets; + safeArea?: FlutterEdgeInsets; + layout?: { + breakpoint?: string; + screenWidth?: number; + screenHeight?: number; + isMobile?: boolean; + isTablet?: boolean; + isDesktop?: boolean; + }; + designSize?: { width?: number; height?: number }; + tablet?: { width?: number; height?: number }; + platform?: { os?: string; version?: string }; + locale?: { languageCode?: string; isRTL?: boolean; isRtl?: boolean }; }; } diff --git a/src/view-paddings.ts b/src/view-paddings.ts index 6017712..193cfb1 100644 --- a/src/view-paddings.ts +++ b/src/view-paddings.ts @@ -1,3 +1,7 @@ -export { useViewPaddings } from "./hooks/use-view-paddings"; -export { viewPaddingsBridge } from "./lib/view-paddings"; +export { useFlutterConfig, useViewPaddings } from "./hooks/use-view-paddings"; +export { + type InitialConfig, + type ViewPaddings, + viewPaddingsBridge, +} from "./lib/view-paddings"; export { FixedBottom } from "./components/utils/fixed-bottom";