From 299d81bade57b0c8a265e5f7c9ee2fe718835410 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Thu, 10 Sep 2026 18:05:57 +0700 Subject: [PATCH] feat(wm): GPS watermark color + place-name/time toggles, "commune, city" locality --- App.tsx | 28 +++++++++++++++++--- src/components/AdjustmentPanel.tsx | 41 ++++++++++++++++++++++++++++-- src/components/Viewfinder.tsx | 35 +++++++++++++++---------- src/types/index.ts | 6 +++++ src/utils/exportEngine.ts | 21 +++++++++------ src/utils/locationUtils.ts | 15 +++++++---- 6 files changed, 114 insertions(+), 32 deletions(-) diff --git a/App.tsx b/App.tsx index 08beda3..dfffbd7 100644 --- a/App.tsx +++ b/App.tsx @@ -89,7 +89,14 @@ export default function App() { // GPS mark placement (WATERMARK tab drag + pinch): fractions of the frame / // photo area plus a size multiplier, same space as the custom mark. There is // nothing to accept — the panel being open IS the edit session. - const DEFAULT_GPS_WM: GpsWatermark = { x: 0.05, y: 0.1, size: 1 }; + const DEFAULT_GPS_WM: GpsWatermark = { + x: 0.05, + y: 0.1, + size: 1, + color: '#f59e0b', + showName: true, + showTime: true, + }; const [gpsWm, setGpsWm] = useState({ ...DEFAULT_GPS_WM }); // FRAME tab ORIENTATION chip (WALL FRAME only): hang the artwork landscape // (the PNG's own 4000x3117) instead of the default portrait rotation. @@ -276,6 +283,7 @@ export default function App() { selectedRecipeId: selectedRecipe?.id || null, gpsInfo, customWm, + gpsWm, }; try { await AsyncStorage.setItem(SESSION_KEY, JSON.stringify(session)); @@ -285,7 +293,7 @@ export default function App() { } }); return () => subscription.remove(); - }, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, customWm, aspectRatio, meterMode, shutterSound]); + }, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, customWm, gpsWm, aspectRatio, meterMode, shutterSound]); // Guards the camera-settings persist effect below: until initSession has // read the stored values, changes are just the initial defaults — writing @@ -392,6 +400,18 @@ export default function App() { font: typeof raw.font === 'string' ? raw.font : null, }); } + if (session.gpsWm && typeof session.gpsWm === 'object') { + // Style chips only — the placement fractions are left alone (restoring + // a stale x/y would silently move the stamp the user just dragged). + const raw = session.gpsWm as Partial; + const keep = (v: unknown, fb: boolean) => (typeof v === 'boolean' ? v : fb); + setGpsWm((g) => ({ + ...g, + color: typeof raw.color === 'string' ? raw.color : g.color, + showName: keep(raw.showName, true), + showTime: keep(raw.showTime, true), + })); + } if (session.selectedRecipeId) { // Film sims are never persisted in the recipe list (sims applied on the // fly) — resolve sim ids via FILM_SIMS, real recipes via the list. @@ -1083,7 +1103,7 @@ export default function App() { customWm={customWm} wmRotation={wmRotation} gpsWm={gpsWm} - onGpsWmChange={(x, y, size) => setGpsWm({ x, y, size })} + onGpsWmChange={(x, y, size) => setGpsWm((g) => ({ ...g, x, y, size }))} onWmSizeChange={(size) => setCustomWm((p) => ({ ...p, size }))} wallLandscape={wallLandscape} // The mark only captures image touches while its panel is open, so a @@ -1141,6 +1161,8 @@ export default function App() { onDeleteRecipe={handleDeleteRecipe} customWm={customWm} onUpdateCustomWm={handleUpdateCustomWm} + gpsWm={gpsWm} + onUpdateGpsWm={setGpsWm} onAcceptCustomWm={handleAcceptCustomWm} onCancelCustomWm={handleCancelCustomWm} wmEditNonce={wmEditNonce} diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index 0c4a9c3..6a4cc1c 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -4,7 +4,7 @@ import Slider from '@react-native-community/slider'; import * as Haptics from 'expo-haptics'; import { Trash2 } from 'lucide-react-native'; import { Skia } from '@shopify/react-native-skia'; -import { Recipe, ColorAdjustments, CustomWatermark, FrameId, BaseFilter } from '../types'; +import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseFilter } from '../types'; import { FRAMES } from '../utils/frameUtils'; import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs'; import { FILM_SIMS, filmSimToRecipe } from '../utils/defaultRecipes'; @@ -19,7 +19,16 @@ import RecipeCreateModal from './RecipeCreateModal'; type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame' | 'watermark'; // Choice-group keys that open a strip above the chip row: color chrome groups // (wb tab) and the D.RANGE selector (iq tab). -type GroupKey = 'cx' | 'cxb' | 'dr' | 'wmColor' | 'wmSize' | 'wmFont'; +type GroupKey = + | 'cx' + | 'cxb' + | 'dr' + | 'wmColor' + | 'wmSize' + | 'wmFont' + | 'gpsColor' + | 'gpsName' + | 'gpsTime'; interface AdjustmentPanelProps { activeTab: TabId; @@ -59,6 +68,10 @@ interface AdjustmentPanelProps { // input and the export options all read the same object. customWm: CustomWatermark; onUpdateCustomWm: (wm: CustomWatermark) => void; + // GPS mark styling — the SAME object the preview and the export read, so a + // chip press repaints both. + gpsWm: GpsWatermark; + onUpdateGpsWm: (wm: GpsWatermark) => void; // ACCEPT: copy the live draft into the mark the export burns in. Edits stay a // draft (viewfinder preview only) until this is pressed. onAcceptCustomWm: () => void; @@ -152,6 +165,8 @@ export default function AdjustmentPanel({ onDeleteRecipe, customWm, onUpdateCustomWm, + gpsWm, + onUpdateGpsWm, onAcceptCustomWm, onCancelCustomWm, wmEditNonce, @@ -388,6 +403,25 @@ export default function AdjustmentPanel({ options: wmFontOptions, onPick: (v) => onUpdateCustomWm({ ...customWm, font: v === 'DEFAULT' ? null : v }), }, + // GPS mark's own features — one chip each, opening its options above (the + // same one-row-at-a-time strip the custom mark's styling uses). + gpsColor: { + label: 'COLOR', off: WM_DEFAULT_COLOR, + value: gpsWm.color ?? WM_DEFAULT_COLOR, options: WM_COLORS, + onPick: (v) => onUpdateGpsWm({ ...gpsWm, color: v }), + }, + gpsName: { + label: 'PLACE NAME', off: 'ON', + value: gpsWm.showName === false ? 'OFF' : 'ON', + options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }], + onPick: (v) => onUpdateGpsWm({ ...gpsWm, showName: v === 'on' }), + }, + gpsTime: { + label: 'TIME', off: 'ON', + value: gpsWm.showTime === false ? 'OFF' : 'ON', + options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }], + onPick: (v) => onUpdateGpsWm({ ...gpsWm, showTime: v === 'on' }), + }, }; const groupChip = (g: GroupKey): ChipDef => ({ key: `grp-${g}`, @@ -537,6 +571,9 @@ export default function AdjustmentPanel({ active: useGeotag, onPress: () => onToggleGeotag(!useGeotag), })} + {useGeotag && renderChip(groupChip('gpsColor'))} + {useGeotag && renderChip(groupChip('gpsName'))} + {useGeotag && renderChip(groupChip('gpsTime'))} {renderChip({ key: 'custom-wm', label: customWm.enabled ? 'CUSTOM WATERMARK ON' : 'CUSTOM WATERMARK OFF', diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 2b84ee9..6dd4cb0 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -108,7 +108,7 @@ interface ViewfinderProps { // GPS mark placement (WATERMARK tab drag + pinch): the same 0..1 fractions // of the same area customWm uses, TOP-LEFT anchored; size multiplies the // base 3.2%-of-width size. Absent = the historical fixed top-left look. - gpsWm?: { x: number; y: number; size: number }; + gpsWm?: { x: number; y: number; size: number; color?: string; showName?: boolean; showTime?: boolean }; onGpsWmChange?: (x: number, y: number, size: number) => void; // Pinch on the custom mark: its SIZE, exactly like the panel's SIZE chip. onWmSizeChange?: (size: number) => void; @@ -1720,25 +1720,32 @@ const Viewfinder = forwardRef(function Viewfi 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())}`; - // TOP-LEFT anchored two-line block at the dragged fraction of the same area - // the custom mark uses (frame card / photo window / screen). A framed - // export's canvas IS that frame, so the fractions carry straight to the - // burn-in — exportEngine step 8 draws with the identical formula. + // TOP-LEFT anchored block at the dragged fraction of the same area the + // custom mark uses (frame card / photo window / screen). A framed export's + // canvas IS that frame, so the fractions carry straight to the burn-in - + // exportEngine step 8 draws with the identical formula. Each enabled line + // (place name / shot time) stacks under the previous one. + const lines: { icon: string; text: string }[] = []; + if (gpsWm?.showName !== false) lines.push({ icon: '📍', text: locationName }); + if (gpsWm?.showTime !== false) lines.push({ icon: '📷', text: timestampStr }); + if (!lines.length) return null; + const { ax, ay, iconSize } = gpsAnchor(); - const row1Y = ay + Math.round(iconSize * 1.1); - const row2Y = ay + Math.round(iconSize * 2.3); + const textColor = gpsWm?.color ?? '#f59e0b'; // 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 = gpsIconFont.measureText('📍'); - const camW = gpsIconFont.measureText('📷'); + // The old fixed row1/row2 spacing, generalised to N rows. + const rowY = (i: number) => ay + Math.round(iconSize * (1.1 + i * 1.2)); return ( - {/* Icons white so they read as glyphs, not part of the amber text */} - - - - + {lines.map((line, i) => ( + + {/* Icons white so they read as glyphs, not part of the coloured text */} + + + + ))} ); }; diff --git a/src/types/index.ts b/src/types/index.ts index a8efa99..6c66ce5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -106,4 +106,10 @@ export interface GpsWatermark { x: number; y: number; size: number; + // Styling set by the WATERMARK tab's PLACE NAME / TIME / COLOR chips. Absent + // on marks saved before those controls existed — the renderers then fall back + // to amber text with both lines shown, i.e. exactly the previous look. + color?: string; + showName?: boolean; + showTime?: boolean; } diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index 641484a..a514282 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -53,7 +53,7 @@ export interface ExportOptions { // WATERMARK tab drag + pinch on the GPS lines: 0..1 fractions of the same // area the custom mark uses (a framed export's canvas IS the frame) plus a // multiplier on the base 3.2%-of-width size. Absent = the old top-left look. - gpsWatermark?: { x: number; y: number; size: number } | null; + gpsWatermark?: { x: number; y: number; size: number; color?: string; showName?: boolean; showTime?: boolean } | null; // WALL FRAME hung the other way round (4000x3117, artwork un-rotated). wallframeLandscape?: boolean; // Library framed-window photo reposition (Viewfinder.getFrameWindowZoom): @@ -606,18 +606,23 @@ export async function processAndExportPhoto( const gpsGap = Math.round(gpsSize * 1.6); // icon box (~1em) + one clear mono space (~0.6em) const baseX = clamp01(gpsPos?.x ?? 0.05) * stampW; const baseY = clamp01(gpsPos?.y ?? 0.1) * stampH; - const row1Y = baseY + Math.round(gpsSize * 1.1); - const row2Y = baseY + Math.round(gpsSize * 2.3); + // Each enabled line (place name / shot time) stacks under the + // previous one, exactly as the viewfinder draws it. + const lines: { icon: string; text: string }[] = []; + if (gpsPos?.showName !== false) lines.push({ icon: '📍', text: locationName }); + if (gpsPos?.showTime !== false) lines.push({ icon: '📷', text: timestampStr }); + textPaint.setColor(Skia.Color(gpsPos?.color ?? '#f59e0b')); // user-chosen, amber by default - // Spin the whole two-line block around its top-left (the anchor the + // Spin the whole block around its top-left (the anchor the // viewfinder uses). 0 = the historical upright look. const wmRot = options?.watermarkRotation ?? 0; stampCanvas.save(); if (wmRot) stampCanvas.rotate(wmRot, baseX, baseY); - stampCanvas.drawText('📍', baseX, row1Y, iconPaint, gpsIconFont); - stampCanvas.drawText(locationName, baseX + gpsGap, row1Y, textPaint, gpsFont); - stampCanvas.drawText('📷', baseX, row2Y, iconPaint, gpsIconFont); - stampCanvas.drawText(timestampStr, baseX + gpsGap, row2Y, textPaint, gpsFont); + lines.forEach((line, i) => { + const rowY = baseY + Math.round(gpsSize * (1.1 + i * 1.2)); + stampCanvas.drawText(line.icon, baseX, rowY, iconPaint, gpsIconFont); + stampCanvas.drawText(line.text, baseX + gpsGap, rowY, textPaint, gpsFont); + }); stampCanvas.restore(); } diff --git a/src/utils/locationUtils.ts b/src/utils/locationUtils.ts index d8c658e..c289794 100644 --- a/src/utils/locationUtils.ts +++ b/src/utils/locationUtils.ts @@ -49,12 +49,17 @@ export async function getCurrentGPS(): Promise { } } -// The one place-name picker: city first, because "the place" on a stamp means -// the town people would name, while subregion is Android's subAdminArea — the -// county/district, i.e. the wrong name for the same coordinates. Shared so the -// camera stamp and a photo's stamp can never disagree. +// The one place-name picker, shared so the camera stamp and a photo's stamp can +// never disagree. A Vietnamese geocoder answers with the commune/ward in +// subregion (post-2025 two-level administration: the commune is the district- +// level name) and the province/city in region, while city and district come +// back null, so the stamp reads "COMMUNE, CITY" instead of a bare city. export function localityName(address: Location.LocationGeocodedAddress): string | null { - const name = address.city || address.subregion || address.district || address.region; + const district = address.subregion || address.district; + const city = address.city || address.region; + // Same name in both slots (one field echoed twice) must not print twice. + const parts = district && district !== city ? [district, city] : [district || city]; + const name = parts.filter(Boolean).join(', '); return name ? name.toUpperCase() : null; }