feat(ui): redesign QR scanner with state machine and mobile sheet

Rewrite QrScannerView with full state machine (idle/requesting/scanning/
detected/success/error), corner bracket animations snapping to QR
cornerPoints, flash toggle, and haptic feedback.

Add QrScannerSheet as bottom sheet wrapper for mobile. Simplify
ConnectPrompt to use useIsMobile() — mobile shows scan button + sheet,
desktop shows paste-only UI.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-05 08:22:49 +08:00
parent d0681ef46d
commit 845e503899
3 changed files with 397 additions and 152 deletions

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback, lazy, Suspense, useRef } from "react";
import { useState, useCallback } from "react";
import { Button } from "@multica/ui/components/ui/button";
import { Textarea } from "@multica/ui/components/ui/textarea";
import { toast } from "@multica/ui/components/ui/sonner";
@ -9,40 +9,17 @@ import {
parseConnectionCode,
saveConnection,
} from "@multica/store";
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
import { HugeiconsIcon } from "@hugeicons/react";
import { Camera01Icon, TextIcon } from "@hugeicons/core-free-icons";
const LazyQrScannerView = lazy(() =>
import("@multica/ui/components/qr-scanner-view").then((m) => ({
default: m.QrScannerView,
})),
);
type Mode = "scan" | "paste";
import { Camera01Icon } from "@hugeicons/core-free-icons";
import { QrScannerSheet } from "@multica/ui/components/qr-scanner-sheet";
export function ConnectPrompt() {
const gwState = useConnectionStore((s) => s.connectionState);
const [codeInput, setCodeInput] = useState("");
const [mode, setMode] = useState<Mode>("paste"); // SSR-safe default
const [canScan, setCanScan] = useState(false);
const scannedRef = useRef(false);
const [scanOpen, setScanOpen] = useState(false);
const isMobile = useIsMobile();
// Detect mobile + camera capability, auto-switch to scan mode
useEffect(() => {
const isTouchDevice =
"ontouchstart" in window || navigator.maxTouchPoints > 0;
const isNarrow = window.innerWidth < 768;
const hasGetUserMedia = !!navigator.mediaDevices?.getUserMedia;
if (hasGetUserMedia) {
setCanScan(true);
if (isTouchDevice && isNarrow) {
setMode("scan");
}
}
}, []);
// Handle paste-mode connect
const handleConnect = useCallback(() => {
const trimmed = codeInput.trim();
if (!trimmed) return;
@ -56,26 +33,11 @@ export function ConnectPrompt() {
}
}, [codeInput]);
// Handle QR scan result — auto-connect, no button needed
const handleQrScan = useCallback((data: string) => {
// Prevent duplicate connects from rapid successive scans
if (scannedRef.current) return;
scannedRef.current = true;
try {
const info = parseConnectionCode(data);
saveConnection(info);
useConnectionStore.getState().connect(info);
} catch (e) {
toast.error((e as Error).message);
// Allow re-scan on error (invalid/expired code)
scannedRef.current = false;
}
}, []);
const handleScanError = useCallback((msg: string) => {
toast.error(msg);
setMode("paste");
// Promise-based handler for QrScannerView — resolve = success, reject = error
const handleScanResult = useCallback(async (data: string) => {
const info = parseConnectionCode(data);
saveConnection(info);
useConnectionStore.getState().connect(info);
}, []);
const isConnecting = gwState === "connecting" || gwState === "connected";
@ -84,8 +46,8 @@ export function ConnectPrompt() {
<div className="flex flex-col items-center justify-center h-full gap-4 px-4">
<div className="text-center space-y-1">
<p className="text-sm text-muted-foreground">
{mode === "scan"
? "Scan QR code to connect"
{isMobile
? "Scan or paste a connection code"
: "Paste a connection code to start"}
</p>
{isConnecting && (
@ -95,70 +57,54 @@ export function ConnectPrompt() {
)}
</div>
{/* Mode toggle — only show if camera is available */}
{canScan && (
<div className="flex gap-1 bg-muted rounded-lg p-1">
<Button
variant={mode === "scan" ? "default" : "ghost"}
size="sm"
className="text-xs gap-1.5 h-7 px-3"
onClick={() => {
scannedRef.current = false;
setMode("scan");
}}
>
<HugeiconsIcon icon={Camera01Icon} className="size-3.5" />
Scan
</Button>
<Button
variant={mode === "paste" ? "default" : "ghost"}
size="sm"
className="text-xs gap-1.5 h-7 px-3"
onClick={() => setMode("paste")}
>
<HugeiconsIcon icon={TextIcon} className="size-3.5" />
Paste
</Button>
</div>
)}
{/* Content */}
<div className="w-full max-w-sm space-y-3">
{mode === "scan" ? (
<Suspense
fallback={
<div className="h-[280px] animate-pulse bg-muted rounded-xl" />
}
>
<LazyQrScannerView
onScan={handleQrScan}
onError={handleScanError}
/>
</Suspense>
) : (
{/* Mobile: scan button + sheet */}
{isMobile && (
<>
<Textarea
value={codeInput}
onChange={(e) => setCodeInput(e.target.value)}
placeholder="Paste connection code here..."
className="text-xs font-mono min-h-[100px] resize-none"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleConnect();
}
}}
/>
<Button
size="sm"
onClick={handleConnect}
disabled={!codeInput.trim() || gwState === "connecting"}
className="w-full text-xs"
onClick={() => setScanOpen(true)}
className="w-full text-xs gap-2"
>
Connect
<HugeiconsIcon icon={Camera01Icon} className="size-4" />
Scan QR Code
</Button>
<QrScannerSheet
open={scanOpen}
onOpenChange={setScanOpen}
onResult={handleScanResult}
/>
</>
)}
{/* Paste UI (always shown) */}
<Textarea
value={codeInput}
onChange={(e) => setCodeInput(e.target.value)}
placeholder="Paste connection code here..."
className="text-xs font-mono min-h-[100px] resize-none"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleConnect();
}
}}
/>
<Button
size="sm"
onClick={handleConnect}
disabled={!codeInput.trim() || gwState === "connecting"}
className="w-full text-xs"
>
Connect
</Button>
{/* Mobile: paste fallback hint */}
{isMobile && (
<p className="text-xs text-muted-foreground text-center">
or paste code above instead
</p>
)}
</div>
</div>
);

View file

@ -0,0 +1,40 @@
"use client"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@multica/ui/components/ui/sheet"
import { QrScannerView } from "@multica/ui/components/qr-scanner-view"
export interface QrScannerSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
onResult: (data: string) => Promise<void>
}
export function QrScannerSheet({
open,
onOpenChange,
onResult,
}: QrScannerSheetProps) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="px-4 pb-8">
{/* Drag handle */}
<div className="mx-auto mt-2 mb-1 h-1 w-10 rounded-full bg-muted-foreground/30" />
<SheetHeader>
<SheetTitle className="text-center">Scan Connection Code</SheetTitle>
</SheetHeader>
<div className="mt-4">
<QrScannerView
open={open}
onResult={onResult}
onClose={() => onOpenChange(false)}
/>
</div>
</SheetContent>
</Sheet>
)
}

View file

@ -1,82 +1,341 @@
"use client"
import { useQrScanner } from "@multica/ui/hooks/use-qr-scanner"
import "./qr-scanner.css"
interface QrScannerViewProps {
onScan: (data: string) => void
onError?: (error: string) => void
import { useState, useCallback, useRef, useEffect } from "react"
import { useQrScanner, type Point } from "@multica/ui/hooks/use-qr-scanner"
import { Spinner } from "@multica/ui/components/spinner"
import { HugeiconsIcon } from "@hugeicons/react"
import {
Camera01Icon,
CheckmarkCircle02Icon,
Alert02Icon,
FlashlightIcon,
} from "@hugeicons/core-free-icons"
type ScannerState =
| "idle"
| "requesting"
| "scanning"
| "detected"
| "success"
| "error"
export interface QrScannerProps {
onResult: (data: string) => Promise<void>
onClose?: () => void
open?: boolean
}
/**
* Camera viewfinder for QR code scanning.
* Standalone QR scanner with full state machine.
*
* Renders a live camera feed with a decorative scan frame overlay.
* Uses getUserMedia via the qr-scanner library (WebWorker-based decoding).
* iOS requires playsinline + muted + autoplay on the <video> element.
* States: idle requesting scanning detected success (auto-close)
*
* error
*/
export function QrScannerView({ onScan, onError }: QrScannerViewProps) {
const { videoRef, isScanning, error, hasCamera } = useQrScanner({
onScan,
onError,
enabled: true,
export function QrScannerView({ onResult, onClose, open }: QrScannerProps) {
const [state, setState] = useState<ScannerState>("idle")
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [mappedPoints, setMappedPoints] = useState<Point[] | null>(null)
const containerRef = useRef<HTMLDivElement | null>(null)
const stateRef = useRef(state)
stateRef.current = state
const startRef = useRef<(() => Promise<void>) | null>(null)
const handleScan = useCallback(
(data: string) => {
if (stateRef.current !== "scanning") return
setState("detected")
navigator.vibrate?.(50)
// Brief detected state then validate
setTimeout(async () => {
try {
await onResult(data)
setState("success")
navigator.vibrate?.(50)
// Auto-close after success
setTimeout(() => onClose?.(), 800)
} catch (e) {
setErrorMessage((e as Error).message || "Invalid code")
setState("error")
navigator.vibrate?.([30, 50, 30])
// Auto-retry after error
setTimeout(() => {
setErrorMessage(null)
setState("scanning")
startRef.current?.()
}, 1500)
}
}, 200)
},
[onResult, onClose],
)
const {
videoRef,
hasCamera,
cornerPoints,
hasFlash,
toggleFlash,
start: scannerStart,
stop: scannerStop,
pause: scannerPause,
} = useQrScanner({
onScan: handleScan,
enabled: false,
})
startRef.current = scannerStart
// Map corner points from video coordinates to container coordinates
useEffect(() => {
if (!cornerPoints || !containerRef.current || !videoRef.current) {
setMappedPoints(null)
return
}
const video = videoRef.current
const container = containerRef.current
const containerRect = container.getBoundingClientRect()
const videoWidth = video.videoWidth || 1
const videoHeight = video.videoHeight || 1
const scaleX = containerRect.width / videoWidth
const scaleY = containerRect.height / videoHeight
setMappedPoints(
cornerPoints.map((p) => ({
x: p.x * scaleX,
y: p.y * scaleY,
})),
)
}, [cornerPoints, videoRef])
// Pause video on detected state
useEffect(() => {
if (state === "detected" || state === "success") {
scannerPause()
}
}, [state, scannerPause])
// Reset state when `open` toggles
useEffect(() => {
if (open === false) {
scannerStop()
setState("idle")
setErrorMessage(null)
setMappedPoints(null)
}
}, [open, scannerStop])
// Cleanup on unmount
useEffect(() => {
return () => scannerStop()
}, [scannerStop])
const handleStart = useCallback(async () => {
setState("requesting")
// Check camera permission (try/catch for Safari which doesn't support camera query)
try {
const perm = await navigator.permissions?.query({
name: "camera" as PermissionName,
})
if (perm?.state === "denied") {
setState("idle")
setErrorMessage("Camera access denied. Please enable it in your browser settings.")
onClose?.()
return
}
} catch {
// Safari doesn't support camera permission query — proceed anyway
}
await scannerStart()
setState("scanning")
}, [scannerStart, onClose])
if (!hasCamera) {
return (
<div className="flex items-center justify-center h-[280px] rounded-xl bg-muted">
<div className="flex items-center justify-center h-[320px] rounded-xl bg-muted">
<p className="text-sm text-muted-foreground">No camera available</p>
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-[280px] rounded-xl bg-muted gap-2">
<p className="text-sm text-muted-foreground">Camera access denied</p>
<p className="text-xs text-muted-foreground/60">
Switch to paste mode below
</p>
</div>
)
}
// Compute bounding box from corner points for detected/success/error bracket positioning
const bracketBounds = mappedPoints
? {
left: Math.min(...mappedPoints.map((p) => p.x)),
top: Math.min(...mappedPoints.map((p) => p.y)),
right: Math.max(...mappedPoints.map((p) => p.x)),
bottom: Math.max(...mappedPoints.map((p) => p.y)),
}
: null
const bracketColor =
state === "success"
? "border-[color:var(--tool-success)]"
: state === "error"
? "border-[color:var(--tool-error)]"
: state === "detected"
? "border-primary"
: "border-white/30"
const bracketAnimation =
state === "scanning"
? "animate-scan-breathe"
: state === "error"
? "animate-scan-shake"
: ""
return (
<div className="relative w-full max-w-[280px] mx-auto">
{/* Camera feed */}
<div className="relative aspect-square rounded-xl overflow-hidden bg-black">
<div className="relative w-full max-w-[320px] mx-auto">
<div
ref={containerRef}
className="relative aspect-square rounded-xl overflow-hidden bg-muted"
>
{/* Camera feed (always rendered for ref stability) */}
<video
ref={videoRef}
autoPlay
playsInline
muted
className="absolute inset-0 w-full h-full object-cover"
className={`absolute inset-0 w-full h-full object-cover ${
state === "idle" || state === "requesting" ? "invisible" : ""
}`}
/>
{/* Scan frame overlay */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="relative w-3/4 h-3/4">
{/* Corner accents */}
<div className="absolute -top-1 -left-1 w-5 h-5 border-t-2 border-l-2 border-white/70 rounded-tl-md" />
<div className="absolute -top-1 -right-1 w-5 h-5 border-t-2 border-r-2 border-white/70 rounded-tr-md" />
<div className="absolute -bottom-1 -left-1 w-5 h-5 border-b-2 border-l-2 border-white/70 rounded-bl-md" />
<div className="absolute -bottom-1 -right-1 w-5 h-5 border-b-2 border-r-2 border-white/70 rounded-br-md" />
{/* Idle state */}
{state === "idle" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
<button
type="button"
onClick={handleStart}
className="flex items-center justify-center size-16 rounded-full bg-foreground/10 hover:bg-foreground/20 transition-colors"
>
<HugeiconsIcon
icon={Camera01Icon}
className="size-7 text-muted-foreground"
/>
</button>
<p className="text-xs text-muted-foreground">Tap to scan</p>
</div>
</div>
)}
{/* Loading state */}
{!isScanning && !error && (
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
<p className="text-xs text-white/80 animate-pulse">
Starting camera...
{/* Requesting state */}
{state === "requesting" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
<Spinner className="text-muted-foreground" />
<p className="text-xs text-muted-foreground">
Requesting camera...
</p>
</div>
)}
{/* Corner brackets overlay */}
{(state === "scanning" ||
state === "detected" ||
state === "success" ||
state === "error") && (
<div className="absolute inset-0 pointer-events-none">
{bracketBounds ? (
// Position brackets around detected QR code
<div
className={`absolute ${bracketAnimation}`}
style={{
left: bracketBounds.left - 8,
top: bracketBounds.top - 8,
width: bracketBounds.right - bracketBounds.left + 16,
height: bracketBounds.bottom - bracketBounds.top + 16,
}}
>
<div
className={`absolute -top-1 -left-1 w-5 h-5 border-t-2 border-l-2 ${bracketColor} rounded-tl-md transition-colors duration-200`}
/>
<div
className={`absolute -top-1 -right-1 w-5 h-5 border-t-2 border-r-2 ${bracketColor} rounded-tr-md transition-colors duration-200`}
/>
<div
className={`absolute -bottom-1 -left-1 w-5 h-5 border-b-2 border-l-2 ${bracketColor} rounded-bl-md transition-colors duration-200`}
/>
<div
className={`absolute -bottom-1 -right-1 w-5 h-5 border-b-2 border-r-2 ${bracketColor} rounded-br-md transition-colors duration-200`}
/>
</div>
) : (
// Default centered brackets
<div className="absolute inset-0 flex items-center justify-center">
<div
className={`relative w-3/4 h-3/4 ${bracketAnimation}`}
>
<div
className={`absolute -top-1 -left-1 w-5 h-5 border-t-2 border-l-2 ${bracketColor} rounded-tl-md transition-colors duration-200`}
/>
<div
className={`absolute -top-1 -right-1 w-5 h-5 border-t-2 border-r-2 ${bracketColor} rounded-tr-md transition-colors duration-200`}
/>
<div
className={`absolute -bottom-1 -left-1 w-5 h-5 border-b-2 border-l-2 ${bracketColor} rounded-bl-md transition-colors duration-200`}
/>
<div
className={`absolute -bottom-1 -right-1 w-5 h-5 border-b-2 border-r-2 ${bracketColor} rounded-br-md transition-colors duration-200`}
/>
</div>
</div>
)}
</div>
)}
{/* Flash toggle */}
{state === "scanning" && hasFlash && (
<button
type="button"
onClick={toggleFlash}
className="absolute top-3 right-3 flex items-center justify-center size-8 rounded-full bg-black/40 hover:bg-black/60 transition-colors pointer-events-auto"
>
<HugeiconsIcon
icon={FlashlightIcon}
className="size-4 text-white"
/>
</button>
)}
{/* Success overlay */}
{state === "success" && (
<div className="absolute inset-0 flex items-center justify-center">
<HugeiconsIcon
icon={CheckmarkCircle02Icon}
className="size-12 text-[color:var(--tool-success)] animate-in fade-in zoom-in duration-300"
/>
</div>
)}
{/* Error overlay */}
{state === "error" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<HugeiconsIcon
icon={Alert02Icon}
className="size-10 text-[color:var(--tool-error)]"
/>
{errorMessage && (
<p className="text-xs text-white bg-black/60 px-3 py-1 rounded-full">
{errorMessage}
</p>
)}
</div>
)}
</div>
{/* Hint text */}
<p className="text-xs text-muted-foreground text-center mt-3">
Point camera at QR code on desktop
</p>
{state === "scanning" && (
<p className="text-xs text-muted-foreground text-center mt-3">
Point at QR code on desktop
</p>
)}
</div>
)
}