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.
109 lines
4.2 KiB
TypeScript
109 lines
4.2 KiB
TypeScript
import * as Location from 'expo-location';
|
|
import { GPSInfo } from '../types';
|
|
|
|
export async function requestLocationPermissions(): Promise<boolean> {
|
|
try {
|
|
const { status } = await Location.requestForegroundPermissionsAsync();
|
|
return status === 'granted';
|
|
} catch (error) {
|
|
console.error('Error requesting location permissions:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// The OS permission screen takes the window focus for a moment and the camera
|
|
// session goes down with it, so a capture that asks for location comes back
|
|
// "Camera is closed" — one ask per shot turned a burst into failed frames.
|
|
// Check the status (no UI, cheap) and raise the dialog at most once per run.
|
|
let locationAsked = false;
|
|
|
|
async function ensureLocationPermission(): Promise<boolean> {
|
|
try {
|
|
if ((await Location.getForegroundPermissionsAsync()).granted) return true;
|
|
if (locationAsked) return false;
|
|
locationAsked = true;
|
|
return (await Location.requestForegroundPermissionsAsync()).status === 'granted';
|
|
} catch (error) {
|
|
console.error('Error requesting location permissions:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function getCurrentGPS(): Promise<GPSInfo | null> {
|
|
try {
|
|
const hasPermission = await ensureLocationPermission();
|
|
if (!hasPermission) return null;
|
|
|
|
const location = await Location.getCurrentPositionAsync({
|
|
accuracy: Location.Accuracy.Balanced,
|
|
});
|
|
|
|
const { latitude, longitude } = location.coords;
|
|
let locality = 'UNKNOWN LOCATION';
|
|
let country = '';
|
|
|
|
try {
|
|
const geocode = await Location.reverseGeocodeAsync({ latitude, longitude });
|
|
if (geocode && geocode.length > 0) {
|
|
const address = geocode[0];
|
|
// Combine city, region, street or subregion to form a retro looking place string
|
|
locality = localityName(address) || 'LOCAL REGION';
|
|
country = (address.country || '').toUpperCase();
|
|
}
|
|
} catch (e) {
|
|
console.warn('Reverse geocoding failed, using coordinates only:', e);
|
|
}
|
|
|
|
return {
|
|
latitude,
|
|
longitude,
|
|
locality,
|
|
country,
|
|
timestamp: location.timestamp,
|
|
};
|
|
} catch (error) {
|
|
console.error('Error fetching GPS info:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// 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 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;
|
|
}
|
|
|
|
export async function reverseGeocode(latitude: number, longitude: number): Promise<string | null> {
|
|
// Foreground permission is not optional here: expo-location's Android
|
|
// geocoder throws LocationUnauthorizedException without it. A photo picked
|
|
// before the camera ever asked for location therefore resolved to no name at
|
|
// all and the stamp fell back to "STREET VIEW" — so make sure it is held
|
|
// before every call (same once-per-run ask as getCurrentGPS).
|
|
await ensureLocationPermission();
|
|
try {
|
|
const geo = await Location.reverseGeocodeAsync({ latitude, longitude });
|
|
if (geo && geo.length > 0) return localityName(geo[0]);
|
|
} catch (e) {
|
|
console.warn('Reverse geocode failed:', e);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function formatCoordinate(val: number, type: 'lat' | 'lon'): string {
|
|
const absolute = Math.abs(val);
|
|
const degrees = Math.floor(absolute);
|
|
const minutes = Math.floor((absolute - degrees) * 60);
|
|
const seconds = ((absolute - degrees - minutes / 60) * 3600).toFixed(2);
|
|
const direction = type === 'lat' ? (val >= 0 ? 'N' : 'S') : (val >= 0 ? 'E' : 'W');
|
|
|
|
return `${degrees}° ${minutes}' ${seconds}" ${direction}`;
|
|
}
|