diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a9de196..1c12781 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -301,6 +301,21 @@ export default async function RootLayout({ } }, 50); } + + // 5. Document Boot ID — reload detection instrumentation. + // If this ID changes across a back navigation, a hard reload + // or WebView recreation happened (not SPA navigation). + window.__habibDocumentBootId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); + + // 6. Hardware Back contract stub. + // Flutter should call: window.__habibHandleHardwareBack() + // The real handler stack is set up by React (use-hardware-back-handler.ts). + // This stub ensures the function exists even before React hydrates. + if (!window.__habibHandleHardwareBack) { + window.__habibHandleHardwareBack = function() { + return Promise.resolve({ handled: false }); + }; + } })(); `, }} diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 72e5bcb..7be85c1 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -6,6 +6,7 @@ import { } from "@tanstack/react-query"; import { type ReactNode, useState } from "react"; import FlutterLocaleSync from "@/components/Componentes/flutter-locale-sync"; +import HardwareBackBridge from "@/components/Componentes/hardware-back-bridge"; import SilentReloader from "@/components/Componentes/silent-reloader"; import { ViewPaddingsProvider } from "@/components/Componentes/view-paddings-provider"; @@ -44,6 +45,7 @@ export default function Providers({ children }: ProvidersProps) { + {children} diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index 264ffb4..f92e846 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -1,7 +1,8 @@ "use client"; import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import Button from "@/components/Componentes/button"; import DataErrorState from "@/components/Componentes/data-error-state"; import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; @@ -166,6 +167,16 @@ export default function QuestionDetailClient({ const [hasTestProgress, setHasTestProgress] = useState(false); const queryClient = useQueryClient(); + // Hardware back in the detail page = navigate back to questions list. + // QuestionAnswersProvider's pagehide/unmount safety net will flush + // any pending answers automatically when the component unmounts. + const handleHardwareBack = useCallback(async () => { + router.replace(questionsListHref); + return true; // handled — keep WebView open + }, [router, questionsListHref]); + + useHardwareBackHandler(handleHardwareBack); + useEffect(() => { if (typeof window !== "undefined") { const draftKey = `marriage:tests:${itemSlug}:draft`; @@ -360,7 +371,7 @@ export default function QuestionDetailClient({ variant="transparent" icon="close" iconLabel={closeLabel} - onClick={() => router.push(questionsListHref)} + onClick={() => router.replace(questionsListHref)} />

{loadingTitle} @@ -412,7 +423,7 @@ export default function QuestionDetailClient({ variant="transparent" icon="close" iconLabel={closeLabel} - onClick={() => router.push(questionsListHref)} + onClick={() => router.replace(questionsListHref)} />

{errorTitle} @@ -455,7 +466,7 @@ export default function QuestionDetailClient({ variant="transparent" icon="close" iconLabel={closeLabel} - onClick={() => router.push(questionsListHref)} + onClick={() => router.replace(questionsListHref)} />

{errorTitle} diff --git a/src/app/questions-list/questions-list-client.tsx b/src/app/questions-list/questions-list-client.tsx index 39138ba..f92ca89 100644 --- a/src/app/questions-list/questions-list-client.tsx +++ b/src/app/questions-list/questions-list-client.tsx @@ -28,7 +28,7 @@ import { applyProfilePatchResultToCache, updateMarriageSectionData, } from "@/hooks/marriage/use-section-data"; -import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back"; +import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import { getAssessmentLocalProgress } from "@/lib/assessment-progress"; import { getSubmitPath, @@ -47,7 +47,18 @@ import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch"; import SectionsRequest from "./sections-request"; export default function QuestionsListClient() { - useCloseServiceOnBack(); + // Hardware back on the root questions list = close the Flutter service. + // Unlike the old useCloseServiceOnBack, this does NOT push fake history + // entries. Flutter calls __habibHandleHardwareBack() and we return false + // (meaning "I didn't handle it — you should close"). + useHardwareBackHandler(() => { + if (typeof window !== "undefined" && (window as any).HabibApp?.postMessage) { + (window as any).HabibApp.postMessage( + JSON.stringify({ action: "close_service" }), + ); + } + return false; // Tell Flutter to close the WebView screen + }); const { dictionary: t, locale } = useI18n(); const router = useRouter(); const queryClient = useQueryClient(); diff --git a/src/components/Componentes/hardware-back-bridge.tsx b/src/components/Componentes/hardware-back-bridge.tsx new file mode 100644 index 0000000..5f71810 --- /dev/null +++ b/src/components/Componentes/hardware-back-bridge.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect } from "react"; +import { handleHardwareBack } from "@/hooks/use-hardware-back-handler"; + +/** + * Wires the React hardware-back handler stack to the global + * window.__habibHandleHardwareBack function. + * + * Mount this once in the Providers tree (after React hydration). + * It replaces the bootstrap stub with the real handler that walks + * the registered stack. + */ +export function HardwareBackBridge() { + useEffect(() => { + window.__habibHandleHardwareBack = handleHardwareBack; + + return () => { + // On unmount (shouldn't happen in practice), restore the stub. + window.__habibHandleHardwareBack = () => + Promise.resolve({ handled: false }); + }; + }, []); + + return null; +} + +export default HardwareBackBridge; diff --git a/src/components/Componentes/question-exit-navigation-button.tsx b/src/components/Componentes/question-exit-navigation-button.tsx index 084b462..09ba455 100644 --- a/src/components/Componentes/question-exit-navigation-button.tsx +++ b/src/components/Componentes/question-exit-navigation-button.tsx @@ -45,7 +45,7 @@ export function QuestionExitNavigationButton({ // ignore } finally { const target = localizePath(exitHref || "/questions-list", locale); - router.push(target); + router.replace(target); } }} /> diff --git a/src/components/Componentes/question-section-flow.tsx b/src/components/Componentes/question-section-flow.tsx index bb58def..a73e1a1 100644 --- a/src/components/Componentes/question-section-flow.tsx +++ b/src/components/Componentes/question-section-flow.tsx @@ -64,7 +64,7 @@ function SectionFlowContent({ // ignore } finally { const target = localizePath(exitHref || "/questions-list", locale); - router.push(target); + router.replace(target); } }, [exitHref, flushAnswers, locale, router, isSubmitting]); diff --git a/src/components/Componentes/use-sheet-scroll-lock.ts b/src/components/Componentes/use-sheet-scroll-lock.ts index d9b1204..72d4854 100644 --- a/src/components/Componentes/use-sheet-scroll-lock.ts +++ b/src/components/Componentes/use-sheet-scroll-lock.ts @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; +import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; let activeSheetCount = 0; let bodyHadDropdownClass = false; @@ -23,6 +24,18 @@ export function useSheetScrollLock( const onBackRef = useRef(onBack); onBackRef.current = onBack; + // Register in the hardware-back handler stack so that Flutter's + // __habibHandleHardwareBack() closes the sheet instead of navigating. + const handleHardwareBack = useCallback(() => { + if (onBackRef.current) { + onBackRef.current(); + return true; // handled — sheet closed + } + return false; + }, []); + + useHardwareBackHandler(handleHardwareBack, isOpen); + useEffect(() => { if (!isOpen) return; diff --git a/src/hooks/use-hardware-back-handler.ts b/src/hooks/use-hardware-back-handler.ts new file mode 100644 index 0000000..1b6f5f7 --- /dev/null +++ b/src/hooks/use-hardware-back-handler.ts @@ -0,0 +1,84 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +/** + * Global stack of hardware-back handlers. + * + * When Flutter sends a hardware back event, the root handler in layout.tsx + * pops the last handler from this stack and calls it. If the handler returns + * true, it means "I handled it — keep the WebView open". If it returns false + * (or the stack is empty), Flutter should close the WebView screen. + * + * Pages register themselves with useHardwareBackHandler(). Sheets and modals + * also register — the last one wins, matching the visual stacking order. + */ +const backHandlerStack: Array<() => boolean | Promise> = []; + +/** + * Register a hardware-back handler. When the user presses the hardware back + * button (Android), Flutter calls window.__habibHandleHardwareBack(). The + * root handler walks the stack from top to bottom and calls the first handler. + * + * @param handler - Return true if you handled the back (e.g. closed a sheet, + * flushed answers and navigated). Return false if you didn't handle it + * (Flutter should close the WebView). + * @param enabled - When false, the handler is not registered. Useful for + * conditionally enabling back handling. + * + * @example + * // In questions-list (root): back = close service + * useHardwareBackHandler(() => false); + * + * // In question-detail: back = flush + navigate + * useHardwareBackHandler(async () => { + * await flushAnswers({ force: true }); + * router.replace(questionsListHref); + * return true; + * }); + */ +export function useHardwareBackHandler( + handler: () => boolean | Promise, + enabled = true, +) { + const handlerRef = useRef(handler); + handlerRef.current = handler; + + useEffect(() => { + if (!enabled) return; + + // Wrap in a stable closure so we can remove the exact reference. + const stableHandler = () => handlerRef.current(); + backHandlerStack.push(stableHandler); + + return () => { + const index = backHandlerStack.lastIndexOf(stableHandler); + if (index !== -1) { + backHandlerStack.splice(index, 1); + } + }; + }, [enabled]); +} + +/** + * Called by the root bootstrap script when Flutter sends a hardware back event. + * Returns { handled: true } if a web handler consumed the event, or + * { handled: false } if Flutter should close the WebView screen. + */ +export async function handleHardwareBack(): Promise<{ handled: boolean }> { + if (backHandlerStack.length === 0) { + return { handled: false }; + } + + // Pop the topmost handler (last registered = topmost in visual stack). + const handler = backHandlerStack[backHandlerStack.length - 1]; + try { + const result = await handler(); + return { handled: result }; + } catch (error) { + console.warn("[HardwareBack] Handler threw:", error); + return { handled: false }; + } +} + +export default useHardwareBackHandler; diff --git a/src/types/window.d.ts b/src/types/window.d.ts index 631a738..85af656 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -88,6 +88,17 @@ declare global { __HABIB_BOOTSTRAP__?: NonNullable; __habibWebReadySent?: boolean; __announceHabibWebReady?: () => void; + /** + * Called by Flutter when the user presses the hardware back button. + * Returns { handled: true } if web consumed the event (keep WebView open), + * or { handled: false } if Flutter should close the WebView screen. + */ + __habibHandleHardwareBack?: () => Promise<{ handled: boolean }>; + /** + * Unique ID per document load. If this changes on back navigation, + * it proves a hard reload / WebView recreation happened. + */ + __habibDocumentBootId?: string; } }