From 6d7a1255489a44095254b6f66f8394ec03c7329d Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 22 Aug 2026 16:49:26 +0330 Subject: [PATCH] feat(location): add auto GPS and manual map picker bridge integration with initial empty state --- .../Componentes/question-birthplace.tsx | 253 ++++++------------ .../Componentes/question-snap-list.test.tsx | 68 +++++ .../Componentes/question-snap-list.tsx | 75 +++++- .../Componentes/report-actions-sheet.tsx | 32 ++- src/lib/geo-region.ts | 4 + src/lib/webview-actions.ts | 96 +++++++ src/types/window.d.ts | 2 + 7 files changed, 354 insertions(+), 176 deletions(-) diff --git a/src/components/Componentes/question-birthplace.tsx b/src/components/Componentes/question-birthplace.tsx index 5073486..f939416 100644 --- a/src/components/Componentes/question-birthplace.tsx +++ b/src/components/Componentes/question-birthplace.tsx @@ -19,6 +19,11 @@ import { getStoredUserGeoRegion, subscribeToUserGeoRegion, } from "@/lib/geo-region"; +import { + isInFlutterWebView, + requestAutoLocation, + pickManualLocation, +} from "@/lib/webview-actions"; const EXIT_ANIMATION_MS = 220; @@ -171,23 +176,11 @@ export function QuestionBirthplace({ const isInitialManual = mode === "manual"; - const defaultCountryFallback = - isResidence && locale === "fa" - ? resolveCountryName("IR", "fa") || "ایران" - : ""; - - const localizedInitialCountry = - resolveCountryName(initial.country, locale) || - initial.country || - (!hasSavedAnswer && !isInitialManual && storedRegion?.country - ? resolveCountryName(storedRegion.country, locale) || storedRegion.country - : defaultCountryFallback); + const localizedInitialCountry = hasSavedAnswer + ? resolveCountryName(initial.country, locale) || initial.country + : ""; - const initialCity = - initial.city || - (!hasSavedAnswer && !isInitialManual && storedRegion?.city - ? storedRegion.city - : ""); + const initialCity = hasSavedAnswer ? initial.city || "" : ""; const initialLoc = localizedInitialCountry || initialCity @@ -202,6 +195,12 @@ export function QuestionBirthplace({ const cityInputStateRef = useRef(initialCity); const selectedCountryStateRef = useRef(localizedInitialCountry || ""); + const lastCoordsRef = useRef<{ latitude?: number; longitude?: number } | undefined>( + storedRegion?.latitude && storedRegion?.longitude + ? { latitude: storedRegion.latitude, longitude: storedRegion.longitude } + : undefined, + ); + useEffect(() => { cityInputStateRef.current = cityInput; }, [cityInput]); @@ -221,7 +220,6 @@ export function QuestionBirthplace({ const [isDetecting, setIsDetecting] = useState(false); const [detectedLocation, setDetectedLocation] = useState(initialLoc); - const hasAutoDetectedRef = useRef(false); useEffect(() => { isMountedRef.current = true; @@ -283,96 +281,49 @@ export function QuestionBirthplace({ [question, setAnswerValue], ); - // Subscribe to live geo region updates (e.g. when Flutter bridge responds asynchronously) - useEffect(() => { - if (!isResidence) return; - - const unsubscribe = subscribeToUserGeoRegion((region) => { - if (!isMountedRef.current) return; - // If user has already switched to manual mode, do not overwrite manual edits - const currentStoredMode = - typeof window !== "undefined" - ? localStorage.getItem(`residence_mode_${question.id}`) - : null; - if (currentStoredMode === "manual" || mode === "manual") return; - - const rawCountry = region.country || region.countryCode || ""; - const country = - resolveCountryName(rawCountry, locale) || - rawCountry || - defaultCountryFallback; - const city = region.city || ""; - - if (country || city) { - setSelectedCountry(country); - selectedCountryStateRef.current = country; - setCityInput(city); - cityInputStateRef.current = city; - const loc = [country, city].filter(Boolean).join(", "); - setDetectedLocation(loc); - updateAnswers(country, city); - setIsDetecting(false); - } - }); - - return () => { - unsubscribe(); - }; - }, [ - isResidence, - mode, - locale, - question.id, - defaultCountryFallback, - updateAnswers, - ]); - - // GeoIP detection logic using unified getUserGeoRegion - const detectLocation = useCallback( - async (force = false) => { - // If there is already a saved answer and we are not forcing, display it - if (rawValue && !force) { - const parsed = parseValue(rawValue); - const cName = - resolveCountryName(parsed.country, locale) || parsed.country; - if (cName || parsed.city) { - const loc = [cName, parsed.city].filter(Boolean).join(", "); - if (cName) { - setSelectedCountry(cName); - selectedCountryStateRef.current = cName; - } - if (parsed.city) { - setCityInput(parsed.city); - cityInputStateRef.current = parsed.city; - } - setDetectedLocation(loc); + const handleAutoClick = async () => { + if (typeof window !== "undefined") { + localStorage.setItem(`residence_mode_${question.id}`, "auto"); + } + setMode("auto"); + setIsDetecting(true); - const storedMode = - typeof window !== "undefined" - ? localStorage.getItem(`residence_mode_${question.id}`) - : null; + try { + if (isInFlutterWebView()) { + const data = await requestAutoLocation(); + if (!isMountedRef.current) return; + lastCoordsRef.current = { + latitude: data.latitude, + longitude: data.longitude, + }; + const rawCountry = data.country || data.country_code || ""; + const country = + resolveCountryName(rawCountry, locale) || rawCountry || ""; + const city = data.city || ""; - if (storedMode === "manual") { - setMode("manual"); - } else { - setMode("auto"); - } - return; + if (country || city) { + setSelectedCountry(country); + selectedCountryStateRef.current = country; + setCityInput(city); + cityInputStateRef.current = city; + const loc = [country, city].filter(Boolean).join(", "); + setDetectedLocation(loc); + updateAnswers(country, city); } - } - - setIsDetecting(true); - - try { - const region = await getUserGeoRegion(force); + } else { + const region = await getUserGeoRegion(true); if (!isMountedRef.current) return; - const city = region.city || ""; const rawCountry = region.country || region.countryCode || ""; const country = - resolveCountryName(rawCountry, locale) || - rawCountry || - defaultCountryFallback; + resolveCountryName(rawCountry, locale) || rawCountry || ""; + + if (region.latitude && region.longitude) { + lastCoordsRef.current = { + latitude: region.latitude, + longitude: region.longitude, + }; + } if (country || city) { setSelectedCountry(country); @@ -383,83 +334,49 @@ export function QuestionBirthplace({ setDetectedLocation(loc); updateAnswers(country, city); } - } catch { - // Keep in auto mode on error, do not force manual - } finally { - if (isMountedRef.current) { - setIsDetecting(false); - } } - }, - [rawValue, locale, question.id, defaultCountryFallback, updateAnswers], - ); - - // Auto-detect and pre-fill on initial mount - useEffect(() => { - if (isLoading) return; - if (isResidence && !hasAutoDetectedRef.current) { - hasAutoDetectedRef.current = true; - const storedMode = - typeof window !== "undefined" - ? localStorage.getItem(`residence_mode_${question.id}`) - : null; - - if (storedMode === "manual") { - setMode("manual"); - return; - } - - // Pre-fill answer immediately if initial values exist and no answer recorded yet - if (!hasSavedAnswer && (localizedInitialCountry || initialCity)) { - updateAnswers(localizedInitialCountry, initialCity); - } - - const parsed = parseValue(rawValue); - if (!parsed.country && !parsed.city) { - void detectLocation(false); - } else { - void detectLocation(false); + } catch (err) { + console.warn("Auto location error:", err); + } finally { + if (isMountedRef.current) { + setIsDetecting(false); } } - }, [ - isResidence, - isLoading, - detectLocation, - rawValue, - question.id, - hasSavedAnswer, - localizedInitialCountry, - initialCity, - updateAnswers, - ]); - - const handleAutoClick = () => { - if (typeof window !== "undefined") { - localStorage.setItem(`residence_mode_${question.id}`, "auto"); - } - setMode("auto"); - detectLocation(true); }; - const handleManualClick = () => { + const handleManualClick = async () => { if (typeof window !== "undefined") { localStorage.setItem(`residence_mode_${question.id}`, "manual"); } setMode("manual"); - const parsed = parseValue(rawValue); - const resolvedC = - resolveCountryName(selectedCountry || parsed.country, locale) || - selectedCountry || - parsed.country || - defaultCountryFallback; - const country = resolvedC; - const city = cityInput !== "" ? cityInput : parsed.city; - setSelectedCountry(country); - selectedCountryStateRef.current = country; - setCityInput(city); - cityInputStateRef.current = city; - updateAnswers(country, city); - setDetectedLocation([country, city].filter(Boolean).join(", ")); + + if (isInFlutterWebView()) { + try { + const data = await pickManualLocation(lastCoordsRef.current); + if (data && isMountedRef.current) { + lastCoordsRef.current = { + latitude: data.latitude, + longitude: data.longitude, + }; + const rawCountry = data.country || data.country_code || ""; + const country = + resolveCountryName(rawCountry, locale) || rawCountry || ""; + const city = data.city || ""; + + if (country || city) { + setSelectedCountry(country); + selectedCountryStateRef.current = country; + setCityInput(city); + cityInputStateRef.current = city; + const loc = [country, city].filter(Boolean).join(", "); + setDetectedLocation(loc); + updateAnswers(country, city); + } + } + } catch (err) { + console.warn("Manual map pick error:", err); + } + } }; // Synchronize state ONLY if rawValue changes externally (e.g. draft fetch or reset) diff --git a/src/components/Componentes/question-snap-list.test.tsx b/src/components/Componentes/question-snap-list.test.tsx index a3b5d09..6d9fd24 100644 --- a/src/components/Componentes/question-snap-list.test.tsx +++ b/src/components/Componentes/question-snap-list.test.tsx @@ -1,4 +1,5 @@ import { + act, cleanup, fireEvent, render, @@ -443,4 +444,71 @@ describe("QuestionSnapList keyboard interaction", () => { expect(onActiveIndexChange).not.toHaveBeenCalledWith(1); }); }); + + describe("Periodic scroll hint idle cycle", () => { + it("follows 2s idle -> 2s visible -> 2s hidden -> repeat cycle and resets on user interaction", () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const { container } = render( + Scroll icon} + > +
Question 1
+
Question 2
+
, + ); + + const hintWrapper = container.querySelector(".motion-safe\\:animate-bounce"); + expect(hintWrapper).not.toBeNull(); + + // Initially hidden (waiting 2s) + expect(hintWrapper).toHaveClass("opacity-0"); + expect(hintWrapper).not.toHaveClass("opacity-100"); + + // Advance 1s: still hidden + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(hintWrapper).toHaveClass("opacity-0"); + + // Advance another 1s (total 2s idle): now visible + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(hintWrapper).toHaveClass("opacity-100"); + + // Advance 2s while visible (total 4s): becomes hidden + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(hintWrapper).toHaveClass("opacity-0"); + + // Advance 2s while hidden (total 6s): becomes visible again (cycle repeat) + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(hintWrapper).toHaveClass("opacity-100"); + + // User interacts (touchstart): immediately hides and restarts 2s idle timer + const region = screen.getByRole("region", { name: "Questions" }); + act(() => { + fireEvent.touchStart(region); + }); + expect(hintWrapper).toHaveClass("opacity-0"); + + // Advance 1.5s after touch: still hidden + act(() => { + vi.advanceTimersByTime(1500); + }); + expect(hintWrapper).toHaveClass("opacity-0"); + + // Advance another 500ms (total 2s after touch): becomes visible again + act(() => { + vi.advanceTimersByTime(500); + }); + expect(hintWrapper).toHaveClass("opacity-100"); + + vi.useRealTimers(); + }); + }); }); diff --git a/src/components/Componentes/question-snap-list.tsx b/src/components/Componentes/question-snap-list.tsx index b73a6e5..5467931 100644 --- a/src/components/Componentes/question-snap-list.tsx +++ b/src/components/Componentes/question-snap-list.tsx @@ -105,6 +105,7 @@ export function QuestionSnapList({ const previousActiveIndexRef = useRef(null); const suppressNextClickRef = useRef(false); const [activeIndex, setActiveIndex] = useState(0); + const [isHintVisible, setIsHintVisible] = useState(false); const activeIndexRef = useRef(activeIndex); activeIndexRef.current = activeIndex; @@ -247,6 +248,76 @@ export function QuestionSnapList({ previousActiveIndexRef.current = activeIndex; }, [activeIndex, onQuestionTransition]); + useEffect(() => { + let timerId: number | null = null; + let isCancelled = false; + + const runCycle = (phase: "wait" | "show" | "hide") => { + if (isCancelled) return; + + if (phase === "wait" || phase === "hide") { + setIsHintVisible(false); + timerId = window.setTimeout(() => { + if (isCancelled) return; + setIsHintVisible(true); + runCycle("show"); + }, 2000); + } else if (phase === "show") { + setIsHintVisible(true); + timerId = window.setTimeout(() => { + if (isCancelled) return; + setIsHintVisible(false); + runCycle("hide"); + }, 2000); + } + }; + + runCycle("wait"); + + const handleUserActivity = () => { + if (isCancelled) return; + setIsHintVisible(false); + if (timerId !== null) { + window.clearTimeout(timerId); + timerId = null; + } + runCycle("wait"); + }; + + const container = containerRef.current; + if (container) { + container.addEventListener("touchstart", handleUserActivity, { + passive: true, + capture: true, + }); + container.addEventListener("mousedown", handleUserActivity, { + passive: true, + capture: true, + }); + container.addEventListener("keydown", handleUserActivity, { + passive: true, + capture: true, + }); + container.addEventListener("wheel", handleUserActivity, { + passive: true, + capture: true, + }); + } + + return () => { + isCancelled = true; + if (timerId !== null) { + window.clearTimeout(timerId); + } + if (container) { + container.removeEventListener("touchstart", handleUserActivity, true); + container.removeEventListener("mousedown", handleUserActivity, true); + container.removeEventListener("keydown", handleUserActivity, true); + container.removeEventListener("wheel", handleUserActivity, true); + } + }; + }, [activeIndex]); + useEffect(() => { return () => { if (wheelUnlockTimeoutRef.current !== null) { @@ -759,8 +830,8 @@ export function QuestionSnapList({ aria-hidden="true" className={[ "pointer-events-none absolute bottom-24 left-1/2 -translate-x-1/2", - "transition-opacity duration-500 motion-safe:animate-bounce", - activeIndex === 0 ? "opacity-100" : "opacity-0", + "transition-opacity duration-500 motion-safe:animate-bounce", + isHintVisible ? "opacity-100" : "opacity-0", ].join(" ")} > {firstQuestionHint} diff --git a/src/components/Componentes/report-actions-sheet.tsx b/src/components/Componentes/report-actions-sheet.tsx index 0e968ae..62317d4 100644 --- a/src/components/Componentes/report-actions-sheet.tsx +++ b/src/components/Componentes/report-actions-sheet.tsx @@ -8,6 +8,8 @@ import { downloadFile, isInFlutterWebView, openExternalUrl, + requestAutoLocation, + pickManualLocation, } from "@/lib/webview-actions"; const EXIT_ANIMATION_MS = 220; @@ -41,11 +43,28 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) { console.log("✅ WEB_READY ارسال شد"); }; - // ✅ دکمه دریافت موقعیت مکانی - // پل اکنون از کانال واقعی HabibApp استفاده می‌کند، پس یک‌بار ارسال کافی است. - const handleGetLocation = () => { - sendToFlutter("REQUEST_LOCATION"); - console.log("📍 REQUEST_LOCATION ارسال شد"); + // ✅ دکمه دریافت خودکار موقعیت مکانی GPS (Auto) + const handleAutoLocation = async () => { + try { + const data = await requestAutoLocation(); + alert(`📍 Auto Location: ${data.city || ""}, ${data.country || ""} (${data.latitude}, ${data.longitude})`); + } catch (e: any) { + alert(`❌ Auto Location Error: ${e.message}`); + } + }; + + // ✅ دکمه انتخاب دستی از روی نقشه فلاتر (Manual) + const handleManualLocation = async () => { + try { + const data = await pickManualLocation({ latitude: 35.6892, longitude: 51.3890 }); + if (data) { + alert(`🗺️ Selected Location: ${data.city || ""}, ${data.country || ""} (${data.latitude}, ${data.longitude})`); + } else { + alert("⚠️ Map selection cancelled"); + } + } catch (e: any) { + alert(`❌ Map Location Error: ${e.message}`); + } }; // ✅ دکمه مشاور @@ -200,7 +219,8 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
{/* Main buttons */}
- + + diff --git a/src/lib/geo-region.ts b/src/lib/geo-region.ts index c7e23db..c038b49 100644 --- a/src/lib/geo-region.ts +++ b/src/lib/geo-region.ts @@ -9,6 +9,8 @@ export type UserGeoRegion = { country?: string; countryCode?: string; // e.g. "IR", "US", "GB" phoneCode?: string; // e.g. "+98", "+1", "+44" + latitude?: number; + longitude?: number; }; const phoneUtil = PhoneNumberUtil.getInstance(); @@ -201,6 +203,8 @@ function fetchFlutterBridgeGeoRegion(): Promise { country: countryName, countryCode: isoCode, phoneCode: phoneCode || "+44", + latitude: (data as any).latitude, + longitude: (data as any).longitude, }; setStoredUserGeoRegion(region); finish(region); diff --git a/src/lib/webview-actions.ts b/src/lib/webview-actions.ts index d7c1050..7ef7f5a 100644 --- a/src/lib/webview-actions.ts +++ b/src/lib/webview-actions.ts @@ -180,3 +180,99 @@ export function openConsultantPage(username: string): boolean { return postActionToFlutter("open_consultant_page", { consultant: username }); } +// ─── Location Actions (Auto GPS & Manual Map) ───────────── + +export interface LocationResultData { + latitude: number; + longitude: number; + city?: string; + country?: string; + country_code?: string; +} + +/** + * Ask Flutter to request GPS permissions and return precise device location + * with reverse-geocoded city and country. + */ +export function requestAutoLocation( + timeoutMs = 15000, +): Promise { + return new Promise((resolve, reject) => { + if (!isInFlutterWebView()) { + reject(new Error("Not in Flutter WebView")); + return; + } + + let timer: ReturnType | null = null; + + const cleanup = () => { + if (timer) clearTimeout(timer); + unsubscribe?.(); + }; + + const unsubscribe = window.addFlutterResponseListener?.((event) => { + if (event.action === "get_auto_location") { + cleanup(); + if (event.success && event.data) { + resolve(event.data as LocationResultData); + } else { + reject(new Error(event.error || "Failed to get auto location")); + } + } + }); + + timer = setTimeout(() => { + cleanup(); + reject(new Error("Timeout waiting for auto location")); + }, timeoutMs); + + postActionToFlutter("get_auto_location"); + }); +} + +/** + * Ask Flutter to open native map dialog for manual location selection. + * Returns selected coordinates and geocoded info, or null if user cancelled. + */ +export function pickManualLocation( + initialCoords?: { latitude?: number; longitude?: number }, + timeoutMs = 120000, +): Promise { + return new Promise((resolve, reject) => { + if (!isInFlutterWebView()) { + reject(new Error("Not in Flutter WebView")); + return; + } + + let timer: ReturnType | null = null; + + const cleanup = () => { + if (timer) clearTimeout(timer); + unsubscribe?.(); + }; + + const unsubscribe = window.addFlutterResponseListener?.((event) => { + if (event.action === "pick_manual_location") { + cleanup(); + if (event.success && event.data) { + resolve(event.data as LocationResultData); + } else if (event.cancelled) { + resolve(null); + } else { + reject(new Error(event.error || "Failed to pick manual location")); + } + } + }); + + timer = setTimeout(() => { + cleanup(); + reject(new Error("Timeout waiting for manual location pick")); + }, timeoutMs); + + postActionToFlutter( + "pick_manual_location", + initialCoords as Record | undefined, + ); + }); +} + diff --git a/src/types/window.d.ts b/src/types/window.d.ts index a72dd9d..6798bbe 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -15,6 +15,8 @@ declare global { status?: string; /** Top-level error/info message */ message?: string; + error?: string; + cancelled?: boolean; data?: { // get_location latitude?: number;