Quick Snap: camera-first on native (runtime Camera plugin), file input on web

The previous file-input-only approach fixed the crash but opened the gallery
on native because capture="environment" is only a hint the OS can ignore.

New platform-aware flow (lib/quickCapture.ts):
- Native (APK): open the OS camera directly via the runtime-injected
  window.Capacitor.Plugins.Camera with source:'CAMERA' — forces the real
  camera and gives built-in preview/retake. Accessed off the runtime object,
  so no '@capacitor/camera' specifier in the bundle (keeps the white-screen
  fix intact). User cancel is treated as a no-op.
- Web: falls back to the file input with capture="environment".
- If the native camera errors, gracefully falls back to the file input.

MapPage Quick Snap now routes through handleQuickSnap() → the captured photo
feeds the existing upload → create-spot flow unchanged. Bump 1.0.69.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 19:38:46 +07:00
parent 2a55d1b3df
commit 6d0480961c
3 changed files with 102 additions and 11 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "skate-spots", "name": "skate-spots",
"private": true, "private": true,
"version": "1.0.68", "version": "1.0.69",
"type": "module", "type": "module",
"homepage": "https://skate-spots.com", "homepage": "https://skate-spots.com",
"scripts": { "scripts": {

70
src/lib/quickCapture.ts Normal file
View file

@ -0,0 +1,70 @@
/**
* Quick Snap capture helper — platform-aware, camera-first.
*
* NATIVE (Capacitor APK): uses the runtime-injected Camera plugin
* (window.Capacitor.Plugins.Camera) with source:'CAMERA', which forces the
* real OS camera and ships its own preview + retake UI. We read the plugin
* off the runtime object rather than importing '@capacitor/camera', so no
* bare specifier ends up in the bundle (that was the white-screen cause).
*
* WEB: there is no reliable way to force the camera from JS, so the caller
* falls back to a file input with capture="environment" (a hint the browser
* may honor). This module only handles the native path; isNativeCapture()
* tells the caller which route to take.
*/
interface CapacitorCameraPlugin {
getPhoto(options: {
resultType: string;
source: string;
quality: number;
correctOrientation: boolean;
}): Promise<{ dataUrl?: string }>;
}
function getNativeCamera(): CapacitorCameraPlugin | null {
const cap = (window as unknown as {
Capacitor?: {
isNativePlatform?: () => boolean;
Plugins?: { Camera?: CapacitorCameraPlugin };
};
}).Capacitor;
if (!cap?.isNativePlatform?.()) return null;
return cap.Plugins?.Camera ?? null;
}
/** True when the native Camera plugin is available (i.e. inside the app). */
export function isNativeCapture(): boolean {
return getNativeCamera() !== null;
}
/**
* Open the native camera and return the captured photo as a File.
* Returns null if the user cancels. Throws if the plugin is unavailable or
* errors (caller should fall back to the web file input).
*/
export async function captureFromNativeCamera(): Promise<File | null> {
const Camera = getNativeCamera();
if (!Camera) throw new Error('Native camera unavailable');
let photo: { dataUrl?: string };
try {
photo = await Camera.getPhoto({
resultType: 'dataUrl', // CameraResultType.DataUrl
source: 'CAMERA', // CameraSource.Camera — forces the camera, not the gallery
quality: 90,
correctOrientation: true,
});
} catch (err) {
// Capacitor throws with a "cancel" message when the user backs out — treat
// that as a benign null rather than an error.
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
if (msg.includes('cancel')) return null;
throw err;
}
if (!photo.dataUrl) return null;
const res = await fetch(photo.dataUrl);
const blob = await res.blob();
return new File([blob], `skate-spot-${Date.now()}.jpg`, { type: 'image/jpeg' });
}

View file

@ -81,6 +81,7 @@ import { SpotChat } from '@/components/SpotChat';
import { useUploadFile } from '@/hooks/useUploadFile'; import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { isNativeCapture, captureFromNativeCamera } from '@/lib/quickCapture';
type ViewMode = 'map' | 'list'; type ViewMode = 'map' | 'list';
@ -604,10 +605,10 @@ export default function MapPage() {
// Mobile layers popover // Mobile layers popover
const [layersOpen, setLayersOpen] = useState(false); const [layersOpen, setLayersOpen] = useState(false);
// Quick Snap — uses the native OS camera/gallery picker via a file input. // Quick Snap — camera-first. On native (the APK) we open the OS camera
// This is more robust than a custom in-app camera (no Capacitor camera // directly via the runtime Camera plugin (with its built-in preview/retake).
// permission plumbing) and works identically on web and native. A single // On web there's no reliable way to force the camera from JS, so we fall back
// photo is all a spot snap needs. // to a file input hinting the rear camera. A single photo is all a spot needs.
const quickSnapInputRef = useRef<HTMLInputElement>(null); const quickSnapInputRef = useRef<HTMLInputElement>(null);
const { mutateAsync: uploadFile, isPending: isUploadingQuickPhoto } = useUploadFile(); const { mutateAsync: uploadFile, isPending: isUploadingQuickPhoto } = useUploadFile();
const { toast } = useToast(); const { toast } = useToast();
@ -762,7 +763,27 @@ export default function MapPage() {
navigate(`/create-spot?lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}`); navigate(`/create-spot?lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}`);
}; };
// Native file-input change → hand the chosen photo to the upload flow. // Quick Snap entry point — camera-first on native, file input on web.
const handleQuickSnap = async () => {
setAddDialOpen(false);
if (isNativeCapture()) {
try {
const file = await captureFromNativeCamera();
if (file) handleQuickCameraCapture(file);
return; // handled natively
} catch (err) {
console.warn('[QuickSnap] native camera failed, falling back to file input:', err);
toast({
title: 'Camera unavailable',
description: 'Opening the photo picker instead.',
});
// fall through to the web/file-input path below
}
}
quickSnapInputRef.current?.click();
};
// Web file-input change → hand the chosen photo to the upload flow.
const handleQuickSnapInput = (e: React.ChangeEvent<HTMLInputElement>) => { const handleQuickSnapInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
// Reset the input so picking the same file again still fires onChange. // Reset the input so picking the same file again still fires onChange.
@ -1709,9 +1730,9 @@ export default function MapPage() {
</span> </span>
</Link> </Link>
))} ))}
{/* Quick Snap — opens the native camera/gallery picker */} {/* Quick Snap — camera-first (native) / file-input (web) */}
<button <button
onClick={() => { setAddDialOpen(false); quickSnapInputRef.current?.click(); }} onClick={handleQuickSnap}
className="flex items-center gap-2.5 animate-in fade-in slide-in-from-bottom-2 duration-200" className="flex items-center gap-2.5 animate-in fade-in slide-in-from-bottom-2 duration-200"
style={{ animationDelay: '200ms' }} style={{ animationDelay: '200ms' }}
> >
@ -1802,9 +1823,9 @@ export default function MapPage() {
</div> </div>
</div> </div>
{/* Quick Snap — hidden native camera/gallery input. {/* Quick Snap — web fallback input (native uses the Camera plugin above).
`capture="environment"` hints the rear camera on phones; on desktop `capture="environment"` hints the rear camera; browsers may honor it
it falls back to a normal file picker. Single photo is all we need. */} or show a chooser. Single photo is all we need. */}
<input <input
ref={quickSnapInputRef} ref={quickSnapInputRef}
type="file" type="file"