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
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();
|