Sell PRO as an in-app unlock, take shared photos, and let FX run negative

- One free Play app (com.locphamtran.recipescamera) with an expo-iap PRO
  unlock, replacing the paid apk for the store channel
- Android SEND/SEND_MULTIPLE share target feeds photos into the editor,
  on cold start and on resume
- BACK button in the TopBar and press-and-hold PEEK replace the chip row
- NOISE REDUCTION and SHARPENING accept -10: a negative SHARPENING softens
  the frame before the grain pass, a negative NOISE REDUCTION re-grains it,
  in the Skia engine, the live preview and the Kotlin export path alike
This commit is contained in:
2026-09-16 06:48:22 +07:00
parent 27a9dfb65f
commit a85ebf0a81
21 changed files with 860 additions and 183 deletions
+14 -7
View File
@@ -6,7 +6,7 @@ import { Star, Trash2 } from 'lucide-react-native';
import { Skia } from '@shopify/react-native-skia';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseFilter, CropRatio } from '../types';
import { FRAMES } from '../utils/frameUtils';
import { IS_PRO, PRO_FRAMES } from '../utils/entitlement';
import { PRO_FRAMES } from '../utils/entitlement';
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
import { FILM_SIMS, filmSimToRecipe } from '../utils/defaultRecipes';
import RecipeCreateModal from './RecipeCreateModal';
@@ -66,6 +66,10 @@ interface AdjustmentPanelProps {
onApplyCrop: () => void;
cropEnabled?: boolean;
useGeotag: boolean;
// Runtime tier: PRO apk or a LITE apk that bought the unlock. A LITE build
// keeps every PRO chip on screen, greyed with its corner tag, so the panel
// has to read the value at render time rather than at build time.
pro: boolean;
// RAW (DNG sidecar) / aspect-ratio / metering-mode now live in the gear
// Settings sheet (gear between library picker and shutter) — no longer
// tool-rail tabs.
@@ -141,6 +145,9 @@ interface AdjustmentPanelProps {
// FRAME → WATERMARK sub-panel is open: App needs this to arm the mark's
// drag/pinch layer on the image (the old `activeTab === 'watermark'`).
onWmPanelChange: (open: boolean) => void;
// BACK (undo) and PEEK (hold to compare) both work on the whole LOOK, not on
// one parameter, and both left this panel: BACK is a button in the top bar,
// PEEK is a hold on the picture itself (TopBar, Viewfinder).
}
interface ChipDef {
@@ -229,6 +236,7 @@ export default function AdjustmentPanel({
onApplyCrop,
cropEnabled,
useGeotag,
pro,
openParam,
onOpenParamChange,
currentBaseFilter,
@@ -338,9 +346,8 @@ export default function AdjustmentPanel({
? 'border-amber-500/70 bg-amber-500/15'
: 'border-zinc-700/60 bg-titan/80';
const textTone = c.active || c.amberValue ? 'text-amber-500' : 'text-zinc-300';
// LITE only: corner tag on every chip whose look LITE cannot export. PRO
// never sees it (there the condition is a constant false).
const proTag = c.pro && !IS_PRO;
// LITE only: corner tag on every chip whose look LITE cannot export.
const proTag = c.pro && !pro;
return (
<View
key={c.key}
@@ -424,7 +431,7 @@ export default function AdjustmentPanel({
// A PRO-only parameter stays visible in LITE as a greyed chip with its
// corner PRO tag — named where it is, which is the only place it can be.
pro: p.pro,
disabled: !!p.pro && !IS_PRO,
disabled: !!p.pro && !pro,
onPress: () => toggleParam(p.key),
}));
@@ -560,7 +567,7 @@ export default function AdjustmentPanel({
const wmFontOptions = useMemo(() => {
// LITE ships a single basic face: the platform's own families (what
// DEFAULT is not) are the paid half of the FONT strip.
if (!IS_PRO) return [{ v: 'DEFAULT', d: 'DEFAULT' }];
if (!pro) return [{ v: 'DEFAULT', d: 'DEFAULT' }];
const mgr = Skia.FontMgr.System();
const names = new Set<string>();
for (let i = 0; i < mgr.countFamilies() && names.size < 120; i++) {
@@ -571,7 +578,7 @@ export default function AdjustmentPanel({
{ v: 'DEFAULT', d: 'DEFAULT' },
...[...names].sort().map((n) => ({ v: n, d: n })),
];
}, []);
}, [pro]);
const groupDefs: Record<Exclude<GroupKey, 'wm'>, { label: string; off: string; value: string; options: { v: string; d: string }[]; onPick: (v: string) => void }> = {
cx: {
label: 'COLOR CHROME', off: 'none', value: a.colorChrome || 'none', options: chromeOptions,