365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
|
import {
|
|
View,
|
|
Text,
|
|
TouchableOpacity,
|
|
Share,
|
|
ActivityIndicator,
|
|
Platform,
|
|
} from "react-native";
|
|
import { ArrowLeft } from "lucide-react-native";
|
|
import ScreenWrapper from "~/components/ui/ScreenWrapper";
|
|
import { Button } from "~/components/ui/button";
|
|
import { router } from "expo-router";
|
|
import ModalToast from "~/components/ui/toast";
|
|
import { useTranslation } from "react-i18next";
|
|
import QRCode from "react-native-qrcode-svg";
|
|
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
|
|
import { UserQrService } from "~/lib/services/userQrService";
|
|
import {
|
|
CameraView,
|
|
useCameraPermissions,
|
|
type BarcodeScanningResult,
|
|
} from "expo-camera";
|
|
import { ROUTES } from "~/lib/routes";
|
|
import BackButton from "~/components/ui/backButton";
|
|
|
|
export default function QRScreen() {
|
|
const { t } = useTranslation();
|
|
const { user, profile, wallet } = useAuthWithProfile();
|
|
const [activeTab, setActiveTab] = useState<"scan" | "my">("scan");
|
|
const [toastVisible, setToastVisible] = useState(false);
|
|
const [toastTitle, setToastTitle] = useState("");
|
|
const [toastDescription, setToastDescription] = useState<string | undefined>(
|
|
undefined
|
|
);
|
|
const [toastVariant, setToastVariant] = useState<
|
|
"success" | "error" | "warning" | "info"
|
|
>("info");
|
|
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
// Scanner state
|
|
const [hasPermission, setHasPermission] = useState<null | boolean>(null);
|
|
const [scanned, setScanned] = useState(false);
|
|
const [permission, requestPermission] = useCameraPermissions();
|
|
|
|
const displayName =
|
|
profile?.fullName || user?.displayName || t("profile.usernamePlaceholder");
|
|
const phoneNumber = profile?.phoneNumber || (user as any)?.phoneNumber || "";
|
|
const accountId = wallet?.uid || user?.uid || "";
|
|
|
|
const [qrPayload, setQrPayload] = useState<string | null>(null);
|
|
|
|
const showToast = (
|
|
title: string,
|
|
description?: string,
|
|
variant: "success" | "error" | "warning" | "info" = "info"
|
|
) => {
|
|
if (toastTimeoutRef.current) {
|
|
clearTimeout(toastTimeoutRef.current);
|
|
}
|
|
|
|
setToastTitle(title);
|
|
setToastDescription(description);
|
|
setToastVariant(variant);
|
|
setToastVisible(true);
|
|
|
|
toastTimeoutRef.current = setTimeout(() => {
|
|
setToastVisible(false);
|
|
toastTimeoutRef.current = null;
|
|
}, 2500);
|
|
};
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (toastTimeoutRef.current) {
|
|
clearTimeout(toastTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Sync camera permission state for Scan tab
|
|
useEffect(() => {
|
|
if (Platform.OS === "web") {
|
|
setHasPermission(false);
|
|
return;
|
|
}
|
|
|
|
if (!permission) {
|
|
return;
|
|
}
|
|
|
|
setHasPermission(permission.granted);
|
|
}, [permission]);
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
const loadQr = async () => {
|
|
if (!user?.uid) return;
|
|
|
|
try {
|
|
const payload = await UserQrService.getOrCreateProfileQr({
|
|
uid: user.uid,
|
|
accountId,
|
|
name: displayName,
|
|
phoneNumber,
|
|
});
|
|
|
|
if (isMounted) {
|
|
setQrPayload(payload);
|
|
}
|
|
} catch (error) {
|
|
console.error("[QRScreen] Failed to load/create profile QR", error);
|
|
}
|
|
};
|
|
|
|
loadQr();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, [user?.uid, accountId, displayName, phoneNumber]);
|
|
|
|
const handleBarCodeScanned = ({ data }: BarcodeScanningResult) => {
|
|
if (scanned) return;
|
|
|
|
try {
|
|
setScanned(true);
|
|
|
|
const parsed = JSON.parse(data);
|
|
if (!parsed || parsed.type !== "AMBA_PROFILE") {
|
|
showToast(
|
|
"Invalid QR",
|
|
"This is not a valid Amba profile QR.",
|
|
"error"
|
|
);
|
|
setScanned(false);
|
|
return;
|
|
}
|
|
|
|
const accountIdFromQr: string | undefined = parsed.accountId;
|
|
const nameFromQr: string | undefined = parsed.name;
|
|
const phoneNumberFromQr: string | undefined = parsed.phoneNumber;
|
|
|
|
if (!phoneNumberFromQr) {
|
|
showToast(
|
|
"Invalid QR",
|
|
"This profile QR does not contain a phone number.",
|
|
"error"
|
|
);
|
|
setScanned(false);
|
|
return;
|
|
}
|
|
|
|
router.push({
|
|
pathname: ROUTES.SEND_OR_REQUEST_MONEY,
|
|
params: {
|
|
selectedContactId: accountIdFromQr || phoneNumberFromQr,
|
|
selectedContactName: nameFromQr || "User",
|
|
selectedContactPhone: phoneNumberFromQr,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.warn("[QRScreen] Failed to parse QR payload", error);
|
|
showToast("Scan failed", "Could not read this QR code.", "error");
|
|
setScanned(false);
|
|
}
|
|
};
|
|
|
|
const handleShare = async () => {
|
|
try {
|
|
await Share.share({
|
|
message: t("qrscreen.shareMessage"),
|
|
});
|
|
} catch (error) {
|
|
console.log("Error sharing QR:", error);
|
|
showToast(
|
|
t("qrscreen.toastErrorTitle"),
|
|
t("qrscreen.toastShareError"),
|
|
"error"
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleClose = () => {
|
|
router.back();
|
|
};
|
|
|
|
return (
|
|
<ScreenWrapper edges={[]}>
|
|
<View className="flex-1 bg-white">
|
|
{/* Top back button */}
|
|
<View className="flex-row justify-start">
|
|
<BackButton />
|
|
</View>
|
|
|
|
{/* Tabs - match KYC style */}
|
|
<View className="flex-row mt-5 mb-4 mx-6 bg-[#F3F4F6] rounded-full p-1">
|
|
<TouchableOpacity
|
|
onPress={() => {
|
|
setActiveTab("scan");
|
|
setScanned(false);
|
|
}}
|
|
className={`flex-1 items-center py-2 rounded-full ${
|
|
activeTab === "scan" ? "bg-white" : "bg-transparent"
|
|
}`}
|
|
activeOpacity={0.8}
|
|
>
|
|
<Text
|
|
className={`text-base font-dmsans-medium ${
|
|
activeTab === "scan" ? "text-[#0F7B4A]" : "text-gray-500"
|
|
}`}
|
|
>
|
|
{t("qrscreen.scanTabLabel", "Scan QR")}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity
|
|
onPress={() => setActiveTab("my")}
|
|
className={`flex-1 items-center py-2 rounded-full ${
|
|
activeTab === "my" ? "bg-white" : "bg-transparent"
|
|
}`}
|
|
activeOpacity={0.8}
|
|
>
|
|
<Text
|
|
className={`text-base font-dmsans-medium ${
|
|
activeTab === "my" ? "text-[#0F7B4A]" : "text-gray-500"
|
|
}`}
|
|
>
|
|
{t("qrscreen.myTabLabel", "My QR")}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Tab content */}
|
|
{activeTab === "my" ? (
|
|
<>
|
|
<View className="flex-1 items-center justify-center px-5">
|
|
{qrPayload ? (
|
|
<QRCode
|
|
value={qrPayload}
|
|
size={260}
|
|
color="#0F7B4A"
|
|
backgroundColor="white"
|
|
/>
|
|
) : (
|
|
<View className="items-center justify-center">
|
|
<ActivityIndicator size="large" color="#0F7B4A" />
|
|
<View className="h-3" />
|
|
<Text className="text-sm font-dmsans text-gray-600">
|
|
{t("qrscreen.loadingLabel", "Preparing your QR code...")}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
<View className="w-full flex flex-col px-5 pb-10 gap-4">
|
|
<Button
|
|
className="h-13 pb-6 rounded-full bg-[#FFB668]"
|
|
onPress={handleShare}
|
|
>
|
|
<Text className="text-white font-dmsans-bold text-base">
|
|
{t("qrscreen.shareButton")}
|
|
</Text>
|
|
</Button>
|
|
|
|
<Button
|
|
className="h-13 rounded-full bg-[#0F7B4A]"
|
|
onPress={handleClose}
|
|
>
|
|
<Text className="text-white font-dmsans-bold text-base">
|
|
{t("qrscreen.goBackButton")}
|
|
</Text>
|
|
</Button>
|
|
</View>
|
|
</>
|
|
) : (
|
|
<View className="flex-1 items-center justify-center px-5">
|
|
{Platform.OS === "web" ? (
|
|
<View className="flex-1 items-center justify-center px-5">
|
|
<Text className="text-base font-dmsans text-gray-600 text-center">
|
|
QR scanning is not supported on web. Please use a mobile
|
|
device.
|
|
</Text>
|
|
</View>
|
|
) : hasPermission === null ? (
|
|
<View className="flex-1 items-center justify-center px-5">
|
|
<ActivityIndicator size="large" color="#0F7B4A" />
|
|
<View className="h-4" />
|
|
<Text className="text-base font-dmsans text-gray-600">
|
|
Requesting camera permission...
|
|
</Text>
|
|
</View>
|
|
) : hasPermission === false ? (
|
|
<View className="flex-1 items-center justify-center px-5">
|
|
<Text className="text-base font-dmsans text-gray-800 mb-2 text-center">
|
|
Camera permission needed
|
|
</Text>
|
|
<Text className="text-sm font-dmsans text-gray-500 mb-4 text-center">
|
|
Please grant camera access to scan profile QR codes.
|
|
</Text>
|
|
<Button
|
|
className="rounded-full bg-primary px-8"
|
|
onPress={async () => {
|
|
try {
|
|
const result = await requestPermission();
|
|
if (result) {
|
|
setHasPermission(result.granted);
|
|
}
|
|
} catch (error) {
|
|
console.warn(
|
|
"[QRScreen] Failed to re-request camera permission",
|
|
error
|
|
);
|
|
showToast(
|
|
"Permission error",
|
|
"Could not update camera permission.",
|
|
"error"
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<Text className="text-white font-dmsans-medium">
|
|
Grant permission
|
|
</Text>
|
|
</Button>
|
|
</View>
|
|
) : (
|
|
<View className="flex-1 items-center justify-center px-5">
|
|
<View
|
|
style={{ overflow: "hidden", borderRadius: 24 }}
|
|
className="w-full aspect-square max-w-[320px] bg-black"
|
|
>
|
|
<CameraView
|
|
style={{ width: "100%", height: "100%" }}
|
|
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
|
|
onBarcodeScanned={
|
|
scanned ? undefined : handleBarCodeScanned
|
|
}
|
|
active={true}
|
|
/>
|
|
</View>
|
|
<View className="h-6" />
|
|
<Text className="text-base font-dmsans text-gray-800 mb-1 text-center">
|
|
Scan profile QR code
|
|
</Text>
|
|
<Text className="text-sm font-dmsans text-gray-500 text-center px-4">
|
|
Align the QR code inside the frame. We will automatically
|
|
select the account and take you to the amount screen.
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
</View>
|
|
<ModalToast
|
|
visible={toastVisible}
|
|
title={toastTitle}
|
|
description={toastDescription}
|
|
variant={toastVariant}
|
|
/>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|