Browse Source

feat(location): add auto GPS and manual map picker bridge integration with initial empty state

master
mortezaei 2 days ago
parent
commit
6d7a125548
  1. 215
      src/components/Componentes/question-birthplace.tsx
  2. 68
      src/components/Componentes/question-snap-list.test.tsx
  3. 73
      src/components/Componentes/question-snap-list.tsx
  4. 32
      src/components/Componentes/report-actions-sheet.tsx
  5. 4
      src/lib/geo-region.ts
  6. 96
      src/lib/webview-actions.ts
  7. 2
      src/types/window.d.ts

215
src/components/Componentes/question-birthplace.tsx

@ -19,6 +19,11 @@ import {
getStoredUserGeoRegion, getStoredUserGeoRegion,
subscribeToUserGeoRegion, subscribeToUserGeoRegion,
} from "@/lib/geo-region"; } from "@/lib/geo-region";
import {
isInFlutterWebView,
requestAutoLocation,
pickManualLocation,
} from "@/lib/webview-actions";
const EXIT_ANIMATION_MS = 220; const EXIT_ANIMATION_MS = 220;
@ -171,23 +176,11 @@ export function QuestionBirthplace({
const isInitialManual = mode === "manual"; const isInitialManual = mode === "manual";
const defaultCountryFallback =
isResidence && locale === "fa"
? resolveCountryName("IR", "fa") || "ایران"
const localizedInitialCountry = hasSavedAnswer
? resolveCountryName(initial.country, locale) || initial.country
: ""; : "";
const localizedInitialCountry =
resolveCountryName(initial.country, locale) ||
initial.country ||
(!hasSavedAnswer && !isInitialManual && storedRegion?.country
? resolveCountryName(storedRegion.country, locale) || storedRegion.country
: defaultCountryFallback);
const initialCity =
initial.city ||
(!hasSavedAnswer && !isInitialManual && storedRegion?.city
? storedRegion.city
: "");
const initialCity = hasSavedAnswer ? initial.city || "" : "";
const initialLoc = const initialLoc =
localizedInitialCountry || initialCity localizedInitialCountry || initialCity
@ -202,6 +195,12 @@ export function QuestionBirthplace({
const cityInputStateRef = useRef(initialCity); const cityInputStateRef = useRef(initialCity);
const selectedCountryStateRef = useRef(localizedInitialCountry || ""); const selectedCountryStateRef = useRef(localizedInitialCountry || "");
const lastCoordsRef = useRef<{ latitude?: number; longitude?: number } | undefined>(
storedRegion?.latitude && storedRegion?.longitude
? { latitude: storedRegion.latitude, longitude: storedRegion.longitude }
: undefined,
);
useEffect(() => { useEffect(() => {
cityInputStateRef.current = cityInput; cityInputStateRef.current = cityInput;
}, [cityInput]); }, [cityInput]);
@ -221,7 +220,6 @@ export function QuestionBirthplace({
const [isDetecting, setIsDetecting] = useState(false); const [isDetecting, setIsDetecting] = useState(false);
const [detectedLocation, setDetectedLocation] = useState(initialLoc); const [detectedLocation, setDetectedLocation] = useState(initialLoc);
const hasAutoDetectedRef = useRef(false);
useEffect(() => { useEffect(() => {
isMountedRef.current = true; isMountedRef.current = true;
@ -283,25 +281,25 @@ export function QuestionBirthplace({
[question, setAnswerValue], [question, setAnswerValue],
); );
// Subscribe to live geo region updates (e.g. when Flutter bridge responds asynchronously)
useEffect(() => {
if (!isResidence) return;
const handleAutoClick = async () => {
if (typeof window !== "undefined") {
localStorage.setItem(`residence_mode_${question.id}`, "auto");
}
setMode("auto");
setIsDetecting(true);
const unsubscribe = subscribeToUserGeoRegion((region) => {
try {
if (isInFlutterWebView()) {
const data = await requestAutoLocation();
if (!isMountedRef.current) return; 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 || "";
lastCoordsRef.current = {
latitude: data.latitude,
longitude: data.longitude,
};
const rawCountry = data.country || data.country_code || "";
const country = const country =
resolveCountryName(rawCountry, locale) ||
rawCountry ||
defaultCountryFallback;
const city = region.city || "";
resolveCountryName(rawCountry, locale) || rawCountry || "";
const city = data.city || "";
if (country || city) { if (country || city) {
setSelectedCountry(country); setSelectedCountry(country);
@ -311,68 +309,21 @@ export function QuestionBirthplace({
const loc = [country, city].filter(Boolean).join(", "); const loc = [country, city].filter(Boolean).join(", ");
setDetectedLocation(loc); setDetectedLocation(loc);
updateAnswers(country, city); 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 storedMode =
typeof window !== "undefined"
? localStorage.getItem(`residence_mode_${question.id}`)
: null;
if (storedMode === "manual") {
setMode("manual");
} else { } else {
setMode("auto");
}
return;
}
}
setIsDetecting(true);
try {
const region = await getUserGeoRegion(force);
const region = await getUserGeoRegion(true);
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
const city = region.city || ""; const city = region.city || "";
const rawCountry = region.country || region.countryCode || ""; const rawCountry = region.country || region.countryCode || "";
const country = 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) { if (country || city) {
setSelectedCountry(country); setSelectedCountry(country);
@ -383,83 +334,49 @@ export function QuestionBirthplace({
setDetectedLocation(loc); setDetectedLocation(loc);
updateAnswers(country, city); updateAnswers(country, city);
} }
} catch {
// Keep in auto mode on error, do not force manual
}
} catch (err) {
console.warn("Auto location error:", err);
} finally { } finally {
if (isMountedRef.current) { if (isMountedRef.current) {
setIsDetecting(false); 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);
}
}
}, [
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") { if (typeof window !== "undefined") {
localStorage.setItem(`residence_mode_${question.id}`, "manual"); localStorage.setItem(`residence_mode_${question.id}`, "manual");
} }
setMode("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;
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); setSelectedCountry(country);
selectedCountryStateRef.current = country; selectedCountryStateRef.current = country;
setCityInput(city); setCityInput(city);
cityInputStateRef.current = city; cityInputStateRef.current = city;
const loc = [country, city].filter(Boolean).join(", ");
setDetectedLocation(loc);
updateAnswers(country, city); updateAnswers(country, city);
setDetectedLocation([country, city].filter(Boolean).join(", "));
}
}
} catch (err) {
console.warn("Manual map pick error:", err);
}
}
}; };
// Synchronize state ONLY if rawValue changes externally (e.g. draft fetch or reset) // Synchronize state ONLY if rawValue changes externally (e.g. draft fetch or reset)

68
src/components/Componentes/question-snap-list.test.tsx

@ -1,4 +1,5 @@
import { import {
act,
cleanup, cleanup,
fireEvent, fireEvent,
render, render,
@ -443,4 +444,71 @@ describe("QuestionSnapList keyboard interaction", () => {
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1); 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(
<QuestionSnapList
firstQuestionHint={<span data-testid="scroll-hint">Scroll icon</span>}
>
<div>Question 1</div>
<div>Question 2</div>
</QuestionSnapList>,
);
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();
});
});
}); });

73
src/components/Componentes/question-snap-list.tsx

@ -105,6 +105,7 @@ export function QuestionSnapList({
const previousActiveIndexRef = useRef<number | null>(null); const previousActiveIndexRef = useRef<number | null>(null);
const suppressNextClickRef = useRef(false); const suppressNextClickRef = useRef(false);
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [isHintVisible, setIsHintVisible] = useState(false);
const activeIndexRef = useRef(activeIndex); const activeIndexRef = useRef(activeIndex);
activeIndexRef.current = activeIndex; activeIndexRef.current = activeIndex;
@ -247,6 +248,76 @@ export function QuestionSnapList({
previousActiveIndexRef.current = activeIndex; previousActiveIndexRef.current = activeIndex;
}, [activeIndex, onQuestionTransition]); }, [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(() => { useEffect(() => {
return () => { return () => {
if (wheelUnlockTimeoutRef.current !== null) { if (wheelUnlockTimeoutRef.current !== null) {
@ -760,7 +831,7 @@ export function QuestionSnapList({
className={[ className={[
"pointer-events-none absolute bottom-24 left-1/2 -translate-x-1/2", "pointer-events-none absolute bottom-24 left-1/2 -translate-x-1/2",
"transition-opacity duration-500 motion-safe:animate-bounce", "transition-opacity duration-500 motion-safe:animate-bounce",
activeIndex === 0 ? "opacity-100" : "opacity-0",
isHintVisible ? "opacity-100" : "opacity-0",
].join(" ")} ].join(" ")}
> >
{firstQuestionHint} {firstQuestionHint}

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

@ -8,6 +8,8 @@ import {
downloadFile, downloadFile,
isInFlutterWebView, isInFlutterWebView,
openExternalUrl, openExternalUrl,
requestAutoLocation,
pickManualLocation,
} from "@/lib/webview-actions"; } from "@/lib/webview-actions";
const EXIT_ANIMATION_MS = 220; const EXIT_ANIMATION_MS = 220;
@ -41,11 +43,28 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
console.log("✅ WEB_READY ارسال شد"); 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) {
<div className="p-3.5 flex flex-col gap-3"> <div className="p-3.5 flex flex-col gap-3">
{/* Main buttons */} {/* Main buttons */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Button onClick={handleGetLocation}>📍 Get Location</Button>
<Button onClick={handleAutoLocation}>📍 Auto Location (GPS)</Button>
<Button onClick={handleManualLocation}>🗺 Manual Location (Map)</Button>
<Button onClick={handleOpenConsultant}> <Button onClick={handleOpenConsultant}>
👨 Habib Consultation 👨 Habib Consultation
</Button> </Button>

4
src/lib/geo-region.ts

@ -9,6 +9,8 @@ export type UserGeoRegion = {
country?: string; country?: string;
countryCode?: string; // e.g. "IR", "US", "GB" countryCode?: string; // e.g. "IR", "US", "GB"
phoneCode?: string; // e.g. "+98", "+1", "+44" phoneCode?: string; // e.g. "+98", "+1", "+44"
latitude?: number;
longitude?: number;
}; };
const phoneUtil = PhoneNumberUtil.getInstance(); const phoneUtil = PhoneNumberUtil.getInstance();
@ -201,6 +203,8 @@ function fetchFlutterBridgeGeoRegion(): Promise<UserGeoRegion> {
country: countryName, country: countryName,
countryCode: isoCode, countryCode: isoCode,
phoneCode: phoneCode || "+44", phoneCode: phoneCode || "+44",
latitude: (data as any).latitude,
longitude: (data as any).longitude,
}; };
setStoredUserGeoRegion(region); setStoredUserGeoRegion(region);
finish(region); finish(region);

96
src/lib/webview-actions.ts

@ -180,3 +180,99 @@ export function openConsultantPage(username: string): boolean {
return postActionToFlutter("open_consultant_page", { consultant: username }); 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<LocationResultData> {
return new Promise((resolve, reject) => {
if (!isInFlutterWebView()) {
reject(new Error("Not in Flutter WebView"));
return;
}
let timer: ReturnType<typeof setTimeout> | 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<LocationResultData | null> {
return new Promise((resolve, reject) => {
if (!isInFlutterWebView()) {
reject(new Error("Not in Flutter WebView"));
return;
}
let timer: ReturnType<typeof setTimeout> | 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<string, unknown> | undefined,
);
});
}

2
src/types/window.d.ts

@ -15,6 +15,8 @@ declare global {
status?: string; status?: string;
/** Top-level error/info message */ /** Top-level error/info message */
message?: string; message?: string;
error?: string;
cancelled?: boolean;
data?: { data?: {
// get_location // get_location
latitude?: number; latitude?: number;

Loading…
Cancel
Save