diff --git a/public/assets/fonts/ArabicYekanXRegular.ttf b/public/assets/fonts/ArabicYekanXRegular.ttf new file mode 100644 index 0000000..cb01293 Binary files /dev/null and b/public/assets/fonts/ArabicYekanXRegular.ttf differ diff --git a/public/assets/fonts/Faminela.otf b/public/assets/fonts/Faminela.otf new file mode 100644 index 0000000..091ff77 Binary files /dev/null and b/public/assets/fonts/Faminela.otf differ diff --git a/public/assets/fonts/NotoNastaliqUrdu.ttf b/public/assets/fonts/NotoNastaliqUrdu.ttf new file mode 100644 index 0000000..3362081 Binary files /dev/null and b/public/assets/fonts/NotoNastaliqUrdu.ttf differ diff --git a/public/assets/fonts/SegoeUIBold.ttf b/public/assets/fonts/SegoeUIBold.ttf new file mode 100644 index 0000000..5723e8b Binary files /dev/null and b/public/assets/fonts/SegoeUIBold.ttf differ diff --git a/public/assets/fonts/YekanXFaNum-R.ttf b/public/assets/fonts/YekanXFaNum-R.ttf new file mode 100644 index 0000000..c96391a Binary files /dev/null and b/public/assets/fonts/YekanXFaNum-R.ttf differ diff --git a/public/assets/fonts/faminela.ttf b/public/assets/fonts/faminela.ttf new file mode 100644 index 0000000..a99d4f2 Binary files /dev/null and b/public/assets/fonts/faminela.ttf differ diff --git a/public/assets/fonts/segoeui.ttf b/public/assets/fonts/segoeui.ttf new file mode 100644 index 0000000..0f52cbd Binary files /dev/null and b/public/assets/fonts/segoeui.ttf differ diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx index e8556bd..a131730 100644 --- a/src/app/[lang]/layout.tsx +++ b/src/app/[lang]/layout.tsx @@ -21,7 +21,13 @@ export default async function LocaleLayout({ return ( -
{children}
+
+ {children} +
); } diff --git a/src/app/globals.css b/src/app/globals.css index b757cbe..6ca0a07 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,5 +1,49 @@ @import "tailwindcss"; +/* ─── App fonts matching Flutter Najm & Hosseinieh ─── */ +@font-face { + font-family: 'Faminela'; + src: url('/assets/fonts/faminela.ttf') format('truetype'); + font-weight: 400 900; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'YekanX'; + src: url('/assets/fonts/YekanXFaNum-R.ttf') format('truetype'); + font-weight: 400 900; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'YekanXArabic'; + src: url('/assets/fonts/ArabicYekanXRegular.ttf') format('truetype'); + font-weight: 400 900; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'NastaliqUrdu'; + src: url('/assets/fonts/NotoNastaliqUrdu.ttf') format('truetype'); + font-weight: 400 900; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'Segoeui'; + src: url('/assets/fonts/segoeui.ttf') format('truetype'); + font-weight: 400 500; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'Segoeui'; + src: url('/assets/fonts/SegoeUIBold.ttf') format('truetype'); + font-weight: 600 900; + font-style: normal; + font-display: swap; +} + :root { --background: #F5F5F5; --default-page-background-image: url("/assets/images/home-Checkups-List.svg"); @@ -8,6 +52,10 @@ --safe-left: env(safe-area-inset-left, 0px); --safe-right: env(safe-area-inset-right, 0px); + /* Default body font follows Flutter AppTheme: Segoeui for non-RTL app locales */ + --marriage-font-sans: 'Segoeui', 'YekanXArabic', 'YekanX', system-ui, sans-serif; + --marriage-font-brand: 'Faminela', 'Segoeui', 'YekanXArabic', 'YekanX', system-ui, sans-serif; + /* ─── Semantic Tokens (Light) ─── */ --semantic-neutral-bg: #f1f5f9; --semantic-neutral-text: #475569; @@ -72,16 +120,44 @@ --semantic-ai-icon: #c4b5fd; } +html[lang='fa'], +body[data-lang='fa'], +.lang-fa { + --marriage-font-sans: 'YekanX', 'Segoeui', 'YekanXArabic', system-ui, sans-serif; + --marriage-font-brand: 'Faminela', 'YekanX', 'Segoeui', 'YekanXArabic', system-ui, sans-serif; + line-height: 1.5; +} + +html[lang='ar'], +body[data-lang='ar'], +.lang-ar { + --marriage-font-sans: 'YekanXArabic', 'Segoeui', 'YekanX', system-ui, sans-serif; + --marriage-font-brand: 'Faminela', 'YekanXArabic', 'Segoeui', 'YekanX', system-ui, sans-serif; + line-height: 1.5; +} + +html[lang='ur'], +body[data-lang='ur'], +.lang-ur, +html[lang='ks'], +body[data-lang='ks'], +.lang-ks { + --marriage-font-sans: 'NastaliqUrdu', 'Segoeui', 'YekanXArabic', 'YekanX', system-ui, sans-serif; + --marriage-font-brand: 'Faminela', 'NastaliqUrdu', 'Segoeui', 'YekanXArabic', 'YekanX', system-ui, sans-serif; + line-height: 2; +} + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + --font-sans: var(--marriage-font-sans); + --font-brand: var(--marriage-font-brand); + --font-faminela: var(--marriage-font-brand); --font-mono: var(--font-geist-mono); - --font-arabic: var(--font-yekanx-arabic), var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; - --font-persian: var(--font-yekanx), var(--font-yekanx-arabic), var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; - --font-urdu: var(--font-nastaliq-urdu), var(--font-yekanx-arabic), var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; - --font-ryling: var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; - --font-faminela: var(--font-faminela-local), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + --font-arabic: 'YekanXArabic', 'Segoeui', 'YekanX', system-ui, sans-serif; + --font-persian: 'YekanX', 'Segoeui', 'YekanXArabic', system-ui, sans-serif; + --font-urdu: 'NastaliqUrdu', 'Segoeui', 'YekanXArabic', 'YekanX', system-ui, sans-serif; + --font-ryling: var(--marriage-font-sans); --color-semantic-neutral-bg: var(--semantic-neutral-bg); --color-semantic-neutral-text: var(--semantic-neutral-text); @@ -190,6 +266,27 @@ textarea, user-select: text !important; } +html, +body, +button, +input, +optgroup, +select, +textarea, +h1, +h2, +h3, +h4, +h5, +h6, +p, +span, +a, +label, +.app-shell { + font-family: var(--marriage-font-sans); +} + body { width: 100%; height: 100%; @@ -198,49 +295,26 @@ body { justify-content: center; overflow: hidden; color: var(--foreground); - font-family: var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; overscroll-behavior: none; + font-size: 14px; + word-spacing: 0.25px; } -/* ─── Multi-Language Typography matching Flutter Najm AppTheme ─── */ -/* 1. Arabic (ar) -> Flutter yekanxArabic (ArabicYekanXRegular.ttf) */ -html:lang(ar) body, -html[lang="ar"] body, -body[data-lang="ar"], -:lang(ar), +/* Specific class helpers */ .font-arabic { - font-family: var(--font-yekanx-arabic), var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + font-family: 'YekanXArabic', 'Segoeui', 'YekanX', system-ui, sans-serif; } -/* 2. Persian (fa) -> Flutter yekanx (YekanXFaNum-R.ttf) */ -html:lang(fa) body, -html[lang="fa"] body, -body[data-lang="fa"], -:lang(fa), .font-persian { - font-family: var(--font-yekanx), var(--font-yekanx-arabic), var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; -} - -/* 3. Urdu (ur) & Kashmiri (ks) -> Flutter NastaliqUrdu (NotoNastaliqUrdu.ttf) */ -html:lang(ur) body, -html[lang="ur"] body, -body[data-lang="ur"], -:lang(ur), -html:lang(ks) body, -html[lang="ks"] body, -body[data-lang="ks"], -:lang(ks), -.font-urdu { - font-family: var(--font-nastaliq-urdu), var(--font-yekanx-arabic), var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + font-family: 'YekanX', 'Segoeui', 'YekanXArabic', system-ui, sans-serif; } -/* 4. Global / Latin / Cyrillic / Asian languages -> Flutter Segoeui */ -html:not([lang="ar"]):not([lang="fa"]):not([lang="ur"]):not([lang="ks"]) body { - font-family: var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; +.font-urdu { + font-family: 'NastaliqUrdu', 'Segoeui', 'YekanXArabic', 'YekanX', system-ui, sans-serif; } .font-faminela { - font-family: var(--font-faminela-local), var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + font-family: var(--marriage-font-brand); } .app-shell { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ca02791..b820a6d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,4 @@ import type { Metadata, Viewport } from "next"; -import localFont from "next/font/local"; import { cookies, headers } from "next/headers"; import Providers from "./providers"; import "./globals.css"; @@ -13,57 +12,6 @@ import { import { getInitialMarriageProfile } from "@/hooks/marriage/use-profile-main"; import type { MarriageProfileResponse } from "@/hooks/marriage/types"; -const yekanxArabic = localFont({ - src: "../../public/fonts/yekanx-arabic/ArabicYekanXRegular.ttf", - variable: "--font-yekanx-arabic", - display: "swap", - preload: true, - fallback: ["Segoe UI", "Tahoma", "Geneva", "Verdana", "sans-serif"], -}); - -const yekanx = localFont({ - src: "../../public/fonts/yekanx/YekanXFaNum-R.ttf", - variable: "--font-yekanx", - display: "swap", - preload: true, - fallback: ["Segoe UI", "Tahoma", "Geneva", "Verdana", "sans-serif"], -}); - -const notoNastaliqUrdu = localFont({ - src: "../../public/fonts/urdu/NotoNastaliqUrdu.ttf", - variable: "--font-nastaliq-urdu", - display: "swap", - preload: true, - fallback: ["Segoe UI", "Tahoma", "Geneva", "Verdana", "sans-serif"], -}); - -const segoeui = localFont({ - src: [ - { - path: "../../public/fonts/segoeui/segoeui.ttf", - weight: "400", - style: "normal", - }, - { - path: "../../public/fonts/segoeui/SegoeUIBold.ttf", - weight: "700", - style: "normal", - }, - ], - variable: "--font-segoeui", - display: "swap", - preload: true, - fallback: ["Segoe UI", "Tahoma", "Geneva", "Verdana", "sans-serif"], -}); - -const faminela = localFont({ - src: "../../public/fonts/Faminela/Faminela.otf", - variable: "--font-faminela-local", - display: "swap", - preload: false, - fallback: ["Arial", "sans-serif"], -}); - const isDevelopment = process.env.NODE_ENV !== "production"; export const metadata: Metadata = { @@ -420,10 +368,7 @@ export default async function RootLayout({ }} /> - + {isDevelopment ? : null}
{children}
diff --git a/src/components/Componentes/information-sheet.test.tsx b/src/components/Componentes/information-sheet.test.tsx index 66ab292..43a6b8c 100644 --- a/src/components/Componentes/information-sheet.test.tsx +++ b/src/components/Componentes/information-sheet.test.tsx @@ -1,8 +1,7 @@ -import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; -import { describe, expect, it, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi, afterEach } from "vitest"; import React from "react"; import { InformationSheet } from "./information-sheet"; -import { viewPaddingsBridge } from "@/lib/view-paddings"; describe("InformationSheet", () => { afterEach(() => { @@ -22,7 +21,7 @@ describe("InformationSheet", () => { expect(screen.getByText("Test Sheet Description")).toBeDefined(); }); - it("should apply smooth keyboard lift when an input inside the sheet receives focus and keyboard height is set", () => { + it("should apply smooth keyboard lift when an input inside the sheet receives focus and keyboard height is set", async () => { render( { const section = document.querySelector("section"); expect(section).toBeDefined(); - // Focus input and simulate Flutter bridge keyboard event - act(() => { - input.focus(); - fireEvent.focusIn(input); - // Simulate flutter keyboard bridge event - window.dispatchEvent( - new MessageEvent("message", { - data: JSON.stringify({ - action: "keyboard_changed", - height: 280, - }), + if (section) { + vi.spyOn(section, "getBoundingClientRect").mockReturnValue({ + top: 300, + bottom: 800, + left: 0, + right: 400, + width: 400, + height: 500, + x: 0, + y: 300, + toJSON: () => {}, + }); + } + + input.focus(); + fireEvent.focusIn(input); + window.dispatchEvent( + new MessageEvent("message", { + data: JSON.stringify({ + action: "keyboard_changed", + height: 280, }), - ); - }); + }), + ); - expect(section?.style.marginBottom).toBe("280px"); + await waitFor(() => { + expect(section?.style.transform).toContain("translate3d"); + }); }); - it("should reset keyboard lift when input loses focus", async () => { + it("should reset keyboard lift when keyboard closes or input loses focus", async () => { render( { const input = screen.getByTestId("test-input"); const section = document.querySelector("section"); - act(() => { - input.focus(); - fireEvent.focusIn(input); - }); + input.focus(); + fireEvent.focusIn(input); - act(() => { - input.blur(); - fireEvent.focusOut(input); - }); - - // Wait for focusout settlement - await new Promise((r) => setTimeout(r, 60)); + window.dispatchEvent( + new MessageEvent("message", { + data: JSON.stringify({ + action: "keyboard_changed", + height: 0, + }), + }), + ); - expect(section?.style.marginBottom).toBe(""); + await waitFor(() => { + expect(section?.style.transform).toBe(""); + }); }); }); + diff --git a/src/components/Componentes/information-sheet.tsx b/src/components/Componentes/information-sheet.tsx index 5d3b1ae..60be3ce 100644 --- a/src/components/Componentes/information-sheet.tsx +++ b/src/components/Componentes/information-sheet.tsx @@ -328,35 +328,70 @@ export function InformationSheet({ return; } - let bridgeKbHeight = viewPaddingsBridge.getConfig().keyboardHeight || 0; - let hasFocusedInput = false; - - const updateSheetLift = () => { - if (!sheetRef.current) return; - - const visualViewport = window.visualViewport; - const vpReduction = visualViewport - ? Math.max(0, window.innerHeight - visualViewport.height) - : 0; - - const detectedHeight = Math.max(bridgeKbHeight, vpReduction); - const defaultHeight = - typeof window !== "undefined" && window.innerHeight > 0 - ? Math.round(window.innerHeight * 0.375) - : 285; - - const effectiveHeight = - detectedHeight > 20 - ? detectedHeight - : hasFocusedInput - ? defaultHeight - : 0; - - if (effectiveHeight > 20 && hasFocusedInput) { - setKeyboardLift(Math.round(effectiveHeight)); - } else if (!hasFocusedInput) { - setKeyboardLift(0); + const KEYBOARD_THRESHOLD = 20; + let keyboardVisible = false; + let keyboardHeight = 0; + let activeInput: HTMLElement | null = null; + let currentLift = 0; + let animFrame: number | null = null; + let closedVpHeight = window.visualViewport?.height ?? window.innerHeight; + + const setLift = (nextLift: number) => { + currentLift = Math.max(0, Math.round(nextLift)); + setKeyboardLift(currentLift); + }; + + const updateLift = () => { + const sheet = sheetRef.current; + if (!sheet || !keyboardVisible || !activeInput) { + setLift(0); + return; } + + // 1. Calculate keyboard visible bottom limit + const flutterTop = + keyboardHeight > 0 ? window.innerHeight - keyboardHeight : null; + const viewport = window.visualViewport; + const viewportBottom = viewport + ? viewport.offsetTop + viewport.height + : window.innerHeight; + const visibleBottom = + flutterTop !== null + ? Math.min(flutterTop, viewportBottom) + : viewportBottom; + + // 2. Calculate sheet base geometry (without current lift) + const sheetRect = sheet.getBoundingClientRect(); + const baseBottom = sheetRect.bottom + currentLift; + const baseTop = sheetRect.top + currentLift; + + const safeTop = + parseFloat( + getComputedStyle(document.documentElement).getPropertyValue( + "--safe-top", + ) || "0", + ) || 16; + const visibleTop = safeTop + 8; + + // 3. Compute required lift to ensure sheet bottom clears keyboard + const overlap = baseBottom - visibleBottom; + if (overlap <= 0) { + setLift(0); + return; + } + + const maxShift = Math.max(0, baseTop - visibleTop); + const computedLift = Math.min(overlap, maxShift); + setLift(computedLift); + }; + + const scheduleUpdate = () => { + if (animFrame !== null) cancelAnimationFrame(animFrame); + animFrame = requestAnimationFrame(() => { + animFrame = null; + updateLift(); + }); + updateLift(); }; const handleFocusIn = (e: FocusEvent) => { @@ -364,45 +399,89 @@ export function InformationSheet({ sheetRef.current?.contains(e.target as Node) && isKeyboardInputTarget(e.target) ) { - hasFocusedInput = true; - updateSheetLift(); - setTimeout(() => { - if (e.target instanceof HTMLElement) { - e.target.scrollIntoView({ behavior: "smooth", block: "center" }); - } - }, 150); + activeInput = e.target; + const currentVpHeight = + window.visualViewport?.height ?? window.innerHeight; + if ( + keyboardHeight > 0 || + closedVpHeight - currentVpHeight > KEYBOARD_THRESHOLD + ) { + keyboardVisible = true; + } + scheduleUpdate(); } }; const handleFocusOut = () => { window.setTimeout(() => { const active = document.activeElement; - hasFocusedInput = Boolean( - sheetRef.current?.contains(active) && isKeyboardInputTarget(active), - ); - updateSheetLift(); + activeInput = + sheetRef.current?.contains(active) && isKeyboardInputTarget(active) + ? (active as HTMLElement) + : null; + if (!activeInput) { + keyboardVisible = false; + setLift(0); + } else { + scheduleUpdate(); + } }, 50); }; const unsubscribeConfig = viewPaddingsBridge.subscribeConfig((config) => { - bridgeKbHeight = config.keyboardHeight; - updateSheetLift(); + const nextHeight = Math.max(0, config.keyboardHeight); + if (nextHeight > 0) { + keyboardHeight = nextHeight; + const active = document.activeElement as HTMLElement | null; + if ( + !activeInput && + active && + sheetRef.current?.contains(active) && + isKeyboardInputTarget(active) + ) { + activeInput = active; + } + if (activeInput) { + keyboardVisible = true; + } + } else { + keyboardHeight = 0; + keyboardVisible = false; + } + scheduleUpdate(); }); - const handleVpResize = () => { - updateSheetLift(); + const handleViewportResize = () => { + const vpHeight = window.visualViewport?.height ?? window.innerHeight; + const reduction = closedVpHeight - vpHeight; + + if (reduction > KEYBOARD_THRESHOLD) { + if (activeInput) { + keyboardVisible = true; + keyboardHeight = reduction; + } + } else if (reduction <= KEYBOARD_THRESHOLD) { + keyboardVisible = false; + keyboardHeight = 0; + if (!activeInput) { + closedVpHeight = vpHeight; + } + } + scheduleUpdate(); }; document.addEventListener("focusin", handleFocusIn); document.addEventListener("focusout", handleFocusOut); - window.visualViewport?.addEventListener("resize", handleVpResize); - window.visualViewport?.addEventListener("scroll", handleVpResize); + window.visualViewport?.addEventListener("resize", handleViewportResize); return () => { + if (animFrame !== null) cancelAnimationFrame(animFrame); document.removeEventListener("focusin", handleFocusIn); document.removeEventListener("focusout", handleFocusOut); - window.visualViewport?.removeEventListener("resize", handleVpResize); - window.visualViewport?.removeEventListener("scroll", handleVpResize); + window.visualViewport?.removeEventListener( + "resize", + handleViewportResize, + ); unsubscribeConfig(); }; }, [isRendered]); @@ -444,12 +523,11 @@ export function InformationSheet({ ref={sheetRef} {...props} style={{ - marginBottom: keyboardLift > 0 ? `${keyboardLift}px` : undefined, - transition: "margin-bottom 200ms cubic-bezier(0.16, 1, 0.3, 1)", - maxHeight: + transform: keyboardLift > 0 - ? `calc(100vh - ${keyboardLift + 20}px)` + ? `translate3d(0, -${keyboardLift}px, 0)` : undefined, + transition: "transform 200ms cubic-bezier(0.16, 1, 0.3, 1)", ...props.style, }} className={[ diff --git a/src/lib/view-paddings.ts b/src/lib/view-paddings.ts index 3b6b5fc..001b72f 100644 --- a/src/lib/view-paddings.ts +++ b/src/lib/view-paddings.ts @@ -136,6 +136,16 @@ class ViewPaddingsBridge { string, any >; + if ( + envelope.action === "keyboard_changed" || + data.action === "keyboard_changed" + ) { + const height = num(data.height ?? envelope.height); + this.config = { ...this.config, keyboardHeight: height }; + this.applyKeyboard(height); + this.notifyConfigListeners(); + return; + } if (this.hasInitialConfig) { if (data.locale) this.applyLocale(data.locale); return;