diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index 6e00126..7322def 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -72,8 +72,12 @@ export default function AdjustmentPanel({ // The single parameter whose slider row is open — a new selection replaces // the previous row (one row at a time, above the chips). const [openParam, setOpenParam] = useState(null); + // The single color-chrome group whose OFF/WEAK/STRONG strip is open above the + // chips (same one-row-at-a-time pattern as openParam, but for choice chips). + const [openGroup, setOpenGroup] = useState<'cx' | 'cxb' | null>(null); useEffect(() => { setOpenParam(null); + setOpenGroup(null); }, [activeTab]); const toggleParam = (key: string) => { @@ -81,6 +85,11 @@ export default function AdjustmentPanel({ setOpenParam((prev) => (prev === key ? null : key)); }; + const toggleGroup = (g: 'cx' | 'cxb') => { + haptic(); + setOpenGroup((prev) => (prev === g ? null : g)); + }; + const a = adjustments; const renderChip = (c: ChipDef) => { @@ -201,6 +210,23 @@ export default function AdjustmentPanel({ } }; + // Color-chrome groups: one toggle chip among the WB chips (COLOR CHROME / + // CHROME BLUE); tapping it reveals the OFF/WEAK/STRONG strip above the chip + // row. Amber text while an effect is active, amber border while its strip is + // open — same affordances as the parameter chips. + const chromeGroup: Record< + 'cx' | 'cxb', + { label: string; value: ColorAdjustments['colorChrome']; onChange: (v: ColorAdjustments['colorChrome']) => void } + > = { + cx: { label: 'COLOR CHROME', value: a.colorChrome || 'none', onChange: (v) => onUpdateAdjustments({ colorChrome: v }) }, + cxb: { label: 'CHROME BLUE', value: a.colorChromeBlue || 'none', onChange: (v) => onUpdateAdjustments({ colorChromeBlue: v }) }, + }; + const chromeOptions = [ + { v: 'none', d: 'OFF' }, + { v: 'weak', d: 'WEAK' }, + { v: 'strong', d: 'STRONG' }, + ]; + // ---- per-tab chip rows (bottom, above the tool rail) ----------------------- const chipsRow = (() => { switch (activeTab) { @@ -267,34 +293,6 @@ export default function AdjustmentPanel({ case 'wb': return ( - - {renderChip({ key: 'sep-cx', label: 'COLOR CHROME', disabled: true, active: false, onPress: () => {} })} - {choiceChips( - 'cx', - [ - { v: 'none', d: 'OFF' }, - { v: 'weak', d: 'WEAK' }, - { v: 'strong', d: 'STRONG' }, - ], - a.colorChrome || 'none', - (val) => onUpdateAdjustments({ colorChrome: val as ColorAdjustments['colorChrome'] }) - )} - {renderChip({ key: 'sep-cxb', label: 'CHROME BLUE', disabled: true, active: false, onPress: () => {} })} - {choiceChips( - 'cxb', - [ - { v: 'none', d: 'OFF' }, - { v: 'weak', d: 'WEAK' }, - { v: 'strong', d: 'STRONG' }, - ], - a.colorChromeBlue || 'none', - (val) => onUpdateAdjustments({ colorChromeBlue: val as ColorAdjustments['colorChromeBlue'] }) - )} - + renderChip({ + key: `grp-${g}`, + label: chromeGroup[g].label, + active: openGroup === g, + amberValue: openGroup !== g && chromeGroup[g].value !== 'none', + onPress: () => toggleGroup(g), + }) + )} ); @@ -401,6 +408,26 @@ export default function AdjustmentPanel({ )} + {openGroup && ( + + + {renderChip({ + key: `grp-hint-${openGroup}`, + label: chromeGroup[openGroup].label, + disabled: true, + active: false, + onPress: () => {}, + })} + {choiceChips(openGroup, chromeOptions, chromeGroup[openGroup].value, (v) => + chromeGroup[openGroup].onChange(v as ColorAdjustments['colorChrome']) + )} + + + )} {chipsRow} ); diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 3071004..ca9a180 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, useWindowDimensions, AppState } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useCameraDevice, type CameraPhotoOutput } from 'react-native-vision-camera'; import { SkiaCamera, type SkiaCameraRef } from 'react-native-vision-camera-skia'; import { createSynchronizable } from 'react-native-worklets'; @@ -93,6 +94,7 @@ export default function Viewfinder({ // Fullscreen hero: the viewfinder fills the entire screen, every overlay // rect derives from window size. const { width: vw, height: vh } = useWindowDimensions(); + const insets = useSafeAreaInsets(); // useCameraDevice resolves the back camera fresh on every render, so once the // permission lands (first-run flow: GRANT button → OS dialog) a re-render is @@ -315,11 +317,12 @@ export default function Viewfinder({ // the preview flicker. All live values flow through the synchronizables. type SkiaCameraOnFrame = NonNullable['onFrame']>; const toneFailLogged = useMemo(() => createSynchronizable(false), []); - // RETRO POLAROID on the live camera: the card + photo window are drawn here, - // in the frame pipeline, because only the worklet can scale the camera feed - // into the polaroid window (a JS overlay could only mask the fullscreen feed, - // which crops the scene instead of fitting it into the frame). - const polaroidCam = mode === 'camera' && selectedFrame === 'polaroid'; + // RETRO POLAROID on the live camera is NOT drawn in the frame pipeline: that + // canvas is an offscreen surface in frame-pixel space which RNSkia later + // cover-fits onto the view, so frame-space geometry overflows the screen and + // cannot be aligned with the dp-space overlays. The camera just renders the + // plain fullscreen feed here; the polaroid card is composited by the JS + // overlay (renderFrameOverlay) in view coordinates, like every other frame. const handleFrame = useCallback( (frame, render) => { 'worklet'; @@ -361,57 +364,11 @@ export default function Viewfinder({ } canvas.drawImage(frameTexture, 0, 0, paint); }; - if (polaroidCam) { - const W = frameTexture.width(); - const H = frameTexture.height(); - // Geometry mirror of utils/frameUtils.polaroidLayout('contain'), - // inlined because worklet code cannot call across module imports. - // Physical polaroid proportions (88x107 mm card, 79x79 mm print). - const SIDE = (88 - 79) / 2 / 88; // 0.05114 per side of card width - const DECK = (107 - 79 - (88 - 79) / 2) / 88; // 0.26705 bottom deck - const WINW = 1 - 2 * SIDE; // 0.89773 window width - const naturalAspect = SIDE + DECK + WINW / (W / H); // cardH / cardW - const cardW = Math.min(W, H / naturalAspect); - const cardH = cardW * naturalAspect; - const cx = (W - cardW) / 2; - const cy = (H - cardH) / 2; - const top = SIDE * cardW; - const maxWinH = cardH - top - DECK * cardW; - let winW = WINW * cardW; - let winH = winW / (W / H); - if (winH > maxWinH) { - winH = maxWinH; - winW = winH * (W / H); - } - const wx = cx + (cardW - winW) / 2; - const wy = cy + top; - // Letterbox bars (black) around the print, then the white card. - const bgPaint = Skia.Paint(); - bgPaint.setColor(Skia.Color('#000000')); - canvas.drawRect(Skia.XYWHRect(0, 0, W, H), bgPaint); - const cardPaint = Skia.Paint(); - cardPaint.setColor(Skia.Color('#faf9f6')); - canvas.drawRect(Skia.XYWHRect(cx, cy, cardW, cardH), cardPaint); - // Scale the whole live frame into the window: draw the pass over the - // full frame rect while the canvas is scaled, so the complete camera - // scene is squeezed into the polaroid window — never cropped. - canvas.save(); - canvas.translate(wx, wy); - canvas.scale(winW / W, winH / H); - drawPass(); - canvas.restore(); - const seamPaint = Skia.Paint(); - seamPaint.setColor(Skia.Color('#e5e5e5')); - seamPaint.setStyle(1); - seamPaint.setStrokeWidth(1); - canvas.drawRect(Skia.XYWHRect(wx + 0.5, wy + 0.5, winW - 1, winH - 1), seamPaint); - return; - } drawPass(); }); frame.dispose(); }, - [colorMatrixSync, toneSync, cinemaSync, toneFailLogged, polaroidCam] + [colorMatrixSync, toneSync, cinemaSync, toneFailLogged] ); const grainOpacity = adjustments.grain / 20; @@ -437,9 +394,9 @@ export default function Viewfinder({ const dh = h * s; return { dx: (vw - dw) / 2, dy: (vh - dh) / 2, dw, dh }; }, [skiaImage, vw, vh]); - // Polaroid card geometry (JS/overlay space). The live camera worklet mirrors - // the same math in frame-pixel space (see handleFrame) — both spaces share the - // same aspect ratio, so the overlay rects line up with the drawn card. + // Polaroid card geometry (JS/overlay space) for the CURRENT mode. Library + // stills keep the photo aspect; camera previews use the screen aspect (the + // feed fills the screen, so the card overlays it in view coordinates). const polaroidGeo = useMemo(() => { if (selectedFrame !== 'polaroid') return null; const aspect = @@ -540,10 +497,47 @@ export default function Viewfinder({ const renderFrameOverlay = () => { if (selectedFrame === 'none') return null; - // Polaroid is drawn by the camera frame pipeline (camera) or its own - // contain-fit canvas (library) — the overlay only handles the frames that - // sit on top of an already fullscreen picture. - if (selectedFrame === 'polaroid') return null; + // Library polaroid is drawn by its own contain-fit canvas (see the early + // return further down); the overlay here handles the frames that sit on top + // of an already fullscreen picture. + if (selectedFrame === 'polaroid') { + const geo = polaroidGeo; + if (!geo) return null; + const { card, window: win } = geo; + // Camera polaroid, composited in view (dp) coordinates: letterbox the + // live feed black outside the card, paint the white card around the photo + // window and a thin seam at the window edge. The window itself stays + // transparent so the fullscreen feed shows through underneath — drawn + // this way the card can never overflow the screen, and it lines up + // exactly with the grain clip / GPS anchor (same polaroidGeo rects). + const black = '#000000'; + const white = '#faf9f6'; + const seam = '#e5e5e5'; + return ( + + {/* black letterbox bars around the card */} + + + + + {/* white card area around the photo window */} + + + + + {/* seam between photo window and card */} + + + ); + } if (selectedFrame === 'classic-white') { return ( @@ -576,13 +570,14 @@ export default function Viewfinder({ const pad = (n: number) => String(n).padStart(2, '0'); const timestampStr = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`; - // Anchor to the PHOTO, not the screen. In camera mode the photo fills the - // screen (area = full canvas). In library mode the photo is contain-fitted - // with black letterbox — a screen-bottom baseline used to land below the - // photo on the letterbox, off the image, so users could not see it. Compute - // inside the fitted rect (imageFitRect) so the mark sits at the photo's - // bottom-left corner; keep it above the bottom chrome/shutter rail. With a - // polaroid frame the photo lives inside the card window — anchor to that. + // Anchor to the PHOTO's top-left, not the screen. Bottom anchoring put the + // mark under the floating chip rows (open WB/FRAME panel covered it), so it + // now lives at the top of the photo/window: the bottom sheet can never reach + // it. In camera mode the photo fills the screen; in library mode it is + // contain-fitted with letterbox — use imageFitRect so the mark sits on the + // photo corner. With a polaroid frame the photo lives inside the card + // window — anchor to that. Always clear the floating TopBar (recipe name + + // SAVE), which ends around insets.top + 56 dp. const win = polaroidGeo?.window ?? null; const area = win ? { dx: win.x, dy: win.y, dw: win.w, dh: win.h } @@ -590,17 +585,13 @@ export default function Viewfinder({ ? imageFitRect : { dx: 0, dy: 0, dw: vw, dh: vh }; const iconSize = Math.round(vw * 0.032); // matches customFont/iconFont size - const padBottom = win - ? Math.round(iconSize * 1.4) // polaroid: just above the window's bottom edge - : mode === 'camera' - ? Math.min(150, area.dh * 0.2) // keep clear of the bottom shutter deck - : Math.round(iconSize * 1.5); // library: sit at the photo corner - const yOffset = - mode === 'library' - ? Math.min(area.dy + area.dh - padBottom, vh - 190) // never under the deck - : area.dy + area.dh - padBottom; + const topBarBottom = insets.top + 56; + const topBase = Math.max(area.dy + Math.round(iconSize * 0.6), topBarBottom); const xOffset = area.dx + (win ? area.dw * 0.05 : Math.min(16, area.dw * 0.04)); + // Two stacked lines (pin/location above camera/timestamp). + const row1Y = topBase + Math.round(iconSize * 1.1); + const row2Y = topBase + Math.round(iconSize * 2.3); // One clear space (0.6em ≈ a mono space) between each icon and its text. const gap = (w: number) => Math.round(Math.max(w, iconSize) + iconSize * 0.6); const pinW = iconFont.measureText('📍'); @@ -609,10 +600,10 @@ export default function Viewfinder({ return ( {/* Icons white so they read as glyphs, not part of the amber text */} - - - - + + + + ); };