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:
+158
@@ -0,0 +1,158 @@
|
||||
# 7 — SCAN QR: quét mã trên trang web để import recipe vào app
|
||||
|
||||
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
|
||||
Nguồn đối chiếu: nhánh `recipes-web`, commit **`8a889db`** ("web: the QR card
|
||||
hands out the look that made the photo") — bản web đã làm, đã đo, đang chạy ở
|
||||
`http://localhost:8090`.
|
||||
|
||||
Đây là ghi chú cho phần mềm Android **và đã implement trên nhánh này**. Khác
|
||||
các ghi chú 1–6 (chỉ nêu yêu cầu), ghi chú này ghi lại cả hai nửa: web đã mở ra
|
||||
cái gì, và app đọc cái đó bằng cách nào.
|
||||
|
||||
---
|
||||
|
||||
## 1. Web đã mở ra cái gì (phía `recipes-web`)
|
||||
|
||||
Trang admin có thêm **checkbox thứ tư `qr`** cạnh `strip / tester / creator`.
|
||||
Tick `qr` cho một ảnh nghĩa là: ảnh đó xuất hiện ở thẻ QR trên landing page.
|
||||
|
||||
Ảnh được tick `qr` phải có **preset** (`photos.recipe`, cột JSON). Khi có,
|
||||
thẻ QR trên landing trỏ tới một route công khai mới của backend:
|
||||
|
||||
```
|
||||
GET /api/photos/:id/preset.recipe
|
||||
```
|
||||
|
||||
| Trường hợp | Trả về |
|
||||
| --- | --- |
|
||||
| `id` không phải số, không có hàng, `recipe IS NULL`, hoặc ảnh không tick `qr` | `404` |
|
||||
| Hợp lệ | `200`, `content-type: application/xml; charset=utf-8`, `x-content-type-options: nosniff`, `content-disposition: attachment; filename="recipescam-<id>.recipe"` |
|
||||
|
||||
Nội dung file là **đúng định dạng `.recipe` mà app đã xuất/nhập**: XML bọc
|
||||
payload xor16-v1, sinh tại `docker/backend/src/recipeFile.ts` (bản sao của
|
||||
`shared/utils/recipeShare.ts`). Salt mới mỗi lần tải — hai lần quét cùng một mã
|
||||
ra hai file khác byte nhưng cùng recipe. `test/security.mjs` khoá hình dạng
|
||||
envelope này (+12 check, backend 134/134).
|
||||
|
||||
Vì vậy **mã QR chỉ chứa URL**, không chứa recipe. Vài chục byte, không phải
|
||||
vài KB base64 — camera chỉ cần bắt được một liên kết ngắn.
|
||||
|
||||
Nhãn i18n liên quan (đã có trên web): `adm.pickQr` = `QR card` / `Thẻ QR`,
|
||||
`adm.noPreset` = `no look to hand out` / `không có look để chia sẻ`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Vì sao KHÔNG dùng vision-camera để quét
|
||||
|
||||
Đường thẳng nhất là quét QR bằng chính session camera của viewfinder. Không
|
||||
được: **vision-camera v5 trên Android không có object output.**
|
||||
|
||||
```text
|
||||
modules/.../HybridCameraFactory.kt:112
|
||||
createObjectOutput → throw "CameraObjectOutput is not available on Android!"
|
||||
```
|
||||
|
||||
`useObjectOutput` là API iOS-only. Barcode/QR trên Android phải đi đường khác,
|
||||
nên app dùng **`expo-camera`** — nó bọc chính barcode scanner của Android.
|
||||
|
||||
```bash
|
||||
npx expo install expo-camera # ~57.0.5
|
||||
```
|
||||
|
||||
`app.json` thêm plugin trước `expo-sharing`:
|
||||
|
||||
```json
|
||||
["expo-camera", { "cameraPermission": "Allow RecipesCam to scan a recipe QR code." }]
|
||||
```
|
||||
|
||||
**Hệ quả bắt buộc: phải build lại dev client / APK.** `expo-camera` là native
|
||||
module mới; bundle JS cũ không có nó.
|
||||
|
||||
App giờ có **hai thư viện camera**. CameraX không cho hai client cùng giữ một
|
||||
ống kính, nên khi sheet quét mở thì viewfinder phải nhả camera ra — xem mục 4.
|
||||
|
||||
### 2.1 HTTP thuần (cleartext) — chỉ chạy ở debug
|
||||
|
||||
Route preset hiện phục vụ qua **HTTP thuần** (`http://<host>:8090/...`). Android
|
||||
9+ chặn cleartext mặc định, nhưng manifest debug đã bật sẵn:
|
||||
|
||||
```xml
|
||||
<!-- android/app/src/debug/AndroidManifest.xml -->
|
||||
<application android:usesCleartextTraffic="true" ... />
|
||||
```
|
||||
|
||||
`android/app/src/main/AndroidManifest.xml` **không** có dòng đó. Nghĩa là:
|
||||
|
||||
| Bản build | Quét thẻ QR |
|
||||
| --- | --- |
|
||||
| dev client / APK debug | chạy — cleartext được bật |
|
||||
| APK release | `fetch` bị chặn, `Import failed` |
|
||||
|
||||
Nếu bản release cũng phải quét được thì **một trong hai**: phục vụ landing qua
|
||||
HTTPS (tốt hơn — khi đó không phải đụng gì), hoặc bật cleartext cho release bằng
|
||||
`expo-build-properties` (`android.usesCleartextTraffic: true`) — plugin này chưa
|
||||
cài, nên chỉ thêm khi thật sự cần.
|
||||
|
||||
---
|
||||
|
||||
## 3. `src/components/QrScanModal.tsx` (mới)
|
||||
|
||||
Sheet quét: `Modal` + `CameraView` (`facing="back"`, `barcodeScannerSettings` chỉ
|
||||
`['qr']`), xin quyền bằng `useCameraPermissions()` khi mở, và một **chốt
|
||||
`takenRef`** — scanner báo mã ở mọi frame còn giữ mã, chốt chặn lần đầu để
|
||||
không import mười lần. Mở lại sheet thì chốt reset.
|
||||
|
||||
Props: `{ visible, onCancel, onScanned }`. Khung ngắm màu hổ phách + dòng
|
||||
`POINT AT A RECIPE QR` + nút X đóng. Khung ngắm `pointerEvents="none"` để cú
|
||||
chạm rơi xuống camera bên dưới.
|
||||
|
||||
---
|
||||
|
||||
## 4. Nối vào App
|
||||
|
||||
| File | Thay đổi |
|
||||
| --- | --- |
|
||||
| `App.tsx` | state `scanVisible`; `storeImportedRecipe(xml)` tách ra dùng chung với IMPORT; `handleScanRecipe(value)`; truyền `onScanRecipe` xuống panel; render `<QrScanModal>`; truyền `paused={scanVisible}` xuống viewfinder |
|
||||
| `src/components/AdjustmentPanel.tsx` | prop `onScanRecipe: () => void`; chip `SCAN QR` sau chip `IMPORT` trong tab `recipes` |
|
||||
| `src/components/Viewfinder.tsx` | prop `paused?: boolean` (mặc định `false`); `isActive={appActive && !ultraWideOn && !paused}`, `active={appActive && ultraWideOn && !paused}` |
|
||||
| `app.json` | plugin `expo-camera` + câu xin quyền |
|
||||
| `package.json` | `expo-camera ~57.0.5` |
|
||||
|
||||
`handleScanRecipe` **đóng sheet trước**, rồi mới xử lý — thất bại là một hộp
|
||||
thoại, không phải một scanner còn chạy. Cổng `liteSaveBlocked` /
|
||||
`promptRecipeLimit()` đứng trước, giống đường IMPORT.
|
||||
|
||||
Liên kết phải đúng route preset, không thì từ chối kèm lý do:
|
||||
|
||||
```ts
|
||||
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());
|
||||
```
|
||||
|
||||
Nhờ vậy mã QR của wifi, link shop, link app store… đều bị chặn bằng câu
|
||||
`That code is not a RecipesCam recipe link.` chứ không im lặng nuốt.
|
||||
|
||||
`storeImportedRecipe` chính là thân cũ của `handleImportRecipe`: dynamic-import
|
||||
`importRecipeXml` → `saveCustomRecipe` → `getAllRecipes` → `applyRecipe` →
|
||||
Haptics success → `Alert.alert('Recipe Imported', …)`. Hai đường (chọn file và
|
||||
quét mã) giờ dùng chung một chỗ lưu, nên recipe quét về nằm cùng danh sách
|
||||
custom như recipe import bằng file.
|
||||
|
||||
---
|
||||
|
||||
## 5. Kiểm thử
|
||||
|
||||
- `node --stack-size=12000 node_modules/typescript/bin/tsc --noEmit` → đúng 15
|
||||
lỗi có sẵn (`Viewfinder.tsx`, `skiaImage.ts`), **không lỗi nào ở
|
||||
`App.tsx` / `QrScanModal.tsx` / `AdjustmentPanel.tsx`.
|
||||
(`npx tsc --noEmit` trên cây Android crash `RangeError: Maximum call stack size exceeded` — lỗi có sẵn, phải truyền `--stack-size`.)
|
||||
- Phía web: `preset-roundtrip.cjs` 11/0 — file backend sinh ra import được bằng
|
||||
chính parser của app (tiếng Việt có dấu, `classic-vivid`, adjustments,
|
||||
`frameId`, `useGeotag`), và envelope lạ bị từ chối.
|
||||
- **Còn phải đo trên máy thật** sau khi build lại: quét thẻ QR trên landing →
|
||||
recipe xuất hiện trong PRESETS và look đổi; quét một mã không phải link
|
||||
RecipesCam → hộp thoại từ chối; mở sheet trong khi viewfinder đang chạy →
|
||||
không có lỗi CameraX "camera already in use".
|
||||
@@ -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}
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
"isAccessMediaLocationEnabled": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Allow RecipesCam to scan a recipe QR code."
|
||||
}
|
||||
],
|
||||
"expo-sharing"
|
||||
],
|
||||
"extra": {
|
||||
|
||||
Generated
+76
@@ -14,6 +14,7 @@
|
||||
"babel-preset-expo": "~57.0.0",
|
||||
"expo": "~57.0.7",
|
||||
"expo-asset": "~57.0.5",
|
||||
"expo-camera": "~57.0.5",
|
||||
"expo-dev-client": "~57.0.6",
|
||||
"expo-document-picker": "~57.0.2",
|
||||
"expo-file-system": "~57.0.1",
|
||||
@@ -2042,6 +2043,12 @@
|
||||
"integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/emscripten": {
|
||||
"version": "1.41.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.6.tgz",
|
||||
"integrity": "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
@@ -2448,6 +2455,15 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/barcode-detector": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.2.tgz",
|
||||
"integrity": "sha512-/4QOrrNrCRmDSBWiiP4aC72dnkuXUEdcFRidHgbPRXUpy82XcCMvJssI6fEs6JT42LS7DvBVZO70qasJbYKyrA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zxing-wasm": "3.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -3380,6 +3396,26 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-camera": {
|
||||
"version": "57.0.5",
|
||||
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-57.0.5.tgz",
|
||||
"integrity": "sha512-0JaUXfDWMytmqm4pVKesZfLTSjAumfS2UGhnc1M4tqJ0qATCS0jxc7KjaoyKEzWJY+FEc8GlPCAGprS/7tNWZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"barcode-detector": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-web": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-web": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-constants": {
|
||||
"version": "57.0.6",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.6.tgz",
|
||||
@@ -7420,6 +7456,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/tagged-tag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.19",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
@@ -7976,6 +8024,34 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zxing-wasm": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.3.tgz",
|
||||
"integrity": "sha512-3lC9BJk4fR5ZJxcGjb0hVnDFOW7KpLXHIebiBmVd4FDRQZVeObztoTKxRUPxlWwSgxrMRONK0u50hBD1aTYEKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/emscripten": "^1.41.5",
|
||||
"type-fest": "^5.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/emscripten": ">=1.39.6"
|
||||
}
|
||||
},
|
||||
"node_modules/zxing-wasm/node_modules/type-fest": {
|
||||
"version": "5.10.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz",
|
||||
"integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"dependencies": {
|
||||
"tagged-tag": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"babel-preset-expo": "~57.0.0",
|
||||
"expo": "~57.0.7",
|
||||
"expo-asset": "~57.0.5",
|
||||
"expo-camera": "~57.0.5",
|
||||
"expo-dev-client": "~57.0.6",
|
||||
"expo-document-picker": "~57.0.2",
|
||||
"expo-file-system": "~57.0.1",
|
||||
|
||||
@@ -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