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 SettingsModal from './src/components/SettingsModal';
|
||||||
import PhotoViewerModal from './src/components/PhotoViewerModal';
|
import PhotoViewerModal from './src/components/PhotoViewerModal';
|
||||||
import LibraryPickerModal from './src/components/LibraryPickerModal';
|
import LibraryPickerModal from './src/components/LibraryPickerModal';
|
||||||
|
import QrScanModal from './src/components/QrScanModal';
|
||||||
import ProModal from './src/components/ProModal';
|
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';
|
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);
|
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
|
||||||
// In-app library picker (grid -> full-screen preview -> USE/CANCEL).
|
// In-app library picker (grid -> full-screen preview -> USE/CANCEL).
|
||||||
const [libraryPickerVisible, setLibraryPickerVisible] = useState(false);
|
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
|
// Gear sheet (sound / ratio / metering mode). Camera captures never auto-open
|
||||||
// the viewer anymore — the thumbnail button does.
|
// the viewer anymore — the thumbnail button does.
|
||||||
const [settingsVisible, setSettingsVisible] = useState(false);
|
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
|
// PRESETS -> IMPORT: a recipe file shared from another phone, picked from
|
||||||
// storage, decoded and stored as a normal custom recipe.
|
// storage, decoded and stored as a normal custom recipe.
|
||||||
const handleImportRecipe = async () => {
|
const handleImportRecipe = async () => {
|
||||||
@@ -1038,13 +1054,30 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const picked = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true });
|
const picked = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true });
|
||||||
if (picked.canceled) return;
|
if (picked.canceled) return;
|
||||||
const xml = await FileSystem.readAsStringAsync(picked.assets[0].uri);
|
await storeImportedRecipe(await FileSystem.readAsStringAsync(picked.assets[0].uri));
|
||||||
const { importRecipeXml } = await import('./src/utils/recipeShare');
|
} catch (e) {
|
||||||
const created = await saveCustomRecipe(importRecipeXml(xml));
|
Alert.alert('Import failed', e instanceof Error ? e.message : String(e));
|
||||||
setRecipes(await getAllRecipes());
|
}
|
||||||
applyRecipe(created);
|
};
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
Alert.alert('Recipe Imported', `"${created.name}" has been added.`);
|
// 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) {
|
} catch (e) {
|
||||||
Alert.alert('Import failed', e instanceof Error ? e.message : String(e));
|
Alert.alert('Import failed', e instanceof Error ? e.message : String(e));
|
||||||
}
|
}
|
||||||
@@ -1821,6 +1854,9 @@ export default function App() {
|
|||||||
photoStraighten={shown.photoStraighten}
|
photoStraighten={shown.photoStraighten}
|
||||||
cameraPermissionGranted={cameraPermissionGranted}
|
cameraPermissionGranted={cameraPermissionGranted}
|
||||||
onRequestCameraPermission={requestCameraPermission}
|
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}
|
photoOutput={photoOutput}
|
||||||
rawOutput={rawPhotoOutput}
|
rawOutput={rawPhotoOutput}
|
||||||
rawEnabled={rawEnabled}
|
rawEnabled={rawEnabled}
|
||||||
@@ -1939,6 +1975,7 @@ export default function App() {
|
|||||||
onToggleGeotag={handleToggleGeotag}
|
onToggleGeotag={handleToggleGeotag}
|
||||||
onSaveRecipe={handleSaveRecipe}
|
onSaveRecipe={handleSaveRecipe}
|
||||||
onImportRecipe={handleImportRecipe}
|
onImportRecipe={handleImportRecipe}
|
||||||
|
onScanRecipe={() => setScanVisible(true)}
|
||||||
recipeLimitReached={liteSaveBlocked}
|
recipeLimitReached={liteSaveBlocked}
|
||||||
onRecipeLimit={promptRecipeLimit}
|
onRecipeLimit={promptRecipeLimit}
|
||||||
onDeleteRecipe={handleDeleteRecipe}
|
onDeleteRecipe={handleDeleteRecipe}
|
||||||
@@ -1965,6 +2002,14 @@ export default function App() {
|
|||||||
onSelect={handleLibraryPicked}
|
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
|
<CameraControls
|
||||||
lastPhotoUri={lastPhotoUri}
|
lastPhotoUri={lastPhotoUri}
|
||||||
onCapture={handleCapture}
|
onCapture={handleCapture}
|
||||||
|
|||||||
@@ -49,6 +49,12 @@
|
|||||||
"isAccessMediaLocationEnabled": true
|
"isAccessMediaLocationEnabled": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
"cameraPermission": "Allow RecipesCam to scan a recipe QR code."
|
||||||
|
}
|
||||||
|
],
|
||||||
"expo-sharing"
|
"expo-sharing"
|
||||||
],
|
],
|
||||||
"extra": {
|
"extra": {
|
||||||
|
|||||||
Generated
+76
@@ -14,6 +14,7 @@
|
|||||||
"babel-preset-expo": "~57.0.0",
|
"babel-preset-expo": "~57.0.0",
|
||||||
"expo": "~57.0.7",
|
"expo": "~57.0.7",
|
||||||
"expo-asset": "~57.0.5",
|
"expo-asset": "~57.0.5",
|
||||||
|
"expo-camera": "~57.0.5",
|
||||||
"expo-dev-client": "~57.0.6",
|
"expo-dev-client": "~57.0.6",
|
||||||
"expo-document-picker": "~57.0.2",
|
"expo-document-picker": "~57.0.2",
|
||||||
"expo-file-system": "~57.0.1",
|
"expo-file-system": "~57.0.1",
|
||||||
@@ -2042,6 +2043,12 @@
|
|||||||
"integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
|
"integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
"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": "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": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@@ -3380,6 +3396,26 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-constants": {
|
||||||
"version": "57.0.6",
|
"version": "57.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.6.tgz",
|
||||||
@@ -7420,6 +7456,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.19",
|
"version": "3.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||||
@@ -7976,6 +8024,34 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"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",
|
"babel-preset-expo": "~57.0.0",
|
||||||
"expo": "~57.0.7",
|
"expo": "~57.0.7",
|
||||||
"expo-asset": "~57.0.5",
|
"expo-asset": "~57.0.5",
|
||||||
|
"expo-camera": "~57.0.5",
|
||||||
"expo-dev-client": "~57.0.6",
|
"expo-dev-client": "~57.0.6",
|
||||||
"expo-document-picker": "~57.0.2",
|
"expo-document-picker": "~57.0.2",
|
||||||
"expo-file-system": "~57.0.1",
|
"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
|
// IMPORT: pick a recipe file shared from another phone and store it as a
|
||||||
// normal custom recipe.
|
// normal custom recipe.
|
||||||
onImportRecipe: () => void;
|
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
|
// 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.
|
// step. The strip's trailing + chip reads it, the save paths gate on it.
|
||||||
recipeLimitReached: boolean;
|
recipeLimitReached: boolean;
|
||||||
@@ -258,6 +261,7 @@ export default function AdjustmentPanel({
|
|||||||
onToggleWallLandscape,
|
onToggleWallLandscape,
|
||||||
onToggleGeotag,
|
onToggleGeotag,
|
||||||
onImportRecipe,
|
onImportRecipe,
|
||||||
|
onScanRecipe,
|
||||||
recipeLimitReached,
|
recipeLimitReached,
|
||||||
onRecipeLimit,
|
onRecipeLimit,
|
||||||
onDeleteRecipe,
|
onDeleteRecipe,
|
||||||
@@ -839,6 +843,12 @@ export default function AdjustmentPanel({
|
|||||||
active: false,
|
active: false,
|
||||||
onPress: onImportRecipe,
|
onPress: onImportRecipe,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'scan',
|
||||||
|
label: 'SCAN QR',
|
||||||
|
active: false,
|
||||||
|
onPress: onScanRecipe,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
8
|
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).
|
// a nonce so the panel puts the caret back in its input).
|
||||||
onWmEdit?: () => void;
|
onWmEdit?: () => void;
|
||||||
cameraPermissionGranted: boolean;
|
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;
|
onRequestCameraPermission: () => void;
|
||||||
photoOutput: CameraPhotoOutput;
|
photoOutput: CameraPhotoOutput;
|
||||||
// Composition ratio (FULL / 4:3 / 3:2 upright) driving the viewfinder band
|
// Composition ratio (FULL / 4:3 / 3:2 upright) driving the viewfinder band
|
||||||
@@ -303,6 +307,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
|||||||
availableHeight,
|
availableHeight,
|
||||||
topInset,
|
topInset,
|
||||||
cameraPermissionGranted,
|
cameraPermissionGranted,
|
||||||
|
paused = false,
|
||||||
onRequestCameraPermission,
|
onRequestCameraPermission,
|
||||||
photoOutput,
|
photoOutput,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
@@ -2871,7 +2876,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
|||||||
ref={skiaCameraRef}
|
ref={skiaCameraRef}
|
||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
device={device}
|
device={device}
|
||||||
isActive={appActive && !ultraWideOn}
|
isActive={appActive && !ultraWideOn && !paused}
|
||||||
outputs={cameraOutputs}
|
outputs={cameraOutputs}
|
||||||
pixelFormat="yuv"
|
pixelFormat="yuv"
|
||||||
targetResolution={frameTarget}
|
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. */}
|
turning one on means turning the other off, never both at once. */}
|
||||||
<UltraWidePreview
|
<UltraWidePreview
|
||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
active={appActive && ultraWideOn}
|
active={appActive && ultraWideOn && !paused}
|
||||||
cover={uwCover}
|
cover={uwCover}
|
||||||
/>
|
/>
|
||||||
{/* Gesture layer: tap = AE/AF lock; vertical drag on the image adjusts
|
{/* Gesture layer: tap = AE/AF lock; vertical drag on the image adjusts
|
||||||
|
|||||||
Reference in New Issue
Block a user