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:
@@ -16,6 +16,7 @@ import ToolRail, { TabId } from './src/components/ToolRail';
|
||||
import SettingsModal from './src/components/SettingsModal';
|
||||
import PhotoViewerModal from './src/components/PhotoViewerModal';
|
||||
import LibraryPickerModal from './src/components/LibraryPickerModal';
|
||||
import QrScanModal from './src/components/QrScanModal';
|
||||
import ProModal from './src/components/ProModal';
|
||||
|
||||
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, FlashMode, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT } from './src/types';
|
||||
@@ -277,6 +278,9 @@ export default function App() {
|
||||
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
|
||||
// In-app library picker (grid -> full-screen preview -> USE/CANCEL).
|
||||
const [libraryPickerVisible, setLibraryPickerVisible] = useState(false);
|
||||
// QR sheet: reads a recipe link off a screen, so the viewfinder camera behind
|
||||
// it is paused while it is up (one lens, two camera libraries).
|
||||
const [scanVisible, setScanVisible] = useState(false);
|
||||
// Gear sheet (sound / ratio / metering mode). Camera captures never auto-open
|
||||
// the viewer anymore — the thumbnail button does.
|
||||
const [settingsVisible, setSettingsVisible] = useState(false);
|
||||
@@ -1028,6 +1032,18 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// PRESETS -> IMPORT / SCAN QR: a `.recipe` file that arrived from elsewhere —
|
||||
// picked off storage or fetched from a scanned link — becomes a normal custom
|
||||
// recipe, and the look on screen switches to it.
|
||||
const storeImportedRecipe = async (xml: string) => {
|
||||
const { importRecipeXml } = await import('./src/utils/recipeShare');
|
||||
const created = await saveCustomRecipe(importRecipeXml(xml));
|
||||
setRecipes(await getAllRecipes());
|
||||
applyRecipe(created);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
Alert.alert('Recipe Imported', `"${created.name}" has been added.`);
|
||||
};
|
||||
|
||||
// PRESETS -> IMPORT: a recipe file shared from another phone, picked from
|
||||
// storage, decoded and stored as a normal custom recipe.
|
||||
const handleImportRecipe = async () => {
|
||||
@@ -1038,13 +1054,30 @@ export default function App() {
|
||||
try {
|
||||
const picked = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true });
|
||||
if (picked.canceled) return;
|
||||
const xml = await FileSystem.readAsStringAsync(picked.assets[0].uri);
|
||||
const { importRecipeXml } = await import('./src/utils/recipeShare');
|
||||
const created = await saveCustomRecipe(importRecipeXml(xml));
|
||||
setRecipes(await getAllRecipes());
|
||||
applyRecipe(created);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
Alert.alert('Recipe Imported', `"${created.name}" has been added.`);
|
||||
await storeImportedRecipe(await FileSystem.readAsStringAsync(picked.assets[0].uri));
|
||||
} catch (e) {
|
||||
Alert.alert('Import failed', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
// PRESETS -> SCAN QR: the landing page's recipe card carries a link to a
|
||||
// photo's own `.recipe` file, so a scan is the same import with the file
|
||||
// fetched instead of picked. The code holds a URL, never the recipe, so
|
||||
// anything that is not the preset route — a shop link, a wifi code — is
|
||||
// refused with the reason. The sheet closes either way; a failure is a
|
||||
// dialog, not a scanner left running.
|
||||
const handleScanRecipe = async (value: string) => {
|
||||
setScanVisible(false);
|
||||
if (liteSaveBlocked) {
|
||||
promptRecipeLimit();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const link = /^https?:\/\/\S+\/api\/photos\/\d+\/preset\.recipe$/.exec(value.trim());
|
||||
if (!link) throw new Error('That code is not a RecipesCam recipe link.');
|
||||
const res = await fetch(link[0]);
|
||||
if (!res.ok) throw new Error(`The recipe link answered ${res.status}.`);
|
||||
await storeImportedRecipe(await res.text());
|
||||
} catch (e) {
|
||||
Alert.alert('Import failed', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
@@ -1821,6 +1854,9 @@ export default function App() {
|
||||
photoStraighten={shown.photoStraighten}
|
||||
cameraPermissionGranted={cameraPermissionGranted}
|
||||
onRequestCameraPermission={requestCameraPermission}
|
||||
// One lens, two camera libraries: while the QR sheet is up its own
|
||||
// scanner owns the camera and this session has to let go.
|
||||
paused={scanVisible}
|
||||
photoOutput={photoOutput}
|
||||
rawOutput={rawPhotoOutput}
|
||||
rawEnabled={rawEnabled}
|
||||
@@ -1939,6 +1975,7 @@ export default function App() {
|
||||
onToggleGeotag={handleToggleGeotag}
|
||||
onSaveRecipe={handleSaveRecipe}
|
||||
onImportRecipe={handleImportRecipe}
|
||||
onScanRecipe={() => setScanVisible(true)}
|
||||
recipeLimitReached={liteSaveBlocked}
|
||||
onRecipeLimit={promptRecipeLimit}
|
||||
onDeleteRecipe={handleDeleteRecipe}
|
||||
@@ -1965,6 +2002,14 @@ export default function App() {
|
||||
onSelect={handleLibraryPicked}
|
||||
/>
|
||||
|
||||
{/* QR sheet: reads a recipe link off another screen (the landing page's
|
||||
card) and imports the file it points at. */}
|
||||
<QrScanModal
|
||||
visible={scanVisible}
|
||||
onCancel={() => setScanVisible(false)}
|
||||
onScanned={handleScanRecipe}
|
||||
/>
|
||||
|
||||
<CameraControls
|
||||
lastPhotoUri={lastPhotoUri}
|
||||
onCapture={handleCapture}
|
||||
|
||||
Reference in New Issue
Block a user