diff --git a/App.tsx b/App.tsx index 829b2f2..5b97366 100644 --- a/App.tsx +++ b/App.tsx @@ -115,7 +115,9 @@ export default function App() { const [adjustments, setAdjustments] = useState({ ...DEFAULT_ADJUSTMENTS }); const [selectedFrame, setSelectedFrame] = useState('none'); - const [useGeotag, setUseGeotag] = useState(true); + // PRO only: the GPS stamp is a paid look, so a LITE session starts without + // it and `handleToggleGeotag` refuses to turn it on. + const [useGeotag, setUseGeotag] = useState(pro); // GPS master switch (SETTINGS -> GPS): may the app read the device's // location at all? ON resolves a fix at capture and reverse-geocodes it; // OFF reads nothing. The GPS WATERMARK chip is separate — it only decides @@ -435,7 +437,7 @@ export default function App() { if (session.adjustments) setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...session.adjustments }); if (session.selectedFrame) setSelectedFrame(session.selectedFrame); - if (session.useGeotag !== undefined) setUseGeotag(session.useGeotag); + if (session.useGeotag !== undefined) setUseGeotag(pro && session.useGeotag); if (session.aspectRatio === 'full' || session.aspectRatio === '4:3' || session.aspectRatio === '3:2') setAspectRatio(session.aspectRatio); if (session.cropRatio) setCropRatio(session.cropRatio); if (session.cropRect && typeof session.cropRect === 'object') setCropRect({ ...DEFAULT_CROP_RECT, ...session.cropRect }); @@ -460,7 +462,7 @@ export default function App() { y: clamp01(raw.y, 0.5), color: typeof raw.color === 'string' ? raw.color : undefined, size: typeof raw.size === 'number' && Number.isFinite(raw.size) ? raw.size : undefined, - font: typeof raw.font === 'string' ? raw.font : null, + font: pro && typeof raw.font === 'string' ? raw.font : null, }); } if (session.gpsWm && typeof session.gpsWm === 'object') { @@ -689,6 +691,10 @@ export default function App() { }; const handleToggleGeotag = async (enabled: boolean) => { + if (enabled && !pro) { + promptProFeature('The GPS watermark'); + return; + } setUseGeotag(enabled); if (selectedRecipe) { setSelectedRecipe({ @@ -942,10 +948,6 @@ export default function App() { // the background. The write is the only failure worth logging — the look is // untouched either way. const handleToggleFavorite = (id: string) => { - if (!pro) { - promptProFeature('Favoriting recipes'); - return; - } const on = !favoriteIds.includes(id); setFavoriteIds((ids) => (on ? [...ids, id] : ids.filter((x) => x !== id))); setFavoriteId(id, on).catch((e) => console.warn('Failed to save favorite:', e)); @@ -1020,14 +1022,9 @@ export default function App() { // Only the EXPORT consults it: the preview shows every look for free, so the // answer is computed where the file is about to be written, not on the chips. // PRO unlocks every look, so a paying user has nothing left in use: only a - // Lite session can trip the two gates below. - const proLook = pro - ? null - : proLookInUse({ - recipeId: selectedRecipe?.id, - frame: selectedFrame, - customWm: customWmApplied.enabled, - }); + // Lite session can trip them: a printed frame it previewed, or a GPS stamp + // that arrived with a restored session. + const proLook = pro ? null : proLookInUse({ frame: selectedFrame, gpsWm: useGeotag }); // The native Kotlin exporter has no text-watermark draw, so a Lite export // (which always carries the mark) must take the Skia engine. const nativeExport = NATIVE_EXPORT && pro; @@ -1059,21 +1056,22 @@ export default function App() { size?: number; font?: string | null; } | null => - // Lite: the user's own mark can never reach the file (that look is gated at - // the shutter); every Lite export carries the RECIPESCAM mark instead. - !pro - ? LITE_MARK - : customWmApplied.enabled && customWmApplied.text.trim().length > 0 + // The user's own mark is free in both tiers. LITE only narrows the FACE it + // may use (one basic font), not the mark itself. + customWmApplied.enabled && customWmApplied.text.trim().length > 0 ? { text: customWmApplied.text.trim(), x: customWmApplied.x, y: customWmApplied.y, color: customWmApplied.color, size: customWmApplied.size, - font: customWmApplied.font, + font: pro ? customWmApplied.font : null, } : null; + // The free build's own mark, burned in on top of the custom one. + const liteMark = pro ? null : LITE_MARK; + // `burst` marks one frame of a held-shutter burst: the loop already gave the // single haptic and the click would machine-gun, so both are muted, and RAW // is skipped (a DNG per frame doubles the exposures the burst has to fit). @@ -1184,6 +1182,7 @@ export default function App() { sharpen: true, aspect: aspectRatio === 'full' ? undefined : aspectRatio, watermark: wmForExport(), + liteMark, watermarkRotation: wmRotation, gpsWatermark: gpsWm, wallframeLandscape: wallLandscape, @@ -1278,6 +1277,7 @@ export default function App() { // EXIF (camera, dates, GPS) is carried into the file. exported: true, watermark: wmForExport(), + liteMark, watermarkRotation: wmRotation, gpsWatermark: gpsWm, wallframeLandscape: wallLandscape, @@ -1302,6 +1302,7 @@ export default function App() { // EXIF (camera, dates, GPS) is carried into the file. exported: true, watermark: wmForExport(), + liteMark, watermarkRotation: wmRotation, gpsWatermark: gpsWm, wallframeLandscape: wallLandscape, @@ -1469,10 +1470,6 @@ export default function App() { // parameter row — otherwise a stale slider row (and its image-drag target) // would survive into the next panel. const handleToggleTab = (tab: TabId) => { - if (tab === 'favorites' && !pro) { - promptProFeature('The FAVORITED tab'); - return; - } setOpenTab((prev) => (prev === tab ? null : tab)); setOpenParamKey(null); }; @@ -1629,6 +1626,8 @@ export default function App() { onToggleGeotag={handleToggleGeotag} onSaveRecipe={handleSaveRecipe} onImportRecipe={handleImportRecipe} + recipeLimitReached={liteSaveBlocked} + onRecipeLimit={promptRecipeLimit} onDeleteRecipe={handleDeleteRecipe} customWm={customWm} onUpdateCustomWm={handleUpdateCustomWm} diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index d60c697..0977a77 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -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, isFreeRecipe } from '../utils/entitlement'; +import { IS_PRO, PRO_FRAMES } from '../utils/entitlement'; import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs'; import { FILM_SIMS, filmSimToRecipe } from '../utils/defaultRecipes'; import RecipeCreateModal from './RecipeCreateModal'; @@ -112,6 +112,10 @@ interface AdjustmentPanelProps { // IMPORT: pick a recipe file shared from another phone and store it as a // normal custom recipe. onImportRecipe: () => 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; + onRecipeLimit: () => void; onDeleteRecipe: (id: string) => void; // WATERMARK tab state — lifted to App so the live Viewfinder overlay, this // input and the export options all read the same object. @@ -245,6 +249,8 @@ export default function AdjustmentPanel({ onToggleWallLandscape, onToggleGeotag, onImportRecipe, + recipeLimitReached, + onRecipeLimit, onDeleteRecipe, customWm, onUpdateCustomWm, @@ -546,6 +552,9 @@ export default function AdjustmentPanel({ // manager exposes (the device's own system fonts); DEFAULT keeps the face // the mark shipped with. 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' }]; const mgr = Skia.FontMgr.System(); const names = new Set(); for (let i = 0; i < mgr.countFamilies() && names.size < 120; i++) { @@ -723,6 +732,7 @@ export default function AdjustmentPanel({ key: 'gps', label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF', active: useGeotag, + pro: true, onPress: () => onToggleGeotag(!useGeotag), }, ...(useGeotag ? [groupChip('gpsColor'), groupChip('gpsName'), groupChip('gpsTime')] : []), @@ -730,7 +740,6 @@ export default function AdjustmentPanel({ key: 'custom-wm', label: customWm.enabled ? 'CUSTOM WATERMARK ON' : 'CUSTOM WATERMARK OFF', active: customWm.enabled, - pro: true, onPress: () => onUpdateCustomWm({ ...customWm, enabled: !customWm.enabled }), }, ...(wmOn @@ -841,7 +850,6 @@ export default function AdjustmentPanel({ key: r.id, label: r.name, active: r.id === currentRecipeId, - pro: !isFreeRecipe(r.id), star: true, starred: true, onPress: () => onSelectRecipe(r), @@ -1024,7 +1032,6 @@ export default function AdjustmentPanel({ key: r.id, label: r.name, active: r.id === currentRecipeId, - pro: !isFreeRecipe(r.id), star: true, starred: favoriteIds.includes(r.id), trash: !!r.isCustom, @@ -1033,6 +1040,16 @@ export default function AdjustmentPanel({ onPressDelete: () => onDeleteRecipe(r.id), }) )} + {/* The next recipe, right after the last one. Past the LITE limit + this is the only chip that cannot be pressed: it carries the + PRO tag and names the build instead of opening the form. */} + {renderChip({ + key: 'add-recipe', + label: '+', + active: false, + pro: recipeLimitReached, + onPress: () => (recipeLimitReached ? onRecipeLimit() : setCreateVisible(true)), + })} )} diff --git a/src/utils/entitlement.ts b/src/utils/entitlement.ts index 95144a3..67ff3ca 100644 --- a/src/utils/entitlement.ts +++ b/src/utils/entitlement.ts @@ -10,27 +10,27 @@ import { IS_PRO_BUILD } from '../provariant'; // code the bundler is free to drop. export const IS_PRO = IS_PRO_BUILD; -// The gate is on the FILE, not on the preview: a LITE user may pick any preset, -// frame or watermark, compose with it and compare before/after — only the -// render/export of that look is held back, and every LITE export carries the -// mark below. +// The gate is on the FILE, not on the preview: a LITE user composes with any +// look and compares it before/after — only the render/export of a PRO look is +// held back, and every LITE export carries the mark below. +// +// What LITE owns outright: the eight film sims, the bundled recipes, its own +// recipes up to LITE_RECIPE_LIMIT, its own watermark text (in one face), the +// plain frames and every parameter. What needs PRO: the three printed frames, +// the GPS stamp, the platform's other fonts, and the next recipe past the +// limit. // Frames on the LITE side: the plain export only. The three printed looks are // PRO (the artwork/card geometry is the paid half of the FRAME tab). export const PRO_FRAMES: FrameId[] = ['classic-white', 'polaroid', 'wallframe']; -// Presets: the eight film SIMS are free (a base-filter matrix is the camera's -// own look); a saved RECIPE — bundled or user-created — is a dialled-in look -// and belongs to PRO. Ids are `sim-*` for sims, anything else for recipes. -export const isFreeRecipe = (id: string | null | undefined): boolean => - !id || id.startsWith('sim-'); - // LITE may store its own recipes, up to this many: creating and editing them is // free, deleting one is PRO — so a LITE list only ever grows to this size. export const LITE_RECIPE_LIMIT = 3; // Burned into every LITE export, bottom-right of the photo/frame area. Fractions -// of the stamp canvas, same space the custom mark uses. +// of the stamp canvas, same space the custom mark uses. The user's own mark is +// free on top of it. export const LITE_MARK = { text: 'RECIPESCAM', x: 0.55, @@ -40,16 +40,14 @@ export const LITE_MARK = { }; export interface Look { - recipeId?: string | null; frame: FrameId; - customWm: boolean; + gpsWm: boolean; } // The first PRO feature a look is using, or null when it is LITE-clean. One // string, so the caller can name it back to the user in the alert. export function proLookInUse(look: Look): string | null { - if (!isFreeRecipe(look.recipeId)) return 'A PRO preset'; if (PRO_FRAMES.includes(look.frame)) return 'A PRO frame'; - if (look.customWm) return 'The custom watermark'; + if (look.gpsWm) return 'The GPS watermark'; return null; } diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index 1b2ed43..00bcf74 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -59,6 +59,16 @@ export interface ExportOptions { size?: number; font?: string | null; } | null; + // The free build's own mark (RECIPESCAM), drawn in ADDITION to `watermark`: + // the custom text is free in LITE, the mark is what LITE leaves behind. + liteMark?: { + text: string; + x: number; + y: number; + color?: string; + size?: number; + font?: string | null; + } | null; // WATERMARK tab ROTATE chip: clockwise degrees (0/90/180/270) applied to // BOTH the GPS lines and the custom text, each around its own anchor (the // same anchor Viewfinder rotates around, so the preview matches the file). @@ -627,7 +637,8 @@ export async function processAndExportPhoto( // one amber-mono paint path serves both: GPS keeps its two icon lines; the // custom watermark is plain text drawn at the user-dragged position. const customWm = options?.watermark ?? null; - if ((useGeotag && gpsInfo) || customWm) { + const liteWm = options?.liteMark ?? null; + if ((useGeotag && gpsInfo) || customWm || liteWm) { const fontAsset = Asset.fromModule(require('../../assets/Cousine-Regular.ttf')); const iconFontAsset = Asset.fromModule(require('../../assets/NotoEmoji-GPS.ttf')); await Promise.all([fontAsset.downloadAsync(), iconFontAsset.downloadAsync()]); @@ -686,28 +697,33 @@ export async function processAndExportPhoto( // area + ~0.35em drop (the grabbed anchor reads as the text's visual // center, not its baseline) — the exact formula Viewfinder uses, so // preview == export. - if (customWm) { + if (customWm || liteWm) { // The mark lives on the FRAME: a framed export's canvas IS the // card/frame (see the branches above), so the whole stamp is the // area — the same 0..1 fractions the viewfinder drag produced. const area = { dx: 0, dy: 0, dw: stampW, dh: stampH }; const clamp01 = (v: number) => Math.max(0, Math.min(1, v)); - const wmSize = Math.round(fontSize * (customWm.size ?? 1)); - const wmFont = customWm.font - ? Skia.Font( - Skia.FontMgr.System().matchFamilyStyle(customWm.font, FontStyle.Normal), - wmSize - ) - : Skia.Font(typeface, wmSize); - const wmPaint = Skia.Paint(); - wmPaint.setColor(Skia.Color(customWm.color ?? '#f59e0b')); - const tx = area.dx + clamp01(customWm.x) * area.dw; - const ty = area.dy + clamp01(customWm.y) * area.dh + Math.round(wmSize * 0.35); const wmRot = options?.watermarkRotation ?? 0; - stampCanvas.save(); - if (wmRot) stampCanvas.rotate(wmRot, tx, ty); - stampCanvas.drawText(customWm.text, tx, ty, wmPaint, wmFont); - stampCanvas.restore(); + // Both marks share this path: the user's text (free in every tier) + // and, in LITE, the build's own RECIPESCAM mark next to it. + for (const mark of [customWm, liteWm]) { + if (!mark) continue; + const wmSize = Math.round(fontSize * (mark.size ?? 1)); + const wmFont = mark.font + ? Skia.Font( + Skia.FontMgr.System().matchFamilyStyle(mark.font, FontStyle.Normal), + wmSize + ) + : Skia.Font(typeface, wmSize); + const wmPaint = Skia.Paint(); + wmPaint.setColor(Skia.Color(mark.color ?? '#f59e0b')); + const tx = area.dx + clamp01(mark.x) * area.dw; + const ty = area.dy + clamp01(mark.y) * area.dh + Math.round(wmSize * 0.35); + stampCanvas.save(); + if (wmRot) stampCanvas.rotate(wmRot, tx, ty); + stampCanvas.drawText(mark.text, tx, ty, wmPaint, wmFont); + stampCanvas.restore(); + } } } }