Browse Source

feat(navigation): register all modals and sheets to hardware back stack to cleanly close on Android back

master
mortezaei 2 days ago
parent
commit
962f7e9bf6
  1. 3
      src/components/Componentes/dismiss-reason-sheet.tsx
  2. 3
      src/components/Componentes/female-consent-sheet.tsx
  3. 3
      src/components/Componentes/female-outcome-sheet.tsx
  4. 3
      src/components/Componentes/help-modal.tsx
  5. 3
      src/components/Componentes/information-sheet.tsx
  6. 3
      src/components/Componentes/outcome-selection-sheet.tsx
  7. 3
      src/components/Componentes/report-actions-sheet.tsx
  8. 3
      src/components/Componentes/support-sheet.tsx
  9. 3
      src/components/Componentes/video-player.tsx
  10. 31
      src/hooks/use-hardware-back-handler.ts

3
src/components/Componentes/dismiss-reason-sheet.tsx

@ -2,6 +2,7 @@
import type { HTMLAttributes } from "react"; import type { HTMLAttributes } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import Button from "./button"; import Button from "./button";
@ -77,6 +78,8 @@ export function DismissReasonSheet({
}, EXIT_ANIMATION_MS); }, EXIT_ANIMATION_MS);
}, [isClosing, onClose]); }, [isClosing, onClose]);
useHardwareBackHandler(closeSheet, isVisible && !isClosing);
useEffect(() => { useEffect(() => {
if (!isVisible) { if (!isVisible) {
return; return;

3
src/components/Componentes/female-consent-sheet.tsx

@ -3,6 +3,7 @@
import Image from "next/image"; import Image from "next/image";
import type { HTMLAttributes, ReactNode } from "react"; import type { HTMLAttributes, ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 220; const EXIT_ANIMATION_MS = 220;
@ -50,6 +51,8 @@ export function FemaleConsentSheet({
}, EXIT_ANIMATION_MS); }, EXIT_ANIMATION_MS);
}, [isClosing, onClose]); }, [isClosing, onClose]);
useHardwareBackHandler(closeSheet, isVisible && !isClosing);
const controls = { close: closeSheet }; const controls = { close: closeSheet };
const resolvedButtons = const resolvedButtons =
typeof buttons === "function" ? buttons(controls) : buttons; typeof buttons === "function" ? buttons(controls) : buttons;

3
src/components/Componentes/female-outcome-sheet.tsx

@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import SwipeButton from "./swipe-button"; import SwipeButton from "./swipe-button";
@ -76,6 +77,8 @@ export function FemaleOutcomeSheet({
}, EXIT_ANIMATION_MS); }, EXIT_ANIMATION_MS);
}, [isClosing, onClose]); }, [isClosing, onClose]);
useHardwareBackHandler(closeSheet, isVisible && !isClosing);
useEffect(() => { useEffect(() => {
if (!isVisible) { if (!isVisible) {
return; return;

3
src/components/Componentes/help-modal.tsx

@ -3,6 +3,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import Button from "./button"; import Button from "./button";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
@ -63,6 +64,8 @@ export function HelpModal({
}, EXIT_ANIMATION_MS); }, EXIT_ANIMATION_MS);
}, [isClosing, onClose]); }, [isClosing, onClose]);
useHardwareBackHandler(closeSheet, isOpen && !isClosing);
// Lock body scroll // Lock body scroll
useEffect(() => { useEffect(() => {
if (!isOpen || !mounted) return; if (!isOpen || !mounted) return;

3
src/components/Componentes/information-sheet.tsx

@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import Button from "./button"; import Button from "./button";
import { LoadingSkeleton } from "./loading-skeleton"; import { LoadingSkeleton } from "./loading-skeleton";
import { LoadingThreeDot } from "./loading-three-dot"; import { LoadingThreeDot } from "./loading-three-dot";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 220; const EXIT_ANIMATION_MS = 220;
@ -171,6 +172,8 @@ export function InformationSheet({
}, EXIT_ANIMATION_MS); }, EXIT_ANIMATION_MS);
}, [isClosing, onClose]); }, [isClosing, onClose]);
useHardwareBackHandler(closeSheet, isVisible && !isClosing);
const controls = { close: closeSheet }; const controls = { close: closeSheet };
const resolvedTitle = typeof title === "function" ? title(controls) : title; const resolvedTitle = typeof title === "function" ? title(controls) : title;
const resolvedButtons = const resolvedButtons =

3
src/components/Componentes/outcome-selection-sheet.tsx

@ -2,6 +2,7 @@
import type { HTMLAttributes } from "react"; import type { HTMLAttributes } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 220; const EXIT_ANIMATION_MS = 220;
@ -51,6 +52,8 @@ export function OutcomeSelectionSheet({
}, EXIT_ANIMATION_MS); }, EXIT_ANIMATION_MS);
}, [isClosing, onClose]); }, [isClosing, onClose]);
useHardwareBackHandler(closeSheet, isVisible && !isClosing);
useEffect(() => { useEffect(() => {
if (!isVisible) { if (!isVisible) {
return; return;

3
src/components/Componentes/report-actions-sheet.tsx

@ -3,6 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Button from "./button"; import Button from "./button";
import { useFlutterBridge } from "@/hooks/useFlutterBridge"; import { useFlutterBridge } from "@/hooks/useFlutterBridge";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { import {
copyToClipboard, copyToClipboard,
downloadFile, downloadFile,
@ -33,6 +34,8 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
setIsClosing(true); setIsClosing(true);
}; };
useHardwareBackHandler(closeSheet, isVisible && !isClosing);
// ✅ دکمه WEB_READY // ✅ دکمه WEB_READY
const handleSendWebReady = () => { const handleSendWebReady = () => {
sendToFlutter("WEB_READY", { sendToFlutter("WEB_READY", {

3
src/components/Componentes/support-sheet.tsx

@ -4,6 +4,7 @@ import Image from "next/image";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { FiHeadphones } from "react-icons/fi"; import { FiHeadphones } from "react-icons/fi";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 220; const EXIT_ANIMATION_MS = 220;
@ -45,6 +46,8 @@ export function SupportSheet({ isOpen, onClose }: SupportSheetProps) {
}, EXIT_ANIMATION_MS); }, EXIT_ANIMATION_MS);
}, [isClosing, onClose]); }, [isClosing, onClose]);
useHardwareBackHandler(closeSheet, isOpen && !isClosing);
// Lock body scroll // Lock body scroll
useEffect(() => { useEffect(() => {
if (!isOpen || !mounted) return; if (!isOpen || !mounted) return;

3
src/components/Componentes/video-player.tsx

@ -2,6 +2,7 @@
import { useEffect, useState, type FC } from "react"; import { useEffect, useState, type FC } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
type VideoPlayerProps = { type VideoPlayerProps = {
isOpen: boolean; isOpen: boolean;
@ -24,6 +25,8 @@ export const VideoPlayer: FC<VideoPlayerProps> = ({
setMounted(true); setMounted(true);
}, []); }, []);
useHardwareBackHandler(onClose, isOpen);
const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement>) => { const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement>) => {
const video = e.currentTarget; const video = e.currentTarget;
if (video.videoWidth && video.videoHeight) { if (video.videoWidth && video.videoHeight) {

31
src/hooks/use-hardware-back-handler.ts

@ -13,32 +13,19 @@ import { useEffect, useRef } from "react";
* Pages register themselves with useHardwareBackHandler(). Sheets and modals * Pages register themselves with useHardwareBackHandler(). Sheets and modals
* also register the last one wins, matching the visual stacking order. * also register the last one wins, matching the visual stacking order.
*/ */
const backHandlerStack: Array<() => boolean | Promise<boolean>> = [];
const backHandlerStack: Array<() => void | boolean | Promise<void | boolean>> = [];
/** /**
* Register a hardware-back handler. When the user presses the hardware back * 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.
* button (Android), Flutter calls window.__habibHandleHardwareBackSync() or
* window.__habibHandleHardwareBack().
* *
* @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;
* });
* @param handler - Return true (or void) if you handled the back (e.g. closed a sheet).
* Return false if you didn't handle it (Flutter should close the WebView).
* @param enabled - When false, the handler is not registered.
*/ */
export function useHardwareBackHandler( export function useHardwareBackHandler(
handler: () => boolean | Promise<boolean>,
handler: () => void | boolean | Promise<void | boolean>,
enabled = true, enabled = true,
) { ) {
const handlerRef = useRef(handler); const handlerRef = useRef(handler);
@ -76,7 +63,7 @@ export function handleHardwareBackSync(): boolean {
// Promise was initiated by invoking the handler; it is handled in JS! // Promise was initiated by invoking the handler; it is handled in JS!
return true; return true;
} }
return Boolean(result);
return result === undefined ? true : Boolean(result);
} catch (error) { } catch (error) {
console.warn("[HardwareBackSync] Handler threw:", error); console.warn("[HardwareBackSync] Handler threw:", error);
return false; return false;
@ -97,7 +84,7 @@ export async function handleHardwareBack(): Promise<{ handled: boolean }> {
const handler = backHandlerStack[backHandlerStack.length - 1]; const handler = backHandlerStack[backHandlerStack.length - 1];
try { try {
const result = await handler(); const result = await handler();
return { handled: Boolean(result) };
return { handled: result === undefined ? true : Boolean(result) };
} catch (error) { } catch (error) {
console.warn("[HardwareBack] Handler threw:", error); console.warn("[HardwareBack] Handler threw:", error);
return { handled: false }; return { handled: false };

Loading…
Cancel
Save