97949e449d
Both flavors build from one JS tree: `lite` hands out the free apk (com.locphamtran.recipescamera), `pro` sells as-is (same id + .pro, label "RecipesCam Pro"). Release is signed from android/keystore.properties, both files gitignored - lose either and no later build can install over this one. The tier reaches JS through src/provariant.ts, which tools/set-variant.mjs rewrites before each build (`npm run apk:lite` / `apk:pro`); the gradle flavor alone would ship a Pro apk that thinks it is Lite. The dev scaffolding goes: the three src/dev probes and their App effects, the EXPO_PUBLIC_CAPTURE_MODE [CAPTURE] timing log, and the -PdevtestSuffix side-by-side install hook. 'balanced' stays the shipped capture mode, which is what those probes measured on the 12S Ultra. Looking at the picture: WB now converts kelvin through the Planckian locus into a luma-normalised gain (unity at 5500K, symmetric tint) instead of the old one-sided push; LEITZ STREETLIFE splits into CLASSIC and VIVID; selecting a recipe loads every knob it carries over the defaults and stays editable, and RESET hands the panel back to the sim's own values. The in-app library picker, the ultra-wide native module and recipe share/import land here too. CLARITY and LIGHT GLOW existed only on the camera worklet and the export engine, so a photo opened from the library ignored both. They now run in all three library preview branches (libPhoto), and the preview clips them to the drawn image - the export engine always clipped its bloom to the canvas, the preview drew it over the whole rect, so the bloom bled past the photo onto the black letterbox and over the frame's mat.
167 lines
5.0 KiB
TypeScript
167 lines
5.0 KiB
TypeScript
import React, { useRef } from 'react';
|
|
import { Animated, PanResponder } from 'react-native';
|
|
|
|
// Pinch / double-tap zoom for one preview photo inside the paging strip.
|
|
// Core PanResponder + Animated only (no react-native-gesture-handler dep).
|
|
// While zoomed in, responder termination is refused so the parent ScrollView
|
|
// cannot steal the gesture — the photo pans instead of paging; swiping to the
|
|
// next photo resumes after zooming back out (double tap or pinch below 1.15).
|
|
// Pinch zooms from any scale: a two-finger gesture never pages the strip, so
|
|
// pinching out of 100% cannot be mistaken for a swipe.
|
|
//
|
|
// ponytail: pinch zooms around the image center, not the finger midpoint;
|
|
// focal zoom needs per-touch location math against the image's screen rect.
|
|
// MAX_SCALE is a hard cap — add a dedicated slider/stepper when 4x is not
|
|
// enough.
|
|
|
|
interface ZoomableImageProps {
|
|
uri: string;
|
|
width: number;
|
|
}
|
|
|
|
const MAX_SCALE = 4;
|
|
const TAP_SCALE = 2.5;
|
|
const TAP_MS = 300;
|
|
|
|
export default function ZoomableImage({ uri, width }: ZoomableImageProps) {
|
|
const scaleAn = useRef(new Animated.Value(1)).current;
|
|
const txAn = useRef(new Animated.Value(0)).current;
|
|
const tyAn = useRef(new Animated.Value(0)).current;
|
|
|
|
const st = useRef({
|
|
scale: 1,
|
|
tx: 0,
|
|
ty: 0,
|
|
w: width,
|
|
h: 0,
|
|
pinchStart: null as { scale: number; dist: number } | null,
|
|
lastDx: 0,
|
|
lastDy: 0,
|
|
moved: false,
|
|
grantAt: 0,
|
|
lastTap: 0,
|
|
}).current;
|
|
|
|
const apply = () => {
|
|
scaleAn.setValue(st.scale);
|
|
txAn.setValue(st.tx);
|
|
tyAn.setValue(st.ty);
|
|
};
|
|
|
|
const clampTx = () => {
|
|
// Keep the scaled image covering the center; pan bounds grow with zoom.
|
|
const k = (st.scale - 1) / 2;
|
|
const mx = st.w * k;
|
|
st.tx = Math.max(-mx, Math.min(mx, st.tx));
|
|
if (st.h > 0) {
|
|
const my = st.h * k;
|
|
st.ty = Math.max(-my, Math.min(my, st.ty));
|
|
}
|
|
};
|
|
|
|
const settle = (scale: number, tx: number, ty: number) => {
|
|
st.scale = scale;
|
|
st.tx = tx;
|
|
st.ty = ty;
|
|
Animated.parallel([
|
|
Animated.spring(scaleAn, { toValue: scale, friction: 8, tension: 80, useNativeDriver: false }),
|
|
Animated.spring(txAn, { toValue: tx, friction: 8, tension: 80, useNativeDriver: false }),
|
|
Animated.spring(tyAn, { toValue: ty, friction: 8, tension: 80, useNativeDriver: false }),
|
|
]).start();
|
|
};
|
|
|
|
const finish = () => {
|
|
const wasTap =
|
|
!st.moved && Date.now() - st.grantAt < 400 && st.pinchStart === null;
|
|
st.pinchStart = null;
|
|
st.lastDx = 0;
|
|
st.lastDy = 0;
|
|
st.moved = false;
|
|
if (wasTap) {
|
|
if (Date.now() - st.lastTap < TAP_MS) {
|
|
st.lastTap = 0;
|
|
if (st.scale > 1.05) settle(1, 0, 0);
|
|
else settle(TAP_SCALE, 0, 0);
|
|
} else {
|
|
st.lastTap = Date.now();
|
|
}
|
|
return;
|
|
}
|
|
if (st.scale < 1.15) {
|
|
if (st.scale !== 1 || st.tx !== 0 || st.ty !== 0) settle(1, 0, 0);
|
|
} else {
|
|
clampTx();
|
|
apply();
|
|
}
|
|
};
|
|
|
|
const pan = useRef(
|
|
PanResponder.create({
|
|
onStartShouldSetPanResponder: () => true,
|
|
onMoveShouldSetPanResponder: () => true,
|
|
// Page the strip while zoom is off; once zoomed (or mid-pinch) the
|
|
// image keeps the gesture for panning/zooming itself.
|
|
onPanResponderTerminationRequest: () =>
|
|
st.scale <= 1.001 && st.pinchStart === null,
|
|
onPanResponderGrant: () => {
|
|
st.grantAt = Date.now();
|
|
st.moved = false;
|
|
st.lastDx = 0;
|
|
st.lastDy = 0;
|
|
},
|
|
onPanResponderMove: (e, g) => {
|
|
const touches = e.nativeEvent.touches;
|
|
if (touches.length >= 2) {
|
|
const a = touches[0];
|
|
const b = touches[1];
|
|
const dist = Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY);
|
|
if (!st.pinchStart) st.pinchStart = { scale: st.scale, dist: dist || 1 };
|
|
const next = Math.max(
|
|
1,
|
|
Math.min(MAX_SCALE, st.pinchStart.scale * (dist / st.pinchStart.dist))
|
|
);
|
|
st.scale = next;
|
|
if (next <= 1.001) {
|
|
st.tx = 0;
|
|
st.ty = 0;
|
|
}
|
|
clampTx();
|
|
apply();
|
|
st.moved = true;
|
|
st.lastDx = g.dx;
|
|
st.lastDy = g.dy;
|
|
} else {
|
|
st.pinchStart = null;
|
|
if (Math.abs(g.dx) > 8 || Math.abs(g.dy) > 8) st.moved = true;
|
|
if (st.scale > 1.001) {
|
|
st.tx += g.dx - st.lastDx;
|
|
st.ty += g.dy - st.lastDy;
|
|
clampTx();
|
|
apply();
|
|
}
|
|
st.lastDx = g.dx;
|
|
st.lastDy = g.dy;
|
|
}
|
|
},
|
|
onPanResponderRelease: finish,
|
|
onPanResponderTerminate: finish,
|
|
})
|
|
).current;
|
|
|
|
return (
|
|
<Animated.Image
|
|
source={{ uri }}
|
|
resizeMode="contain"
|
|
onLayout={(e) => {
|
|
st.w = e.nativeEvent.layout.width;
|
|
st.h = e.nativeEvent.layout.height;
|
|
}}
|
|
style={[
|
|
{ width, height: '100%' },
|
|
{ transform: [{ translateX: txAn }, { translateY: tyAn }, { scale: scaleAn }] },
|
|
]}
|
|
{...pan.panHandlers}
|
|
/>
|
|
);
|
|
}
|