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.
 
 
 
 
 

370 lines
11 KiB

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<string, unknown> | null {
if (!event || typeof event !== "object") return null;
const envelope = event as Record<string, unknown>;
const rawData = envelope.data ?? envelope.payload;
if (!rawData || typeof rawData !== "object") return null;
return rawData as Record<string, unknown>;
}
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<string, any>;
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();