Browse Source

feat(webview): support Flutter initial_config handshake and dynamic safe area insets

master
mortezaei 2 weeks ago
parent
commit
8d453b6d59
  1. 1
      src/app/layout.tsx
  2. 16
      src/hooks/use-view-paddings.ts
  3. 183
      src/lib/view-paddings.ts
  4. 26
      src/types/window.d.ts
  5. 8
      src/view-paddings.ts

1
src/app/layout.tsx

@ -147,6 +147,7 @@ export default function RootLayout({
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: ` __html: `
if (typeof window !== 'undefined' && window.HabibApp) { if (typeof window !== 'undefined' && window.HabibApp) {
window.HabibApp.postMessage(JSON.stringify({ action: 'web_ready' }));
window.HabibApp.postMessage(JSON.stringify({ action: 'get_view_paddings' })); window.HabibApp.postMessage(JSON.stringify({ action: 'get_view_paddings' }));
} }
`, `,

16
src/hooks/use-view-paddings.ts

@ -1,10 +1,12 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { viewPaddingsBridge } from "@/lib/view-paddings";
import { type InitialConfig, viewPaddingsBridge } from "@/lib/view-paddings";
export function useViewPaddings() { export function useViewPaddings() {
const [paddings, setPaddings] = useState(() => viewPaddingsBridge.getPaddings());
const [paddings, setPaddings] = useState(() =>
viewPaddingsBridge.getPaddings(),
);
useEffect(() => { useEffect(() => {
return viewPaddingsBridge.subscribe(setPaddings); return viewPaddingsBridge.subscribe(setPaddings);
@ -12,3 +14,13 @@ export function useViewPaddings() {
return paddings; return paddings;
} }
export function useFlutterConfig(): InitialConfig {
const [config, setConfig] = useState(() => viewPaddingsBridge.getConfig());
useEffect(() => {
return viewPaddingsBridge.subscribeConfig(setConfig);
}, []);
return config;
}

183
src/lib/view-paddings.ts

@ -5,9 +5,83 @@ interface ViewPaddings {
right: 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 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 { class ViewPaddingsBridge {
private paddings: ViewPaddings = { top: 0, bottom: 0, left: 0, right: 0 }; 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 listeners: Array<(paddings: ViewPaddings) => void> = [];
private configListeners: Array<(config: InitialConfig) => void> = [];
private flutterUnsubscribe?: () => void; private flutterUnsubscribe?: () => void;
constructor() { constructor() {
@ -18,7 +92,7 @@ class ViewPaddingsBridge {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
this.setupFlutterListener(); this.setupFlutterListener();
this.requestPaddings();
this.requestConfig();
} }
private setupFlutterListener() { private setupFlutterListener() {
@ -32,15 +106,16 @@ class ViewPaddingsBridge {
if (typeof win.addFlutterResponseListener !== "function") return false; if (typeof win.addFlutterResponseListener !== "function") return false;
this.flutterUnsubscribe = win.addFlutterResponseListener((event) => { 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; if (typeof window === "undefined") return;
const app = (window as any).HabibApp; const app = (window as any).HabibApp;
if (app?.postMessage) { if (app?.postMessage) {
// اعلام آمادگی وب؛ فلاتر با initial_config پاسخ می‌دهد.
app.postMessage(JSON.stringify({ action: "web_ready" }));
// درخواست سازگاری عقب‌رو برای نسخه‌های قدیمی‌تر اپ.
app.postMessage(JSON.stringify({ action: "get_view_paddings" })); 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() { private applyPaddings() {
if (typeof document === "undefined") return; 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() { private notifyListeners() {
this.listeners.forEach((listener) => listener(this.paddings)); this.listeners.forEach((listener) => listener(this.paddings));
} }
private notifyConfigListeners() {
this.configListeners.forEach((listener) => listener(this.config));
}
public getPaddings(): ViewPaddings { public getPaddings(): ViewPaddings {
return { ...this.paddings }; 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 { public subscribe(listener: (paddings: ViewPaddings) => void): () => void {
this.listeners.push(listener); this.listeners.push(listener);
return () => { return () => {
@ -87,6 +229,17 @@ class ViewPaddingsBridge {
if (index >= 0) this.listeners.splice(index, 1); 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(); export const viewPaddingsBridge = new ViewPaddingsBridge();

26
src/types/window.d.ts

@ -1,16 +1,42 @@
declare global { declare global {
interface FlutterEdgeInsets {
top?: number;
bottom?: number;
left?: number;
right?: number;
}
interface FlutterResponseEvent { interface FlutterResponseEvent {
action: string; action: string;
success: boolean; success: boolean;
data?: { data?: {
// get_location
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
// get_view_paddings (flat edges)
top?: number; top?: number;
bottom?: number; bottom?: number;
left?: number; left?: number;
right?: number; right?: number;
message?: string; message?: string;
timestamp?: number; 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 };
}; };
} }

8
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"; export { FixedBottom } from "./components/utils/fixed-bottom";
Loading…
Cancel
Save