android: scan the web recipe card's QR and import the look it points at
The landing page's QR card now carries a link to `GET /api/photos/:id/preset.recipe`, so the app can pick a look up off a screen instead of a file. PRESETS gains a SCAN QR chip next to IMPORT: the code is decoded, checked against the preset route, fetched, and stored through the same `storeImportedRecipe` path IMPORT already uses, so an imported look and a scanned look land in one place. Scanning runs on `expo-camera`, not the viewfinder's vision-camera: v5's object output is iOS-only, Android's `createObjectOutput` throws. That is a second camera library in the app, so the sheet pauses the viewfinder (`paused` prop) — CameraX will not let two clients hold one lens. A native rebuild is required for the new module. The code holds a URL, never the recipe, so anything that is not the preset route is refused by name rather than silently dropped. On a release APK the plain-HTTP link is blocked by Android's cleartext policy; debug builds have it on. Noted in 7_SCAN_QR.md along with the web side, the regex, and the paused-viewfinder rule.
This commit is contained in:
@@ -116,6 +116,9 @@ interface AdjustmentPanelProps {
|
||||
// IMPORT: pick a recipe file shared from another phone and store it as a
|
||||
// normal custom recipe.
|
||||
onImportRecipe: () => void;
|
||||
// SCAN QR: read the recipe link on the landing page's card with the camera
|
||||
// and import the file it points at — same store, no file picker.
|
||||
onScanRecipe: () => void;
|
||||
// LITE: this many of its own recipes exist, so the next one is the paid
|
||||
// step. The strip's trailing + chip reads it, the save paths gate on it.
|
||||
recipeLimitReached: boolean;
|
||||
@@ -258,6 +261,7 @@ export default function AdjustmentPanel({
|
||||
onToggleWallLandscape,
|
||||
onToggleGeotag,
|
||||
onImportRecipe,
|
||||
onScanRecipe,
|
||||
recipeLimitReached,
|
||||
onRecipeLimit,
|
||||
onDeleteRecipe,
|
||||
@@ -839,6 +843,12 @@ export default function AdjustmentPanel({
|
||||
active: false,
|
||||
onPress: onImportRecipe,
|
||||
},
|
||||
{
|
||||
key: 'scan',
|
||||
label: 'SCAN QR',
|
||||
active: false,
|
||||
onPress: onScanRecipe,
|
||||
},
|
||||
],
|
||||
8
|
||||
);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { Modal, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
|
||||
import { X } from 'lucide-react-native';
|
||||
|
||||
// SCAN QR: reads the code on the landing page's recipe card and hands the
|
||||
// decoded string to App, which fetches the `.recipe` file it points at. The
|
||||
// code carries a link, never the recipe itself, so the camera only ever has to
|
||||
// frame a few dozen bytes.
|
||||
//
|
||||
// This is the app's second camera on purpose: `expo-camera` (Android's own
|
||||
// barcode scanner) instead of the vision-camera session the viewfinder uses,
|
||||
// because vision-camera v5's object output is iOS-only — Android's
|
||||
// `createObjectOutput` throws "CameraObjectOutput is not available on
|
||||
// Android!". App keeps the two from fighting over the lens by pausing the
|
||||
// viewfinder while this sheet is up (`Viewfinder paused`).
|
||||
interface QrScanModalProps {
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
onScanned: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function QrScanModal({ visible, onCancel, onScanned }: QrScanModalProps) {
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
// One code per opening: the scanner reports every frame that holds a code, so
|
||||
// the first one trips this latch and the rest are dropped until the sheet is
|
||||
// opened again.
|
||||
const takenRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
takenRef.current = false;
|
||||
if (permission && !permission.granted && permission.canAskAgain) requestPermission();
|
||||
}, [visible, permission, requestPermission]);
|
||||
|
||||
const handleScanned = useCallback(
|
||||
(result: BarcodeScanningResult) => {
|
||||
if (takenRef.current) return;
|
||||
takenRef.current = true;
|
||||
onScanned(result.data);
|
||||
},
|
||||
[onScanned],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="fade" statusBarTranslucent onRequestClose={onCancel}>
|
||||
<View className="flex-1 bg-black">
|
||||
{permission?.granted ? (
|
||||
<CameraView
|
||||
style={{ flex: 1 }}
|
||||
facing="back"
|
||||
active={visible}
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={handleScanned}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center p-8">
|
||||
<Text className="text-zinc-400 font-mono text-xs text-center leading-5">
|
||||
CAMERA PERMISSION IS NEEDED TO SCAN A RECIPE CODE
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Reticle. Pointer-transparent so the touch goes to the camera. */}
|
||||
<View className="absolute inset-0 items-center justify-center" pointerEvents="none">
|
||||
<View className="w-64 h-64 rounded-2xl border-2 border-amber-500/80" />
|
||||
<Text className="mt-6 text-amber-500 font-mono text-xs tracking-widest">
|
||||
POINT AT A RECIPE QR
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={onCancel}
|
||||
accessibilityLabel="Close the QR scanner"
|
||||
className="absolute top-12 right-6 w-10 h-10 rounded-full bg-black/60 border border-zinc-700 items-center justify-center"
|
||||
>
|
||||
<X color="#ffffff" size={20} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -65,6 +65,10 @@ interface ViewfinderProps {
|
||||
// a nonce so the panel puts the caret back in its input).
|
||||
onWmEdit?: () => void;
|
||||
cameraPermissionGranted: boolean;
|
||||
// Another camera session wants the lens (the QR scanner's own sheet): the
|
||||
// live session stops until it is up, exactly as it does for the app going
|
||||
// to the background — two CameraX clients cannot hold one camera.
|
||||
paused?: boolean;
|
||||
onRequestCameraPermission: () => void;
|
||||
photoOutput: CameraPhotoOutput;
|
||||
// Composition ratio (FULL / 4:3 / 3:2 upright) driving the viewfinder band
|
||||
@@ -303,6 +307,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
availableHeight,
|
||||
topInset,
|
||||
cameraPermissionGranted,
|
||||
paused = false,
|
||||
onRequestCameraPermission,
|
||||
photoOutput,
|
||||
aspectRatio,
|
||||
@@ -2871,7 +2876,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
ref={skiaCameraRef}
|
||||
style={StyleSheet.absoluteFill}
|
||||
device={device}
|
||||
isActive={appActive && !ultraWideOn}
|
||||
isActive={appActive && !ultraWideOn && !paused}
|
||||
outputs={cameraOutputs}
|
||||
pixelFormat="yuv"
|
||||
targetResolution={frameTarget}
|
||||
@@ -2886,7 +2891,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
turning one on means turning the other off, never both at once. */}
|
||||
<UltraWidePreview
|
||||
style={StyleSheet.absoluteFill}
|
||||
active={appActive && ultraWideOn}
|
||||
active={appActive && ultraWideOn && !paused}
|
||||
cover={uwCover}
|
||||
/>
|
||||
{/* Gesture layer: tap = AE/AF lock; vertical drag on the image adjusts
|
||||
|
||||
Reference in New Issue
Block a user