Ship Expo Router screens, seeded stores, and polished UX so auth, shop, checkout, prescriptions, loyalty, family, and account processes work end-to-end offline. Co-authored-by: Cursor <cursoragent@cursor.com>
197 lines
6.4 KiB
TypeScript
197 lines
6.4 KiB
TypeScript
import { CameraView, useCameraPermissions } from "expo-camera";
|
|
import * as ImagePicker from "expo-image-picker";
|
|
import { router } from "expo-router";
|
|
import { useRef, useState } from "react";
|
|
import { Alert, Image, Pressable, ScrollView, View } from "react-native";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { Card } from "@/components/ui/Card";
|
|
import { Screen } from "@/components/ui/Screen";
|
|
import { Text } from "@/components/ui/Text";
|
|
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
|
|
import { colors, gutter } from "@/lib/theme";
|
|
import { routes } from "@/lib/routes";
|
|
|
|
export default function RxScanScreen() {
|
|
const insets = useSafeAreaInsets();
|
|
const { t } = useTranslation();
|
|
const [permission, requestPermission] = useCameraPermissions();
|
|
const cameraRef = useRef<CameraView>(null);
|
|
const draftImages = usePrescriptionStore((s) => s.draftImages);
|
|
const setDraftImages = usePrescriptionStore((s) => s.setDraftImages);
|
|
const [ready, setReady] = useState(false);
|
|
|
|
const capture = async () => {
|
|
const photo = await cameraRef.current?.takePictureAsync({ quality: 0.7 });
|
|
if (photo?.uri) setDraftImages([...draftImages, photo.uri]);
|
|
};
|
|
|
|
const pick = async () => {
|
|
const res = await ImagePicker.launchImageLibraryAsync({
|
|
allowsMultipleSelection: true,
|
|
quality: 0.7,
|
|
});
|
|
if (!res.canceled) {
|
|
setDraftImages([...draftImages, ...res.assets.map((a) => a.uri)]);
|
|
}
|
|
};
|
|
|
|
const removeAt = (uri: string) => {
|
|
setDraftImages(draftImages.filter((u) => u !== uri));
|
|
};
|
|
|
|
if (!permission?.granted) {
|
|
return (
|
|
<Screen>
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
paddingTop: insets.top + 24,
|
|
paddingHorizontal: gutter,
|
|
justifyContent: "center",
|
|
gap: 14,
|
|
}}
|
|
>
|
|
<Text variant="h1">Scan prescription</Text>
|
|
<Text variant="body" color={colors.muted}>
|
|
Allow camera access to photograph your prescription, or upload existing photos.
|
|
</Text>
|
|
<Card elevated={false}>
|
|
<Text variant="bodySm" color={colors.muted}>
|
|
Tips: place the Rx on a flat surface, include the full page, and avoid glare.
|
|
</Text>
|
|
</Card>
|
|
<Button title="Grant camera permission" onPress={requestPermission} />
|
|
<Button title="Upload from photos" variant="outline" onPress={pick} />
|
|
{draftImages.length ? (
|
|
<Button
|
|
title={`Continue with ${draftImages.length} photo${draftImages.length === 1 ? "" : "s"}`}
|
|
variant="soft"
|
|
onPress={() => router.push(routes.rxReview)}
|
|
/>
|
|
) : null}
|
|
<Button title="Cancel" variant="ghost" onPress={() => router.back()} />
|
|
</View>
|
|
</Screen>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={{ flex: 1, backgroundColor: "#000" }}>
|
|
<CameraView
|
|
ref={cameraRef}
|
|
style={{ flex: 1 }}
|
|
facing="back"
|
|
onCameraReady={() => setReady(true)}
|
|
>
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
top: insets.top + 8,
|
|
left: 16,
|
|
right: 16,
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Pressable onPress={() => router.back()} hitSlop={8}>
|
|
<Ionicons name="close" size={28} color="#fff" />
|
|
</Pressable>
|
|
<Text variant="label" color="#fff">
|
|
{t("common.scanRx")}
|
|
</Text>
|
|
<Text variant="caption" color="rgba(255,255,255,0.75)">
|
|
{draftImages.length} saved
|
|
</Text>
|
|
</View>
|
|
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
top: "20%",
|
|
left: "8%",
|
|
right: "8%",
|
|
bottom: "30%",
|
|
borderWidth: 2,
|
|
borderColor: colors.brand,
|
|
borderRadius: 16,
|
|
}}
|
|
/>
|
|
<Text
|
|
variant="caption"
|
|
color="rgba(255,255,255,0.85)"
|
|
center
|
|
style={{ position: "absolute", top: "16%", alignSelf: "center", width: "80%" }}
|
|
>
|
|
Align the full prescription inside the frame
|
|
</Text>
|
|
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
bottom: 36,
|
|
left: 0,
|
|
right: 0,
|
|
alignItems: "center",
|
|
gap: 12,
|
|
}}
|
|
>
|
|
<ScrollView horizontal contentContainerStyle={{ gap: 8, paddingHorizontal: 16 }}>
|
|
{draftImages.map((uri) => (
|
|
<Pressable key={uri} onLongPress={() => removeAt(uri)}>
|
|
<Image
|
|
source={{ uri }}
|
|
style={{ width: 56, height: 56, borderRadius: 8 }}
|
|
/>
|
|
</Pressable>
|
|
))}
|
|
</ScrollView>
|
|
{draftImages.length ? (
|
|
<Text variant="micro" color="rgba(255,255,255,0.7)">
|
|
Long-press a thumbnail to remove
|
|
</Text>
|
|
) : null}
|
|
<View style={{ flexDirection: "row", gap: 24, alignItems: "center" }}>
|
|
<Pressable onPress={pick} accessibilityLabel="Upload photos">
|
|
<Ionicons name="images" size={28} color="#fff" />
|
|
</Pressable>
|
|
<Pressable
|
|
disabled={!ready}
|
|
onPress={capture}
|
|
accessibilityLabel="Capture"
|
|
style={{
|
|
width: 72,
|
|
height: 72,
|
|
borderRadius: 36,
|
|
backgroundColor: colors.brand,
|
|
borderWidth: 4,
|
|
borderColor: "#fff",
|
|
opacity: ready ? 1 : 0.5,
|
|
}}
|
|
/>
|
|
<Pressable
|
|
onPress={() => {
|
|
if (!draftImages.length) {
|
|
Alert.alert("Add a photo", "Capture or upload at least one image first.");
|
|
return;
|
|
}
|
|
router.push(routes.rxReview);
|
|
}}
|
|
accessibilityLabel="Continue to review"
|
|
>
|
|
<Ionicons
|
|
name="checkmark-circle"
|
|
size={34}
|
|
color={draftImages.length ? colors.brand : "#666"}
|
|
/>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
</CameraView>
|
|
</View>
|
|
);
|
|
}
|