Migrate from .NET MAUI to Expo + Convex
Complete rewrite of Scry using TypeScript stack:
- Expo/React Native with Expo Router (file-based routing)
- Convex backend (serverless functions + real-time database)
- Adaptive camera system (expo-camera in Expo Go, Vision Camera in production)
- React Native Skia + fast-opencv for image processing
- GDPR-compliant auth setup with Zitadel OIDC (pending configuration)
Key features:
- Card recognition pipeline ported to TypeScript
- Perceptual hashing (192-bit color pHash)
- CLAHE preprocessing for lighting normalization
- Local SQLite cache with Convex sync
- Collection management with offline support
Removes all .NET/MAUI code (src/, test/, tools/).
💘 Generated with Crush
Assisted-by: Claude Opus 4.5 via Crush <crush@charm.land>
42
.agents/skills/frontend-designer.md
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
---
|
||||||
|
name: frontend-design
|
||||||
|
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
|
||||||
|
license: Complete terms in LICENSE.txt
|
||||||
|
---
|
||||||
|
|
||||||
|
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
|
||||||
|
|
||||||
|
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
|
||||||
|
|
||||||
|
## Design Thinking
|
||||||
|
|
||||||
|
Before coding, understand the context and commit to a BOLD aesthetic direction:
|
||||||
|
- **Purpose**: What problem does this interface solve? Who uses it?
|
||||||
|
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
|
||||||
|
- **Constraints**: Technical requirements (framework, performance, accessibility).
|
||||||
|
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
|
||||||
|
|
||||||
|
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
|
||||||
|
|
||||||
|
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
|
||||||
|
- Production-grade and functional
|
||||||
|
- Visually striking and memorable
|
||||||
|
- Cohesive with a clear aesthetic point-of-view
|
||||||
|
- Meticulously refined in every detail
|
||||||
|
|
||||||
|
## Frontend Aesthetics Guidelines
|
||||||
|
|
||||||
|
Focus on:
|
||||||
|
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
|
||||||
|
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
|
||||||
|
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
|
||||||
|
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
|
||||||
|
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
|
||||||
|
|
||||||
|
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
|
||||||
|
|
||||||
|
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
|
||||||
|
|
||||||
|
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
|
||||||
|
|
||||||
|
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
|
||||||
30
.crush.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://charm.land/crush.json",
|
||||||
|
"permissions": {
|
||||||
|
"allowed_tools": [
|
||||||
|
"view",
|
||||||
|
"ls",
|
||||||
|
"grep",
|
||||||
|
"glob",
|
||||||
|
"edit",
|
||||||
|
"go",
|
||||||
|
"just",
|
||||||
|
"agent",
|
||||||
|
"agentic_fetch"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"initialize_as": "AGENTS.md",
|
||||||
|
"skills_paths": ["./.agents/skills"]
|
||||||
|
},
|
||||||
|
"lsp": {
|
||||||
|
"typescript": {
|
||||||
|
"command": "typescript-language-server",
|
||||||
|
"args": ["--stdio"]
|
||||||
|
},
|
||||||
|
"omnisharp": {
|
||||||
|
"command": "wsl",
|
||||||
|
"args": ["--", "bash", "-lc", "\"OmniSharp\""]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
.gitignore
vendored
|
|
@ -1,24 +1,41 @@
|
||||||
# .NET
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
bin/
|
|
||||||
obj/
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
dist/
|
dist/
|
||||||
*.dll
|
web-build/
|
||||||
*.exe
|
expo-env.d.ts
|
||||||
*.pdb
|
|
||||||
|
|
||||||
# IDE
|
# Native
|
||||||
.vs/
|
.kotlin/
|
||||||
.vscode/
|
*.orig.*
|
||||||
.idea/
|
*.jks
|
||||||
*.user
|
*.p8
|
||||||
*.suo
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
# OS
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
*.pem
|
||||||
|
|
||||||
# Project specific
|
# local env files
|
||||||
*.csv
|
.env*.local
|
||||||
*.dlens
|
|
||||||
*.apk
|
# typescript
|
||||||
debug/
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
|
|
|
||||||
35
.just/emu.just
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
set shell := ["C:/Program Files/Git/usr/bin/bash.exe", "-c"]
|
||||||
|
set unstable := true
|
||||||
|
|
||||||
|
android_sdk := replace(env('LOCALAPPDATA'), '\', '/') / "Android/Sdk"
|
||||||
|
adb := android_sdk / "platform-tools/adb.exe"
|
||||||
|
avd := android_sdk / "cmdline-tools/latest/bin/avdmanager.bat"
|
||||||
|
emulator := android_sdk / "emulator/emulator.exe"
|
||||||
|
camera_virtual := "-camera-back virtualscene -virtualscene-poster wall=\"" + (justfile_directory() / "TestImages/reference_alpha/serra_angel.jpg") + "\""
|
||||||
|
camera_webcam := "-camera-back webcam0 -camera-front webcam0"
|
||||||
|
|
||||||
|
default camera="virtual":
|
||||||
|
{{ emulator }} -avd Pixel_6 {{ if camera == "virtual" { camera_virtual } else { camera_webcam } }} -no-snapshot-load -gpu host
|
||||||
|
|
||||||
|
install:
|
||||||
|
{{ avd }} delete avd -n Pixel_6
|
||||||
|
{{ avd }} create avd -n Pixel_6 -k "system-images;android-36;google_apis_playstore;x86_64" -d pixel_6
|
||||||
|
|
||||||
|
# Wait for emulator to fully boot (timeout after 2 minutes)
|
||||||
|
[script]
|
||||||
|
emu-wait:
|
||||||
|
# Wait for Android emulator to boot with timeout
|
||||||
|
TIMEOUT=120
|
||||||
|
|
||||||
|
echo "Waiting for emulator to boot..."
|
||||||
|
|
||||||
|
for ((i=TIMEOUT; i>0; i--)); do
|
||||||
|
if [ "$({{ adb }} shell getprop sys.boot_completed 2>/dev/null)" = "1" ]; then
|
||||||
|
echo "Emulator ready"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Emulator failed to boot within 2 minutes"
|
||||||
|
exit 1
|
||||||
60
.justfile
|
|
@ -3,45 +3,17 @@
|
||||||
set shell := ["C:/Program Files/Git/usr/bin/bash.exe", "-c"]
|
set shell := ["C:/Program Files/Git/usr/bin/bash.exe", "-c"]
|
||||||
set unstable := true
|
set unstable := true
|
||||||
|
|
||||||
|
mod emu '.just/emu.just'
|
||||||
|
|
||||||
# Android SDK paths
|
# Android SDK paths
|
||||||
|
|
||||||
android_sdk := replace(env('LOCALAPPDATA'), '\', '/') / "Android/Sdk"
|
android_sdk := replace(env('LOCALAPPDATA'), '\', '/') / "Android/Sdk"
|
||||||
adb := android_sdk / "platform-tools/adb.exe"
|
adb := android_sdk / "platform-tools/adb.exe"
|
||||||
emulator := android_sdk / "emulator/emulator.exe"
|
|
||||||
camera_virtual := "-camera-back virtualscene -virtualscene-poster wall=\"" + (justfile_directory() / "TestImages/reference_alpha/serra_angel.jpg") + "\""
|
|
||||||
camera_webcam := "-camera-back webcam0 -camera-front webcam0"
|
|
||||||
|
|
||||||
[private]
|
[private]
|
||||||
@default:
|
@default:
|
||||||
just --list
|
just --list
|
||||||
|
|
||||||
# Start emulator in background
|
|
||||||
emu camera="virtual":
|
|
||||||
{{ emulator }} -avd Pixel_6 {{ if camera == "virtual" { camera_virtual } else { camera_webcam } }} -no-snapshot-load -gpu host
|
|
||||||
|
|
||||||
# Kill the running emulator
|
|
||||||
emu-kill:
|
|
||||||
{{ adb }} emu kill
|
|
||||||
|
|
||||||
# Wait for emulator to fully boot (timeout after 2 minutes)
|
|
||||||
[script]
|
|
||||||
emu-wait:
|
|
||||||
# Wait for Android emulator to boot with timeout
|
|
||||||
TIMEOUT=120
|
|
||||||
|
|
||||||
echo "Waiting for emulator to boot..."
|
|
||||||
|
|
||||||
for ((i=TIMEOUT; i>0; i--)); do
|
|
||||||
if [ "$({{ adb }} shell getprop sys.boot_completed 2>/dev/null)" = "1" ]; then
|
|
||||||
echo "Emulator ready"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Emulator failed to boot within 2 minutes"
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
# Build a project
|
# Build a project
|
||||||
build project="src/Scry.App" target="net10.0-android":
|
build project="src/Scry.App" target="net10.0-android":
|
||||||
@echo "Building {{ project }}..."
|
@echo "Building {{ project }}..."
|
||||||
|
|
@ -79,6 +51,30 @@ gen-db: (build "tools/DbGenerator" "net10.0")
|
||||||
dotnet run --project tools/DbGenerator --no-build -- src/Scry.App/Resources/Raw/card_hashes.db
|
dotnet run --project tools/DbGenerator --no-build -- src/Scry.App/Resources/Raw/card_hashes.db
|
||||||
@echo "Completed generating the database"
|
@echo "Completed generating the database"
|
||||||
|
|
||||||
# Full workflow: start emulator, wait, run with hot reload
|
# Start Expo dev server with Convex (hot reload)
|
||||||
dev:
|
dev:
|
||||||
dotnet watch --project src/Scry.App -f net10.0-android
|
bun run dev
|
||||||
|
|
||||||
|
# Start just the Expo dev server
|
||||||
|
start:
|
||||||
|
bun run dev:expo
|
||||||
|
|
||||||
|
# Start just Convex dev server
|
||||||
|
convex-dev:
|
||||||
|
bun run dev:convex
|
||||||
|
|
||||||
|
# Run Expo app on Android emulator
|
||||||
|
android:
|
||||||
|
bun run android
|
||||||
|
|
||||||
|
# Install Expo app dependencies
|
||||||
|
expo-install:
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Run hash migration to Convex
|
||||||
|
expo-migrate:
|
||||||
|
bun run migrate
|
||||||
|
|
||||||
|
# TypeScript check for Expo app
|
||||||
|
expo-typecheck:
|
||||||
|
bun run typecheck
|
||||||
|
|
|
||||||
357
AGENTS.md
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Scry is a Magic: The Gathering card scanner app built with .NET MAUI. It uses perceptual hashing to match photographed cards against a database of known card images from Scryfall.
|
Scry is a Magic: The Gathering card scanner app built with Expo (React Native) and Convex. It uses perceptual hashing to match photographed cards against a database of known card images from Scryfall.
|
||||||
|
|
||||||
**Key components:**
|
**Tech Stack:**
|
||||||
- Mobile scanning app (MAUI/Android)
|
- **Frontend**: Expo/React Native with Expo Router (file-based routing)
|
||||||
- Card recognition via perceptual hashing (not OCR)
|
- **Backend**: Convex (serverless functions + real-time database)
|
||||||
- SQLite database with pre-computed hashes
|
- **Image Processing**: React Native Skia, fast-opencv
|
||||||
- Scryfall API integration for card data
|
- **Camera**: Adaptive (expo-camera in dev, react-native-vision-camera in production)
|
||||||
|
- **Auth**: Convex Auth with Zitadel OIDC (GDPR-compliant, no PII stored)
|
||||||
|
- **Package Manager**: Bun (not npm/yarn)
|
||||||
|
|
||||||
## Build Commands
|
## Build Commands
|
||||||
|
|
||||||
|
|
@ -16,55 +18,84 @@ Use `just` commands (defined in `.justfile`):
|
||||||
|
|
||||||
| Task | Command | Notes |
|
| Task | Command | Notes |
|
||||||
|------|---------|-------|
|
|------|---------|-------|
|
||||||
| Build project | `just build` | Builds Android debug |
|
| Start dev server | `just dev` | Runs Convex + Expo together |
|
||||||
| Run tests | `just test` | Runs xUnit tests |
|
| Expo only | `just start` | Just the Expo dev server |
|
||||||
| Generate card database | `just gen-db` | Downloads from Scryfall, computes hashes |
|
| Convex only | `just convex-dev` | Just the Convex dev server |
|
||||||
| Publish app | `just publish` | Creates release APK |
|
| Run on Android | `just android` | Starts Android emulator |
|
||||||
| Hot reload dev | `just dev` | Uses `dotnet watch` |
|
| Install deps | `just expo-install` | Runs `bun install` |
|
||||||
| Start emulator | `just emu` | Virtual camera with Serra Angel |
|
| Migrate hashes | `just expo-migrate` | Migrate card hashes to Convex |
|
||||||
| Install to device | `just install` | Installs release APK |
|
| Type check | `just expo-typecheck` | TypeScript check |
|
||||||
|
| Start emulator | `just emu` | Virtual camera (submodule) |
|
||||||
|
|
||||||
### Database Generator Options
|
### Direct Bun Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
just gen-db # Default: 500 cards with test images
|
bun install # Install dependencies
|
||||||
dotnet run --project tools/DbGenerator -- -c 1000 # More cards
|
bun run dev # Convex + Expo hot reload
|
||||||
dotnet run --project tools/DbGenerator -- --force # Rebuild from scratch
|
bun run dev:convex # Convex dev server only
|
||||||
dotnet run --project tools/DbGenerator -- --no-test-cards # Skip priority test cards
|
bun run dev:expo # Expo dev server only
|
||||||
|
bun run android # Run on Android
|
||||||
|
bun run migrate # Migrate hashes to Convex
|
||||||
|
bun run typecheck # TypeScript check
|
||||||
|
bunx convex dev # Convex CLI
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
app/ # Expo Router pages
|
||||||
├── Scry.App/ # MAUI mobile app (Android target)
|
├── _layout.tsx # Root layout (Convex + HashCache providers)
|
||||||
│ ├── Views/ # XAML pages (ScanPage, CollectionPage, etc.)
|
├── modal.tsx # Card details modal
|
||||||
│ ├── ViewModels/ # MVVM ViewModels using CommunityToolkit.Mvvm
|
├── +not-found.tsx # 404 page
|
||||||
│ ├── Services/ # App-layer services (ICardRecognitionService, ICardRepository)
|
└── (tabs)/ # Tab navigation group
|
||||||
│ ├── Converters/ # XAML value converters
|
├── _layout.tsx # Tab bar layout
|
||||||
│ ├── Models/ # App-specific models (CollectionEntry)
|
├── index.tsx # Collection tab (home)
|
||||||
│ └── Resources/Raw/ # Bundled card_hashes.db
|
├── scan.tsx # Camera scan tab
|
||||||
│
|
└── settings.tsx # Settings tab
|
||||||
└── Scry.Core/ # Platform-independent core library
|
|
||||||
├── Recognition/ # CardRecognitionService, RecognitionOptions
|
|
||||||
├── Imaging/ # PerceptualHash, ImagePreprocessor, CardDetector
|
|
||||||
├── Data/ # CardDatabase (SQLite)
|
|
||||||
├── Models/ # Card, Oracle, Set, ScanResult
|
|
||||||
└── Scryfall/ # ScryfallClient for API/bulk data
|
|
||||||
|
|
||||||
test/
|
components/
|
||||||
└── Scry.Tests/ # xUnit tests
|
├── camera/ # Adaptive camera system
|
||||||
|
│ ├── index.tsx # AdaptiveCamera wrapper
|
||||||
|
│ ├── ExpoCamera.tsx # expo-camera (Expo Go)
|
||||||
|
│ └── VisionCamera.tsx # react-native-vision-camera (production)
|
||||||
|
└── *.tsx # Shared UI components
|
||||||
|
|
||||||
tools/
|
convex/ # Backend (Convex functions + schema)
|
||||||
└── DbGenerator/ # CLI tool to generate card_hashes.db
|
├── schema.ts # Database schema
|
||||||
|
├── auth.ts # Zitadel OIDC configuration
|
||||||
|
├── http.ts # HTTP endpoints for auth
|
||||||
|
├── cards.ts # Card queries/mutations
|
||||||
|
├── collections.ts # User collection functions
|
||||||
|
├── users.ts # User functions
|
||||||
|
├── scanHistory.ts # Scan history functions
|
||||||
|
└── _generated/ # Auto-generated types
|
||||||
|
|
||||||
TestImages/ # Test images organized by category
|
lib/
|
||||||
├── reference_alpha/ # Alpha/Beta cards for testing
|
├── recognition/ # Card recognition pipeline
|
||||||
├── single_cards/ # Individual card photos
|
│ ├── recognitionService.ts # Main recognition logic
|
||||||
├── varying_quality/ # Different lighting/quality
|
│ ├── cardDetector.ts # Edge detection, find card quad
|
||||||
├── hands/ # Cards held in hand
|
│ ├── perspectiveCorrection.ts # Warp to rectangle
|
||||||
├── foil/ # Foil cards with glare
|
│ ├── clahe.ts # CLAHE lighting normalization
|
||||||
└── ... # More categories
|
│ ├── perceptualHash.ts # 192-bit color hash (24 bytes)
|
||||||
|
│ ├── imageUtils.ts # Resize, rotate, grayscale
|
||||||
|
│ ├── imageLoader.ts # Load/resize images
|
||||||
|
│ └── skiaDecoder.ts # Decode images with Skia
|
||||||
|
├── hooks/ # React hooks
|
||||||
|
│ ├── useAuth.ts # OAuth flow with expo-auth-session
|
||||||
|
│ ├── useCamera.ts # Adaptive camera permissions
|
||||||
|
│ ├── useConvex.ts # Convex data hooks
|
||||||
|
│ ├── useSync.ts # Hash sync hook
|
||||||
|
│ └── useUserProfile.ts # Fetch profile from Zitadel
|
||||||
|
├── context/ # React contexts
|
||||||
|
│ └── HashCacheContext.tsx # In-memory hash cache
|
||||||
|
└── db/ # Local database utilities
|
||||||
|
├── localDatabase.ts # SQLite wrapper
|
||||||
|
└── syncService.ts # Sync with Convex
|
||||||
|
|
||||||
|
scripts/
|
||||||
|
└── migrate-hashes.ts # Migration script
|
||||||
|
|
||||||
|
TestImages/ # Test images (225 files)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
@ -76,114 +107,67 @@ Camera Image
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────────┐
|
┌─────────────────────┐
|
||||||
│ CardDetector │ ← Edge detection, find card quad
|
│ loadImageAsBase64 │ ← Resize to 480×640
|
||||||
|
└─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ decodeImageBase64 │ ← Skia decodes to RGBA pixels
|
||||||
|
└─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ detectCard │ ← Edge detection, find card quad
|
||||||
│ (optional) │
|
│ (optional) │
|
||||||
└─────────────────────┘
|
└─────────────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────────┐
|
┌─────────────────────┐
|
||||||
│ PerspectiveCorrection│ ← Warp to rectangle
|
│ warpPerspective │ ← Warp detected quad to rectangle
|
||||||
└─────────────────────┘
|
└─────────────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────────┐
|
┌─────────────────────┐
|
||||||
│ ImagePreprocessor │ ← CLAHE for lighting normalization
|
│ applyCLAHE │ ← Lighting normalization
|
||||||
│ (ApplyClahe) │
|
|
||||||
└─────────────────────┘
|
└─────────────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────────┐
|
┌─────────────────────┐
|
||||||
│ PerceptualHash │ ← Compute 192-bit color hash (24 bytes)
|
│ computeColorHash │ ← Compute 192-bit color hash (24 bytes)
|
||||||
│ (ComputeColorHash) │
|
|
||||||
└─────────────────────┘
|
└─────────────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────────┐
|
┌─────────────────────┐
|
||||||
│ CardRecognitionService│ ← Hamming distance match against DB
|
│ recognizeCard │ ← Hamming distance match against cache
|
||||||
└─────────────────────┘
|
└─────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data Model
|
### Data Model (Convex Schema)
|
||||||
|
|
||||||
Three-table schema mirroring Scryfall:
|
| Table | Purpose |
|
||||||
|
|
||||||
- **oracles** - Abstract game cards (one per unique card name)
|
|
||||||
- **sets** - MTG sets with metadata
|
|
||||||
- **cards** - Printings with perceptual hashes (one per unique artwork)
|
|
||||||
|
|
||||||
The `Card` model includes denormalized Oracle fields for convenience.
|
|
||||||
|
|
||||||
### Key Classes
|
|
||||||
|
|
||||||
| Class | Purpose |
|
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| `CardRecognitionService` | Main recognition logic, caches DB, handles rotation matching |
|
| `users` | Minimal auth (no PII, GDPR compliant) |
|
||||||
| `PerceptualHash` | DCT-based color hash (192-bit = 8 bytes × 3 RGB channels) |
|
| `cards` | Card printings with 24-byte perceptual hashes |
|
||||||
| `ImagePreprocessor` | CLAHE, resize, grayscale conversions |
|
| `oracles` | Abstract game cards (one per unique card name) |
|
||||||
| `CardDetector` | Edge detection + contour analysis to find card boundaries |
|
| `sets` | MTG sets with metadata |
|
||||||
| `PerspectiveCorrection` | Warp detected quad to rectangle |
|
| `collections` | User card collections |
|
||||||
| `CardDatabase` | SQLite wrapper with batch insert, queries |
|
| `scanHistory` | Scan history with confidence scores |
|
||||||
| `ScryfallClient` | Bulk data streaming, image downloads |
|
| `metadata` | Sync metadata |
|
||||||
|
|
||||||
## Code Conventions
|
### GDPR Compliance
|
||||||
|
|
||||||
### General
|
- Database stores **no user PII** - only auth subject ID
|
||||||
|
- User profile (name, email, image) fetched from Zitadel userinfo endpoint on demand
|
||||||
|
- Profile held in memory only, never persisted
|
||||||
|
|
||||||
- **Target**: .NET 10.0 (net10.0-android for app, net10.0 for Core/tools)
|
### Adaptive Camera System
|
||||||
- **Nullable**: Enabled everywhere (`<Nullable>enable</Nullable>`)
|
|
||||||
- **Warnings as errors**: `<TreatWarningsAsErrors>true</TreatWarningsAsErrors>`
|
|
||||||
- **Central package management**: Versions in `Directory.Packages.props`
|
|
||||||
|
|
||||||
### C# Style
|
The app detects its runtime environment and uses the appropriate camera:
|
||||||
|
|
||||||
- Records for data models (`record Card`, `record ScanResult`)
|
- **Expo Go** (`Constants.appOwnership === "expo"`): Uses `expo-camera`
|
||||||
- `required` properties for non-nullable required fields
|
- **Production builds**: Uses `react-native-vision-camera`
|
||||||
- Extension methods for conversions (`ScryfallCard.ToCard()`)
|
|
||||||
- Static classes for pure functions (`PerceptualHash`, `ImagePreprocessor`)
|
|
||||||
- `using` declarations (not `using` blocks) for disposables
|
|
||||||
- File-scoped namespaces
|
|
||||||
- Primary constructors where appropriate
|
|
||||||
- `CancellationToken` parameter on all async methods
|
|
||||||
|
|
||||||
### MVVM (App layer)
|
Both expose the same `CameraHandle` interface with `takePhoto()`.
|
||||||
|
|
||||||
- `CommunityToolkit.Mvvm` for source generators
|
|
||||||
- `[ObservableProperty]` attributes for bindable properties
|
|
||||||
- `[RelayCommand]` for commands
|
|
||||||
- ViewModels in `Scry.ViewModels` namespace
|
|
||||||
|
|
||||||
### Naming
|
|
||||||
|
|
||||||
- Services: `ICardRecognitionService`, `CardRecognitionService`
|
|
||||||
- Database methods: `GetCardsWithHashAsync`, `InsertCardBatchAsync`
|
|
||||||
- Hash methods: `ComputeColorHash`, `HammingDistance`
|
|
||||||
- Test methods: `RecognizeAsync_ExactMatch_ReturnsSuccess`
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Tests are in `test/Scry.Tests` using xUnit.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
just test # Run all tests
|
|
||||||
dotnet test --filter "FullyQualifiedName~PerceptualHash" # Filter by name
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Categories
|
|
||||||
|
|
||||||
| Test Class | Tests |
|
|
||||||
|------------|-------|
|
|
||||||
| `PerceptualHashTests` | Hash computation, Hamming distance |
|
|
||||||
| `CardRecognitionTests` | End-to-end recognition |
|
|
||||||
| `CardDatabaseTests` | SQLite CRUD operations |
|
|
||||||
| `ImagePreprocessorTests` | CLAHE, resize |
|
|
||||||
| `RobustnessAnalysisTests` | Multiple image variations |
|
|
||||||
|
|
||||||
### Test Images
|
|
||||||
|
|
||||||
TestImages directory contains categorized reference images:
|
|
||||||
- `reference_alpha/` - Alpha/Beta cards (matching DbGenerator priority cards)
|
|
||||||
- `single_cards/` - Clean single card photos
|
|
||||||
- `varying_quality/` - Different lighting/blur conditions
|
|
||||||
|
|
||||||
## Key Algorithms
|
## Key Algorithms
|
||||||
|
|
||||||
|
|
@ -206,86 +190,99 @@ Applied in LAB color space to L channel only:
|
||||||
- Clip limit prevents over-amplification of noise
|
- Clip limit prevents over-amplification of noise
|
||||||
- Bilinear interpolation between tiles for smooth output
|
- Bilinear interpolation between tiles for smooth output
|
||||||
|
|
||||||
### Card Detection
|
## Code Conventions
|
||||||
|
|
||||||
Pure SkiaSharp implementation:
|
### General
|
||||||
1. Grayscale → Gaussian blur → Canny edge detection
|
|
||||||
2. Contour tracing via flood fill
|
|
||||||
3. Douglas-Peucker simplification → Convex hull
|
|
||||||
4. Find best quadrilateral matching MTG aspect ratio (88/63 ≈ 1.397)
|
|
||||||
5. Order corners: top-left, top-right, bottom-right, bottom-left
|
|
||||||
|
|
||||||
## Debug Mode
|
- **TypeScript**: Strict mode enabled
|
||||||
|
- **Formatting**: Prettier defaults
|
||||||
|
- **Package Manager**: Bun (never use npm/yarn)
|
||||||
|
|
||||||
Set `RecognitionOptions.DebugOutputDirectory` to save pipeline stages:
|
### React/React Native
|
||||||
- `01_input.png` - Original image
|
|
||||||
- `02_detection.png` - Card detection visualization
|
|
||||||
- `03_perspective_corrected.png` - Warped card
|
|
||||||
- `05_clahe_*.png` - After CLAHE preprocessing
|
|
||||||
|
|
||||||
On Android: `/sdcard/Download/scry-debug` (pull with `adb pull`)
|
- Functional components with hooks
|
||||||
|
- Expo Router for navigation (file-based)
|
||||||
|
- Convex hooks for data (`useQuery`, `useMutation`)
|
||||||
|
|
||||||
## Dependencies
|
### Naming
|
||||||
|
|
||||||
### Core Library (Scry.Core)
|
- Hooks: `useCardHashes`, `useCameraPermission`
|
||||||
|
- Components: PascalCase (`AdaptiveCamera`, `ScanScreen`)
|
||||||
|
- Files: camelCase for modules, PascalCase for components
|
||||||
|
- Convex functions: camelCase (`cards.ts`, `getByScryfallId`)
|
||||||
|
|
||||||
- **SkiaSharp** - Image processing, DCT, edge detection
|
### Convex Backend
|
||||||
- **Microsoft.Data.Sqlite** - SQLite database
|
|
||||||
- **Microsoft.Extensions.Options** - Options pattern
|
|
||||||
|
|
||||||
### App (Scry.App)
|
- Queries are reactive and cached
|
||||||
|
- Mutations are transactional
|
||||||
|
- Use `v.` validators for all arguments
|
||||||
|
- Index frequently queried fields
|
||||||
|
|
||||||
- **CommunityToolkit.Maui** - MAUI extensions
|
## Environment Variables
|
||||||
- **CommunityToolkit.Maui.Camera** - Camera integration
|
|
||||||
- **CommunityToolkit.Mvvm** - MVVM source generators
|
|
||||||
|
|
||||||
### DbGenerator Tool
|
### Convex Backend (`convex/.env.local`)
|
||||||
|
|
||||||
- **Spectre.Console** / **Spectre.Console.Cli** - Rich terminal UI
|
```bash
|
||||||
|
AUTH_ZITADEL_ID=your-client-id
|
||||||
|
AUTH_ZITADEL_SECRET=your-client-secret
|
||||||
|
AUTH_ZITADEL_ISSUER=https://your-zitadel-instance
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client Side (`.env`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EXPO_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud
|
||||||
|
EXPO_PUBLIC_ZITADEL_ISSUER=https://your-zitadel-instance
|
||||||
|
```
|
||||||
|
|
||||||
## Common Tasks
|
## Common Tasks
|
||||||
|
|
||||||
### Adding a New Card to Priority Test Set
|
### Adding a New Convex Function
|
||||||
|
|
||||||
1. Add image to `TestImages/reference_alpha/` or appropriate folder
|
1. Add function to appropriate file in `convex/` (e.g., `cards.ts`)
|
||||||
2. Add entry to `GenerateCommand.PriorityCardsWithSets` dictionary
|
2. Run `bunx convex dev` to regenerate types
|
||||||
3. Run `just gen-db` to regenerate database
|
3. Import from `convex/_generated/api` in client code
|
||||||
|
|
||||||
### Debugging Recognition Failures
|
### Testing Recognition
|
||||||
|
|
||||||
1. Enable debug output in `MauiProgram.cs`:
|
1. Add test images to `TestImages/`
|
||||||
```csharp
|
2. Use the scan tab in the app
|
||||||
options.DebugOutputDirectory = "/sdcard/Download/scry-debug";
|
3. Check console logs for `[Scry]` prefixed messages
|
||||||
```
|
|
||||||
2. Run recognition
|
|
||||||
3. Pull debug images: `adb pull /sdcard/Download/scry-debug`
|
|
||||||
4. Compare `05_clahe_*.png` with reference images in database
|
|
||||||
|
|
||||||
### Modifying Hash Algorithm
|
### Debugging Camera Issues
|
||||||
|
|
||||||
1. Update `PerceptualHash.ComputeColorHash()`
|
- In Expo Go: Uses `expo-camera`, check for "Dev mode" indicator
|
||||||
2. Update `CardRecognitionService.ColorHashBits` constant
|
- In production: Uses Vision Camera, requires EAS build
|
||||||
3. Regenerate database: `just gen-db --force`
|
|
||||||
4. Run tests: `just test`
|
|
||||||
|
|
||||||
## Gotchas
|
## Dependencies
|
||||||
|
|
||||||
1. **Hash size is 24 bytes (192 bits)** - 3 RGB channels × 8 bytes each
|
### Core
|
||||||
2. **Confidence threshold is 85%** - Configurable in `CardRecognitionService.MinConfidence`
|
|
||||||
3. **Card detection is optional** - Controlled by `RecognitionOptions.EnableCardDetection`
|
|
||||||
4. **Rotation matching tries 4 orientations** - Controlled by `RecognitionOptions.EnableRotationMatching`
|
|
||||||
5. **Database is bundled in APK** - Copied on first run to app data directory
|
|
||||||
6. **Multi-face cards** - Only front face image is used for hashing
|
|
||||||
7. **Rate limiting** - DbGenerator uses 50ms delay between Scryfall image downloads
|
|
||||||
|
|
||||||
## CI/CD
|
- `expo` ~54.0.33
|
||||||
|
- `expo-router` ~6.0.23
|
||||||
|
- `convex` ^1.31.7
|
||||||
|
- `@convex-dev/auth` ^0.0.90
|
||||||
|
- `react` 19.1.0
|
||||||
|
- `react-native` 0.81.5
|
||||||
|
|
||||||
Forgejo Actions workflow (`.forgejo/workflows/release.yml`):
|
### Image Processing
|
||||||
- Builds for win-x64, linux-x64, osx-x64
|
|
||||||
- Creates "standard" and "embedded" (with APK) variants
|
- `@shopify/react-native-skia` ^2.4.18
|
||||||
- Publishes to Forgejo releases
|
- `react-native-fast-opencv` ^0.4.7
|
||||||
|
|
||||||
|
### Camera
|
||||||
|
|
||||||
|
- `expo-camera` ^17.0.10 (Expo Go)
|
||||||
|
- `react-native-vision-camera` ^4.7.3 (production)
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
- `expo-auth-session` ^7.0.10
|
||||||
|
- `expo-secure-store` ^15.0.8
|
||||||
|
|
||||||
## External Resources
|
## External Resources
|
||||||
|
|
||||||
- [Scryfall API](https://scryfall.com/docs/api) - Card data source
|
- [Scryfall API](https://scryfall.com/docs/api) - Card data source
|
||||||
- [CARD_RECOGNITION.md](docs/CARD_RECOGNITION.md) - Detailed architecture doc
|
- [Convex Docs](https://docs.convex.dev/) - Backend documentation
|
||||||
|
- [Expo Router](https://docs.expo.dev/router/introduction/) - Navigation
|
||||||
|
- [docs/CARD_RECOGNITION.md](docs/CARD_RECOGNITION.md) - Recognition architecture
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<Project>
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
|
||||||
<AnalysisLevel>latest</AnalysisLevel>
|
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
|
||||||
</PropertyGroup>
|
|
||||||
</Project>
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<Project>
|
|
||||||
<PropertyGroup>
|
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageVersion Include="CommunityToolkit.Maui" Version="14.0.0" />
|
|
||||||
<PackageVersion Include="CommunityToolkit.Maui.Camera" Version="6.0.0" />
|
|
||||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
|
||||||
<PackageVersion Include="Microsoft.Maui.Controls" Version="10.0.30" />
|
|
||||||
<PackageVersion
|
|
||||||
Include="Microsoft.Maui.Controls.Compatibility"
|
|
||||||
Version="10.0.30"
|
|
||||||
/>
|
|
||||||
<PackageVersion
|
|
||||||
Include="Microsoft.Extensions.Logging.Debug"
|
|
||||||
Version="10.0.0"
|
|
||||||
/>
|
|
||||||
<PackageVersion Include="SkiaSharp" Version="3.119.0" />
|
|
||||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.1" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
|
||||||
<PackageVersion Include="Spectre.Console" Version="0.50.0" />
|
|
||||||
<PackageVersion Include="Spectre.Console.Cli" Version="0.50.0" />
|
|
||||||
|
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
|
||||||
<PackageVersion Include="xunit" Version="2.9.2" />
|
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
|
||||||
<PackageVersion Include="coverlet.collector" Version="6.0.2" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<Solution>
|
|
||||||
<Folder Name="/src/">
|
|
||||||
<Project Path="src/Scry.App/Scry.App.csproj" />
|
|
||||||
<Project Path="src/Scry.Core/Scry.Core.csproj" />
|
|
||||||
</Folder>
|
|
||||||
<Folder Name="/test/">
|
|
||||||
<Project Path="test/Scry.Tests/Scry.Tests.csproj" />
|
|
||||||
</Folder>
|
|
||||||
</Solution>
|
|
||||||
39
app.json
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Scry",
|
||||||
|
"slug": "scry",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/images/icon.png",
|
||||||
|
"scheme": "scry",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/images/splash-icon.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
},
|
||||||
|
"edgeToEdgeEnabled": true,
|
||||||
|
"predictiveBackGestureEnabled": false
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"bundler": "metro",
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/images/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router"
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/(tabs)/_layout.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import React from "react";
|
||||||
|
import FontAwesome from "@expo/vector-icons/FontAwesome";
|
||||||
|
import { Tabs } from "expo-router";
|
||||||
|
import { useColorScheme } from "@/components/useColorScheme";
|
||||||
|
import Colors from "@/constants/Colors";
|
||||||
|
|
||||||
|
function TabBarIcon(props: {
|
||||||
|
name: React.ComponentProps<typeof FontAwesome>["name"];
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
|
return <FontAwesome size={24} style={{ marginBottom: -3 }} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint,
|
||||||
|
tabBarInactiveTintColor: "#888",
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: colorScheme === "dark" ? "#1a1a1a" : "#fff",
|
||||||
|
borderTopColor: colorScheme === "dark" ? "#333" : "#eee",
|
||||||
|
},
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: colorScheme === "dark" ? "#1a1a1a" : "#fff",
|
||||||
|
},
|
||||||
|
headerTintColor: colorScheme === "dark" ? "#fff" : "#000",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: "Collection",
|
||||||
|
tabBarIcon: ({ color }) => (
|
||||||
|
<TabBarIcon name="th-large" color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="scan"
|
||||||
|
options={{
|
||||||
|
title: "Scan",
|
||||||
|
headerShown: false,
|
||||||
|
tabBarIcon: ({ color }) => <TabBarIcon name="camera" color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="settings"
|
||||||
|
options={{
|
||||||
|
title: "Settings",
|
||||||
|
tabBarIcon: ({ color }) => <TabBarIcon name="cog" color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
406
app/(tabs)/index.tsx
Normal file
|
|
@ -0,0 +1,406 @@
|
||||||
|
/**
|
||||||
|
* Collection screen - displays user's scanned cards.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
FlatList,
|
||||||
|
Image,
|
||||||
|
Pressable,
|
||||||
|
TextInput,
|
||||||
|
RefreshControl,
|
||||||
|
Dimensions,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from "react-native";
|
||||||
|
import { FontAwesome } from "@expo/vector-icons";
|
||||||
|
import { useQuery } from "convex/react";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { api } from "../../convex/_generated/api";
|
||||||
|
import { useCurrentUser } from "@/lib/hooks";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
const CARD_WIDTH = (SCREEN_WIDTH - 48) / 3;
|
||||||
|
const CARD_HEIGHT = CARD_WIDTH * (88 / 63); // MTG aspect ratio
|
||||||
|
|
||||||
|
interface CollectionItem {
|
||||||
|
id: string;
|
||||||
|
cardId: string;
|
||||||
|
name: string;
|
||||||
|
setCode: string;
|
||||||
|
imageUri?: string;
|
||||||
|
quantity: number;
|
||||||
|
isFoil: boolean;
|
||||||
|
addedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollectionScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [sortBy, setSortBy] = useState<"name" | "set" | "recent">("recent");
|
||||||
|
|
||||||
|
// Get authenticated user
|
||||||
|
const { user, isAuthenticated } = useCurrentUser();
|
||||||
|
const userId = user?._id ?? null;
|
||||||
|
|
||||||
|
// Fetch collection from Convex
|
||||||
|
const rawCollection = useQuery(
|
||||||
|
api.collections.getByUser,
|
||||||
|
userId ? { userId: userId as any } : "skip"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Transform Convex data to UI format
|
||||||
|
const collection = useMemo<CollectionItem[]>(() => {
|
||||||
|
if (!rawCollection) return [];
|
||||||
|
|
||||||
|
return rawCollection.map((entry) => ({
|
||||||
|
id: entry._id,
|
||||||
|
cardId: entry.cardId,
|
||||||
|
name: entry.card?.name || "Unknown",
|
||||||
|
setCode: entry.card?.setCode || "???",
|
||||||
|
imageUri: entry.card?.imageUri,
|
||||||
|
quantity: entry.quantity,
|
||||||
|
isFoil: entry.isFoil,
|
||||||
|
addedAt: entry.addedAt,
|
||||||
|
}));
|
||||||
|
}, [rawCollection]);
|
||||||
|
|
||||||
|
// Filter collection by search query
|
||||||
|
const filteredCollection = useMemo(
|
||||||
|
() =>
|
||||||
|
collection.filter((item) =>
|
||||||
|
item.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
),
|
||||||
|
[collection, searchQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort collection
|
||||||
|
const sortedCollection = useMemo(
|
||||||
|
() =>
|
||||||
|
[...filteredCollection].sort((a, b) => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case "name":
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
case "set":
|
||||||
|
return a.setCode.localeCompare(b.setCode);
|
||||||
|
case "recent":
|
||||||
|
default:
|
||||||
|
return b.addedAt - a.addedAt;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[filteredCollection, sortBy]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onRefresh = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
// Convex automatically syncs - just show loading briefly
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
setRefreshing(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderCard = useCallback(
|
||||||
|
({ item }: { item: CollectionItem }) => (
|
||||||
|
<Pressable
|
||||||
|
style={styles.cardContainer}
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: "/modal",
|
||||||
|
params: {
|
||||||
|
collectionEntryId: item.id,
|
||||||
|
cardId: item.cardId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.imageUri ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: item.imageUri }}
|
||||||
|
style={styles.cardImage}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.cardImage, styles.cardPlaceholder]}>
|
||||||
|
<FontAwesome name="question" size={24} color="#666" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{item.quantity > 1 && (
|
||||||
|
<View style={styles.quantityBadge}>
|
||||||
|
<Text style={styles.quantityText}>×{item.quantity}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{item.isFoil && (
|
||||||
|
<View style={styles.foilBadge}>
|
||||||
|
<FontAwesome name="star" size={10} color="#FFD700" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderEmpty = useCallback(
|
||||||
|
() => (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<FontAwesome name="inbox" size={64} color="#444" />
|
||||||
|
<Text style={styles.emptyTitle}>No Cards Yet</Text>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
{userId
|
||||||
|
? "Start scanning cards to build your collection!"
|
||||||
|
: "Sign in to start building your collection!"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (rawCollection === undefined && userId) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#007AFF" />
|
||||||
|
<Text style={styles.loadingText}>Loading collection...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total cards count
|
||||||
|
const totalCount = collection.reduce((sum, item) => sum + item.quantity, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Search bar */}
|
||||||
|
<View style={styles.searchContainer}>
|
||||||
|
<FontAwesome
|
||||||
|
name="search"
|
||||||
|
size={16}
|
||||||
|
color="#888"
|
||||||
|
style={styles.searchIcon}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
style={styles.searchInput}
|
||||||
|
placeholder="Search your collection..."
|
||||||
|
placeholderTextColor="#666"
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
{searchQuery.length > 0 && (
|
||||||
|
<Pressable onPress={() => setSearchQuery("")}>
|
||||||
|
<FontAwesome name="times-circle" size={16} color="#666" />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Stats and sort bar */}
|
||||||
|
<View style={styles.statsBar}>
|
||||||
|
<Text style={styles.statsText}>
|
||||||
|
{sortedCollection.length} unique
|
||||||
|
{collection.length > 0 && ` (${totalCount} total)`}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.sortButtons}>
|
||||||
|
<Pressable
|
||||||
|
style={[
|
||||||
|
styles.sortButton,
|
||||||
|
sortBy === "recent" && styles.sortButtonActive,
|
||||||
|
]}
|
||||||
|
onPress={() => setSortBy("recent")}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.sortButtonText,
|
||||||
|
sortBy === "recent" && styles.sortButtonTextActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Recent
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[
|
||||||
|
styles.sortButton,
|
||||||
|
sortBy === "name" && styles.sortButtonActive,
|
||||||
|
]}
|
||||||
|
onPress={() => setSortBy("name")}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.sortButtonText,
|
||||||
|
sortBy === "name" && styles.sortButtonTextActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[
|
||||||
|
styles.sortButton,
|
||||||
|
sortBy === "set" && styles.sortButtonActive,
|
||||||
|
]}
|
||||||
|
onPress={() => setSortBy("set")}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.sortButtonText,
|
||||||
|
sortBy === "set" && styles.sortButtonTextActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Set
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Card grid */}
|
||||||
|
<FlatList
|
||||||
|
data={sortedCollection}
|
||||||
|
renderItem={renderCard}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
numColumns={3}
|
||||||
|
contentContainerStyle={styles.gridContent}
|
||||||
|
ListEmptyComponent={renderEmpty}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor="#007AFF"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
},
|
||||||
|
searchContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
searchIcon: {
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
flex: 1,
|
||||||
|
height: 44,
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
statsBar: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
statsText: {
|
||||||
|
color: "#888",
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
sortButtons: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
sortButton: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
sortButtonActive: {
|
||||||
|
backgroundColor: "#333",
|
||||||
|
},
|
||||||
|
sortButtonText: {
|
||||||
|
color: "#666",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
sortButtonTextActive: {
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
gridContent: {
|
||||||
|
padding: 16,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
cardContainer: {
|
||||||
|
width: CARD_WIDTH,
|
||||||
|
height: CARD_HEIGHT,
|
||||||
|
marginRight: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
cardImage: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
cardPlaceholder: {
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
quantityBadge: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.8)",
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
quantityText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
foilBadge: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 4,
|
||||||
|
left: 4,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.8)",
|
||||||
|
padding: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingTop: 100,
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
marginTop: 20,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#888",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 16,
|
||||||
|
color: "#888",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
572
app/(tabs)/scan.tsx
Normal file
|
|
@ -0,0 +1,572 @@
|
||||||
|
/**
|
||||||
|
* Camera scanning screen for card recognition.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useRef, useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Pressable,
|
||||||
|
ActivityIndicator,
|
||||||
|
Dimensions,
|
||||||
|
Image,
|
||||||
|
Alert,
|
||||||
|
} from "react-native";
|
||||||
|
import { FontAwesome } from "@expo/vector-icons";
|
||||||
|
import { useMutation, useQuery } from "convex/react";
|
||||||
|
import { api } from "../../convex/_generated/api";
|
||||||
|
import { useCameraPermission } from "@/lib/hooks/useCamera";
|
||||||
|
import { useCurrentUser } from "@/lib/hooks";
|
||||||
|
import { useHashCache } from "@/lib/context";
|
||||||
|
import { recognizeCard } from "@/lib/recognition";
|
||||||
|
import { loadImageAsBase64 } from "@/lib/recognition/imageLoader";
|
||||||
|
import { decodeImageBase64 } from "@/lib/recognition/skiaDecoder";
|
||||||
|
import { AdaptiveCamera, CameraHandle, isExpoGo } from "@/components/camera";
|
||||||
|
|
||||||
|
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
const CARD_ASPECT_RATIO = 63 / 88; // Width / Height
|
||||||
|
const SCAN_BOX_HEIGHT = SCREEN_HEIGHT * 0.5;
|
||||||
|
const SCAN_BOX_WIDTH = SCAN_BOX_HEIGHT * CARD_ASPECT_RATIO;
|
||||||
|
|
||||||
|
interface ScanResult {
|
||||||
|
cardId: string;
|
||||||
|
cardName: string;
|
||||||
|
setCode: string;
|
||||||
|
imageUri?: string;
|
||||||
|
confidence: number;
|
||||||
|
distance: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScanScreen() {
|
||||||
|
const { hasPermission, isLoading: permLoading, requestPermission } = useCameraPermission();
|
||||||
|
const cameraRef = useRef<CameraHandle>(null);
|
||||||
|
|
||||||
|
// Local scan state
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [flashEnabled, setFlashEnabled] = useState(false);
|
||||||
|
const [isAddingToCollection, setIsAddingToCollection] = useState(false);
|
||||||
|
const [lastScanResult, setLastScanResult] = useState<ScanResult | null>(null);
|
||||||
|
const [scanError, setScanError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Hash cache from context
|
||||||
|
const { cardHashes, hashesLoaded } = useHashCache();
|
||||||
|
|
||||||
|
// Get authenticated user
|
||||||
|
const { user, isAuthenticated } = useCurrentUser();
|
||||||
|
const userId = user?._id ?? null;
|
||||||
|
|
||||||
|
// Convex mutations
|
||||||
|
const addToCollection = useMutation(api.collections.add);
|
||||||
|
const getCardByScryfallId = useQuery(
|
||||||
|
api.cards.getByScryfallId,
|
||||||
|
lastScanResult ? { scryfallId: lastScanResult.cardId } : "skip"
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearScanState = useCallback(() => {
|
||||||
|
setLastScanResult(null);
|
||||||
|
setScanError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-clear scan result after 5 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastScanResult) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
clearScanState();
|
||||||
|
}, 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [lastScanResult, clearScanState]);
|
||||||
|
|
||||||
|
const handleCapture = useCallback(async () => {
|
||||||
|
if (!cameraRef.current || isProcessing || !hashesLoaded) return;
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Take photo using adaptive camera
|
||||||
|
const photo = await cameraRef.current.takePhoto();
|
||||||
|
|
||||||
|
console.log("[Scry] Photo captured:", photo.uri);
|
||||||
|
|
||||||
|
// Load and resize image for processing
|
||||||
|
const { base64 } = await loadImageAsBase64(
|
||||||
|
photo.uri,
|
||||||
|
480, // Target width for processing
|
||||||
|
640 // Target height
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!base64) {
|
||||||
|
throw new Error("Failed to load image data");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode to RGBA pixels using Skia
|
||||||
|
const decoded = decodeImageBase64(base64);
|
||||||
|
|
||||||
|
if (!decoded) {
|
||||||
|
throw new Error("Failed to decode image pixels");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Scry] Image decoded:", decoded.width, "x", decoded.height);
|
||||||
|
console.log("[Scry] Matching against", cardHashes.length, "cards");
|
||||||
|
|
||||||
|
// Run recognition
|
||||||
|
const result = recognizeCard(
|
||||||
|
decoded.pixels,
|
||||||
|
decoded.width,
|
||||||
|
decoded.height,
|
||||||
|
cardHashes,
|
||||||
|
{
|
||||||
|
enableCardDetection: true,
|
||||||
|
enableRotationMatching: true,
|
||||||
|
minConfidence: 0.85,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("[Scry] Recognition result:", result);
|
||||||
|
|
||||||
|
if (result.success && result.match) {
|
||||||
|
// Find the card info from our hashes
|
||||||
|
const matchedCard = cardHashes.find((c) => c.id === result.match!.cardId);
|
||||||
|
|
||||||
|
setLastScanResult({
|
||||||
|
cardId: result.match.cardId,
|
||||||
|
cardName: matchedCard?.name || "Unknown Card",
|
||||||
|
setCode: matchedCard?.setCode || "???",
|
||||||
|
imageUri: matchedCard?.imageUri,
|
||||||
|
confidence: result.match.confidence,
|
||||||
|
distance: result.match.distance,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
setScanError(null);
|
||||||
|
} else {
|
||||||
|
setScanError(result.error || "No match found. Try adjusting lighting or angle.");
|
||||||
|
setLastScanResult(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Scry] Capture error:", error);
|
||||||
|
setScanError(error instanceof Error ? error.message : "Failed to capture image");
|
||||||
|
setLastScanResult(null);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}, [isProcessing, hashesLoaded, cardHashes]);
|
||||||
|
|
||||||
|
const handleAddToCollection = useCallback(async () => {
|
||||||
|
if (!lastScanResult || !userId || !getCardByScryfallId) return;
|
||||||
|
|
||||||
|
setIsAddingToCollection(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addToCollection({
|
||||||
|
userId: userId as any,
|
||||||
|
cardId: getCardByScryfallId._id,
|
||||||
|
quantity: 1,
|
||||||
|
isFoil: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
"Added to Collection",
|
||||||
|
`${lastScanResult.cardName} has been added to your collection.`,
|
||||||
|
[{ text: "OK" }]
|
||||||
|
);
|
||||||
|
|
||||||
|
clearScanState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Scry] Failed to add to collection:", error);
|
||||||
|
Alert.alert("Error", "Failed to add card to collection. Please try again.", [
|
||||||
|
{ text: "OK" },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setIsAddingToCollection(false);
|
||||||
|
}
|
||||||
|
}, [lastScanResult, userId, getCardByScryfallId, addToCollection, clearScanState]);
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (permLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<ActivityIndicator size="large" color="#007AFF" />
|
||||||
|
<Text style={styles.loadingText}>Checking camera permission...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission denied
|
||||||
|
if (!hasPermission) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<FontAwesome name="camera" size={64} color="#666" />
|
||||||
|
<Text style={styles.permissionTitle}>Camera Access Required</Text>
|
||||||
|
<Text style={styles.permissionText}>
|
||||||
|
Scry needs camera access to scan your Magic cards.
|
||||||
|
</Text>
|
||||||
|
<Pressable style={styles.permissionButton} onPress={requestPermission}>
|
||||||
|
<Text style={styles.permissionButtonText}>Enable Camera</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Adaptive camera - uses expo-camera in Expo Go, Vision Camera in production */}
|
||||||
|
<AdaptiveCamera ref={cameraRef} flashEnabled={flashEnabled} />
|
||||||
|
|
||||||
|
{/* Overlay with scan box */}
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
{/* Top dark area */}
|
||||||
|
<View style={styles.overlayTop} />
|
||||||
|
|
||||||
|
{/* Middle row with scan box */}
|
||||||
|
<View style={styles.overlayMiddle}>
|
||||||
|
<View style={styles.overlaySide} />
|
||||||
|
<View style={styles.scanBox}>
|
||||||
|
{/* Corner markers */}
|
||||||
|
<View style={[styles.corner, styles.cornerTopLeft]} />
|
||||||
|
<View style={[styles.corner, styles.cornerTopRight]} />
|
||||||
|
<View style={[styles.corner, styles.cornerBottomLeft]} />
|
||||||
|
<View style={[styles.corner, styles.cornerBottomRight]} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.overlaySide} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bottom dark area */}
|
||||||
|
<View style={styles.overlayBottom} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<View style={styles.instructionContainer}>
|
||||||
|
<Text style={styles.instructionText}>
|
||||||
|
{hashesLoaded
|
||||||
|
? `Position card in frame • ${cardHashes.length} cards loaded`
|
||||||
|
: "Loading card database..."}
|
||||||
|
</Text>
|
||||||
|
{isExpoGo && (
|
||||||
|
<Text style={styles.devModeText}>Dev mode (expo-camera)</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Scan result overlay */}
|
||||||
|
{lastScanResult && (
|
||||||
|
<View style={styles.resultContainer}>
|
||||||
|
<View style={styles.resultCard}>
|
||||||
|
{lastScanResult.imageUri && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: lastScanResult.imageUri }}
|
||||||
|
style={styles.resultImage}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View style={styles.resultInfo}>
|
||||||
|
<Text style={styles.resultName}>{lastScanResult.cardName}</Text>
|
||||||
|
<Text style={styles.resultSet}>{lastScanResult.setCode.toUpperCase()}</Text>
|
||||||
|
<Text style={styles.resultConfidence}>
|
||||||
|
{Math.round(lastScanResult.confidence * 100)}% match
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.addButton, !userId && styles.addButtonDisabled]}
|
||||||
|
onPress={handleAddToCollection}
|
||||||
|
disabled={!userId || isAddingToCollection}
|
||||||
|
>
|
||||||
|
{isAddingToCollection ? (
|
||||||
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FontAwesome name="plus" size={20} color="#fff" />
|
||||||
|
<Text style={styles.addButtonText}>Add</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{scanError && (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorMessage}>{scanError}</Text>
|
||||||
|
<Pressable onPress={clearScanState}>
|
||||||
|
<Text style={styles.errorDismiss}>Dismiss</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<View style={styles.controls}>
|
||||||
|
{/* Flash toggle */}
|
||||||
|
<Pressable style={styles.controlButton} onPress={() => setFlashEnabled(!flashEnabled)}>
|
||||||
|
<FontAwesome
|
||||||
|
name="bolt"
|
||||||
|
size={24}
|
||||||
|
color={flashEnabled ? "#FFD700" : "#fff"}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Capture button */}
|
||||||
|
<Pressable
|
||||||
|
style={[styles.captureButton, isProcessing && styles.captureButtonDisabled]}
|
||||||
|
onPress={handleCapture}
|
||||||
|
disabled={isProcessing || !hashesLoaded}
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<ActivityIndicator size="small" color="#007AFF" />
|
||||||
|
) : (
|
||||||
|
<View style={styles.captureButtonInner} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Placeholder for symmetry */}
|
||||||
|
<View style={styles.controlButton} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#000",
|
||||||
|
},
|
||||||
|
centered: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 16,
|
||||||
|
color: "#888",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
permissionTitle: {
|
||||||
|
marginTop: 20,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
permissionText: {
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#888",
|
||||||
|
textAlign: "center",
|
||||||
|
maxWidth: 280,
|
||||||
|
},
|
||||||
|
permissionButton: {
|
||||||
|
marginTop: 24,
|
||||||
|
backgroundColor: "#007AFF",
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
permissionButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
marginTop: 16,
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#FF6B6B",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
},
|
||||||
|
overlayTop: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
|
},
|
||||||
|
overlayMiddle: {
|
||||||
|
flexDirection: "row",
|
||||||
|
height: SCAN_BOX_HEIGHT,
|
||||||
|
},
|
||||||
|
overlaySide: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
|
},
|
||||||
|
scanBox: {
|
||||||
|
width: SCAN_BOX_WIDTH,
|
||||||
|
height: SCAN_BOX_HEIGHT,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: "rgba(255,255,255,0.3)",
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
overlayBottom: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
|
},
|
||||||
|
corner: {
|
||||||
|
position: "absolute",
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderColor: "#007AFF",
|
||||||
|
},
|
||||||
|
cornerTopLeft: {
|
||||||
|
top: -2,
|
||||||
|
left: -2,
|
||||||
|
borderTopWidth: 4,
|
||||||
|
borderLeftWidth: 4,
|
||||||
|
borderTopLeftRadius: 12,
|
||||||
|
},
|
||||||
|
cornerTopRight: {
|
||||||
|
top: -2,
|
||||||
|
right: -2,
|
||||||
|
borderTopWidth: 4,
|
||||||
|
borderRightWidth: 4,
|
||||||
|
borderTopRightRadius: 12,
|
||||||
|
},
|
||||||
|
cornerBottomLeft: {
|
||||||
|
bottom: -2,
|
||||||
|
left: -2,
|
||||||
|
borderBottomWidth: 4,
|
||||||
|
borderLeftWidth: 4,
|
||||||
|
borderBottomLeftRadius: 12,
|
||||||
|
},
|
||||||
|
cornerBottomRight: {
|
||||||
|
bottom: -2,
|
||||||
|
right: -2,
|
||||||
|
borderBottomWidth: 4,
|
||||||
|
borderRightWidth: 4,
|
||||||
|
borderBottomRightRadius: 12,
|
||||||
|
},
|
||||||
|
instructionContainer: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 60,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
instructionText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.5)",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
devModeText: {
|
||||||
|
marginTop: 8,
|
||||||
|
color: "#FFD700",
|
||||||
|
fontSize: 12,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.5)",
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
resultContainer: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 140,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
},
|
||||||
|
resultCard: {
|
||||||
|
flexDirection: "row",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.9)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
resultImage: {
|
||||||
|
width: 50,
|
||||||
|
height: 70,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
resultInfo: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
resultName: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
resultSet: {
|
||||||
|
color: "#888",
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
resultConfidence: {
|
||||||
|
color: "#4CD964",
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
addButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#4CD964",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 8,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
addButtonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
addButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 140,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
backgroundColor: "rgba(255,107,107,0.95)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
errorMessage: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
errorDismiss: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 40,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-around",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
},
|
||||||
|
controlButton: {
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
captureButton: {
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
borderRadius: 36,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 4,
|
||||||
|
borderColor: "rgba(255,255,255,0.3)",
|
||||||
|
},
|
||||||
|
captureButtonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
captureButtonInner: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
});
|
||||||
313
app/(tabs)/settings.tsx
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
/**
|
||||||
|
* Settings screen for Scry app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
ScrollView,
|
||||||
|
Pressable,
|
||||||
|
Switch,
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
} from "react-native";
|
||||||
|
import { FontAwesome } from "@expo/vector-icons";
|
||||||
|
import { useQuery } from "convex/react";
|
||||||
|
import { api } from "../../convex/_generated/api";
|
||||||
|
import { useSync } from "@/lib/hooks/useSync";
|
||||||
|
import { useHashCache } from "@/lib/context";
|
||||||
|
|
||||||
|
interface SettingRowProps {
|
||||||
|
icon: React.ComponentProps<typeof FontAwesome>["name"];
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
rightElement?: React.ReactNode;
|
||||||
|
destructive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingRow({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
onPress,
|
||||||
|
rightElement,
|
||||||
|
destructive,
|
||||||
|
}: SettingRowProps) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.settingRow,
|
||||||
|
pressed && onPress && styles.settingRowPressed,
|
||||||
|
]}
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={!onPress}
|
||||||
|
>
|
||||||
|
<View style={[styles.settingIcon, destructive && styles.settingIconDestructive]}>
|
||||||
|
<FontAwesome name={icon} size={18} color={destructive ? "#FF6B6B" : "#007AFF"} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.settingContent}>
|
||||||
|
<Text style={[styles.settingTitle, destructive && styles.settingTitleDestructive]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle && <Text style={styles.settingSubtitle}>{subtitle}</Text>}
|
||||||
|
</View>
|
||||||
|
{rightElement || (onPress && <FontAwesome name="chevron-right" size={14} color="#666" />)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>{title}</Text>
|
||||||
|
<View style={styles.sectionContent}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsScreen() {
|
||||||
|
const [cardDetectionEnabled, setCardDetectionEnabled] = useState(true);
|
||||||
|
const [rotationMatchingEnabled, setRotationMatchingEnabled] = useState(true);
|
||||||
|
|
||||||
|
// Get hash count from context
|
||||||
|
const { cardHashes, hashesLoaded } = useHashCache();
|
||||||
|
|
||||||
|
// Sync hook for cache management
|
||||||
|
const { isInitialized, isSyncing, lastSync, localCardCount, error: syncError, sync, clearCache } =
|
||||||
|
useSync();
|
||||||
|
|
||||||
|
// Get total card count from Convex
|
||||||
|
const cardCount = useQuery(api.cards.count);
|
||||||
|
|
||||||
|
const formatLastSync = (timestamp: number) => {
|
||||||
|
if (!timestamp) return "Never";
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleDateString() + " " + date.toLocaleTimeString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearCache = () => {
|
||||||
|
Alert.alert(
|
||||||
|
"Clear Local Cache",
|
||||||
|
"This will remove all downloaded card data. You'll need to sync again to scan cards.",
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Clear",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
await clearCache();
|
||||||
|
Alert.alert("Cache Cleared", "Local card data has been removed.");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManualSync = async () => {
|
||||||
|
await sync();
|
||||||
|
Alert.alert("Sync Complete", `${localCardCount} cards now available for scanning.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
|
{/* Database section */}
|
||||||
|
<SettingSection title="Database">
|
||||||
|
<SettingRow
|
||||||
|
icon="database"
|
||||||
|
title="Card Database"
|
||||||
|
subtitle={
|
||||||
|
isInitialized
|
||||||
|
? `${localCardCount.toLocaleString()} cards cached locally`
|
||||||
|
: isSyncing
|
||||||
|
? "Loading..."
|
||||||
|
: "Not initialized"
|
||||||
|
}
|
||||||
|
rightElement={isSyncing ? <ActivityIndicator size="small" color="#007AFF" /> : undefined}
|
||||||
|
/>
|
||||||
|
<SettingRow
|
||||||
|
icon="refresh"
|
||||||
|
title="Sync Now"
|
||||||
|
subtitle={`Last sync: ${formatLastSync(lastSync)}`}
|
||||||
|
onPress={handleManualSync}
|
||||||
|
rightElement={isSyncing ? <ActivityIndicator size="small" color="#007AFF" /> : undefined}
|
||||||
|
/>
|
||||||
|
{syncError && (
|
||||||
|
<SettingRow icon="exclamation-triangle" title="Sync Error" subtitle={syncError} destructive />
|
||||||
|
)}
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
{/* Recognition section */}
|
||||||
|
<SettingSection title="Recognition">
|
||||||
|
<SettingRow
|
||||||
|
icon="crop"
|
||||||
|
title="Card Detection"
|
||||||
|
subtitle="Automatically detect card boundaries"
|
||||||
|
rightElement={
|
||||||
|
<Switch
|
||||||
|
value={cardDetectionEnabled}
|
||||||
|
onValueChange={setCardDetectionEnabled}
|
||||||
|
trackColor={{ true: "#007AFF" }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingRow
|
||||||
|
icon="rotate-right"
|
||||||
|
title="Rotation Matching"
|
||||||
|
subtitle="Try multiple rotations for matching"
|
||||||
|
rightElement={
|
||||||
|
<Switch
|
||||||
|
value={rotationMatchingEnabled}
|
||||||
|
onValueChange={setRotationMatchingEnabled}
|
||||||
|
trackColor={{ true: "#007AFF" }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
{/* Collection section */}
|
||||||
|
<SettingSection title="Collection">
|
||||||
|
<SettingRow
|
||||||
|
icon="th-large"
|
||||||
|
title="Cards in Database"
|
||||||
|
subtitle={`${(cardCount ?? 0).toLocaleString()} cards available`}
|
||||||
|
/>
|
||||||
|
<SettingRow
|
||||||
|
icon="cloud-upload"
|
||||||
|
title="Export Collection"
|
||||||
|
subtitle="Export as CSV or JSON"
|
||||||
|
onPress={() => {
|
||||||
|
// TODO: Implement export
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
{/* About section */}
|
||||||
|
<SettingSection title="About">
|
||||||
|
<SettingRow icon="info-circle" title="Version" subtitle="1.0.0 (Expo + Convex)" />
|
||||||
|
<SettingRow
|
||||||
|
icon="github"
|
||||||
|
title="Source Code"
|
||||||
|
subtitle="View on Forgejo"
|
||||||
|
onPress={() => {
|
||||||
|
// TODO: Open source URL
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
{/* Danger zone */}
|
||||||
|
<SettingSection title="Danger Zone">
|
||||||
|
<SettingRow
|
||||||
|
icon="trash"
|
||||||
|
title="Clear Collection"
|
||||||
|
subtitle="Remove all cards from collection"
|
||||||
|
onPress={() => {
|
||||||
|
Alert.alert(
|
||||||
|
"Clear Collection",
|
||||||
|
"This will remove all cards from your collection. This cannot be undone.",
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{ text: "Clear", style: "destructive", onPress: () => {} },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
<SettingRow
|
||||||
|
icon="eraser"
|
||||||
|
title="Clear Local Cache"
|
||||||
|
subtitle="Remove downloaded card data"
|
||||||
|
onPress={handleClearCache}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Text style={styles.footerText}>Scry • Card scanner for Magic: The Gathering</Text>
|
||||||
|
<Text style={styles.footerSubtext}>Card data © Wizards of the Coast</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: "#888",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "600",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
marginLeft: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
sectionContent: {
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
borderRadius: 12,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
settingRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 14,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: "#3a3a3a",
|
||||||
|
},
|
||||||
|
settingRowPressed: {
|
||||||
|
backgroundColor: "#333",
|
||||||
|
},
|
||||||
|
settingIcon: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: "rgba(0,122,255,0.1)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
settingIconDestructive: {
|
||||||
|
backgroundColor: "rgba(255,107,107,0.1)",
|
||||||
|
},
|
||||||
|
settingContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
settingTitle: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
settingTitleDestructive: {
|
||||||
|
color: "#FF6B6B",
|
||||||
|
},
|
||||||
|
settingSubtitle: {
|
||||||
|
color: "#888",
|
||||||
|
fontSize: 13,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
marginTop: 40,
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
footerText: {
|
||||||
|
color: "#666",
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
footerSubtext: {
|
||||||
|
color: "#444",
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
38
app/+html.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||||
|
|
||||||
|
// This file is web-only and used to configure the root HTML for every
|
||||||
|
// web page during static rendering.
|
||||||
|
// The contents of this function only run in Node.js environments and
|
||||||
|
// do not have access to the DOM or browser APIs.
|
||||||
|
export default function Root({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||||
|
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||||
|
*/}
|
||||||
|
<ScrollViewStyleReset />
|
||||||
|
|
||||||
|
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||||
|
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||||
|
</head>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responsiveBackground = `
|
||||||
|
body {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
}`;
|
||||||
40
app/+not-found.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Link, Stack } from 'expo-router';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
import { Text, View } from '@/components/Themed';
|
||||||
|
|
||||||
|
export default function NotFoundScreen() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>This screen doesn't exist.</Text>
|
||||||
|
|
||||||
|
<Link href="/" style={styles.link}>
|
||||||
|
<Text style={styles.linkText}>Go to home screen!</Text>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
marginTop: 15,
|
||||||
|
paddingVertical: 15,
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#2e78b7',
|
||||||
|
},
|
||||||
|
});
|
||||||
72
app/_layout.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import FontAwesome from "@expo/vector-icons/FontAwesome";
|
||||||
|
import { useFonts } from "expo-font";
|
||||||
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { ConvexProvider, ConvexReactClient } from "convex/react";
|
||||||
|
import { HashCacheProvider } from "@/lib/context";
|
||||||
|
import { useColorScheme } from "@/components/useColorScheme";
|
||||||
|
|
||||||
|
// Initialize Convex client
|
||||||
|
const convex = new ConvexReactClient(
|
||||||
|
process.env.EXPO_PUBLIC_CONVEX_URL as string
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep splash screen visible while loading fonts
|
||||||
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const [loaded, error] = useFonts({
|
||||||
|
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
||||||
|
...FontAwesome.font,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) throw error;
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loaded) {
|
||||||
|
SplashScreen.hideAsync();
|
||||||
|
}
|
||||||
|
}, [loaded]);
|
||||||
|
|
||||||
|
if (!loaded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConvexProvider client={convex}>
|
||||||
|
<HashCacheProvider>
|
||||||
|
<RootLayoutNav />
|
||||||
|
</HashCacheProvider>
|
||||||
|
</ConvexProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootLayoutNav() {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: colorScheme === "dark" ? "#1a1a1a" : "#fff",
|
||||||
|
},
|
||||||
|
headerTintColor: colorScheme === "dark" ? "#fff" : "#000",
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: colorScheme === "dark" ? "#1a1a1a" : "#fff",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="modal"
|
||||||
|
options={{
|
||||||
|
presentation: "modal",
|
||||||
|
title: "Card Details",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
376
app/modal.tsx
Normal file
|
|
@ -0,0 +1,376 @@
|
||||||
|
/**
|
||||||
|
* Card detail modal - shows full card info with quantity controls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Image,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Dimensions,
|
||||||
|
Platform,
|
||||||
|
} from "react-native";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { FontAwesome } from "@expo/vector-icons";
|
||||||
|
import { useQuery, useMutation } from "convex/react";
|
||||||
|
import { api } from "../convex/_generated/api";
|
||||||
|
import { useCurrentUser } from "@/lib/hooks";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
const CARD_WIDTH = SCREEN_WIDTH - 64;
|
||||||
|
const CARD_HEIGHT = CARD_WIDTH * (88 / 63);
|
||||||
|
|
||||||
|
export default function CardDetailModal() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useLocalSearchParams<{
|
||||||
|
collectionEntryId: string;
|
||||||
|
cardId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Get authenticated user
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const userId = user?._id ?? null;
|
||||||
|
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
|
||||||
|
// Fetch card details
|
||||||
|
const card = useQuery(
|
||||||
|
api.cards.getByScryfallId,
|
||||||
|
params.cardId ? { scryfallId: params.cardId } : "skip"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch collection entry
|
||||||
|
const collection = useQuery(
|
||||||
|
api.collections.getByUser,
|
||||||
|
userId ? { userId: userId as any } : "skip"
|
||||||
|
);
|
||||||
|
|
||||||
|
const collectionEntry = collection?.find(
|
||||||
|
(entry) => entry._id === params.collectionEntryId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const updateQuantity = useMutation(api.collections.updateQuantity);
|
||||||
|
const removeCard = useMutation(api.collections.remove);
|
||||||
|
|
||||||
|
const handleQuantityChange = async (delta: number) => {
|
||||||
|
if (!collectionEntry) return;
|
||||||
|
|
||||||
|
const newQuantity = collectionEntry.quantity + delta;
|
||||||
|
|
||||||
|
if (newQuantity <= 0) {
|
||||||
|
handleRemove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
await updateQuantity({
|
||||||
|
entryId: collectionEntry._id,
|
||||||
|
quantity: newQuantity,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update quantity:", error);
|
||||||
|
Alert.alert("Error", "Failed to update quantity");
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
Alert.alert(
|
||||||
|
"Remove Card",
|
||||||
|
`Remove ${card?.name || "this card"} from your collection?`,
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Remove",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
if (!collectionEntry) return;
|
||||||
|
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
await removeCard({ entryId: collectionEntry._id });
|
||||||
|
router.back();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to remove card:", error);
|
||||||
|
Alert.alert("Error", "Failed to remove card");
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (!card || !collectionEntry) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#007AFF" />
|
||||||
|
<StatusBar style={Platform.OS === "ios" ? "light" : "auto"} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
|
{/* Card image */}
|
||||||
|
<View style={styles.imageContainer}>
|
||||||
|
{card.imageUri ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: card.imageUri }}
|
||||||
|
style={styles.cardImage}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.cardImage, styles.cardPlaceholder]}>
|
||||||
|
<FontAwesome name="image" size={48} color="#666" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Card info */}
|
||||||
|
<View style={styles.infoContainer}>
|
||||||
|
<Text style={styles.cardName}>{card.name}</Text>
|
||||||
|
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
<View style={styles.metaItem}>
|
||||||
|
<Text style={styles.metaLabel}>Set</Text>
|
||||||
|
<Text style={styles.metaValue}>{card.setCode.toUpperCase()}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metaItem}>
|
||||||
|
<Text style={styles.metaLabel}>Number</Text>
|
||||||
|
<Text style={styles.metaValue}>{card.collectorNumber}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metaItem}>
|
||||||
|
<Text style={styles.metaLabel}>Rarity</Text>
|
||||||
|
<Text style={styles.metaValue}>{card.rarity}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{card.artist && (
|
||||||
|
<View style={styles.artistRow}>
|
||||||
|
<FontAwesome name="paint-brush" size={12} color="#888" />
|
||||||
|
<Text style={styles.artistText}>{card.artist}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Quantity controls */}
|
||||||
|
<View style={styles.quantityContainer}>
|
||||||
|
<Text style={styles.quantityLabel}>Quantity</Text>
|
||||||
|
|
||||||
|
<View style={styles.quantityControls}>
|
||||||
|
<Pressable
|
||||||
|
style={styles.quantityButton}
|
||||||
|
onPress={() => handleQuantityChange(-1)}
|
||||||
|
disabled={isUpdating}
|
||||||
|
>
|
||||||
|
<FontAwesome name="minus" size={16} color="#fff" />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={styles.quantityDisplay}>
|
||||||
|
{isUpdating ? (
|
||||||
|
<ActivityIndicator size="small" color="#007AFF" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.quantityValue}>{collectionEntry.quantity}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={styles.quantityButton}
|
||||||
|
onPress={() => handleQuantityChange(1)}
|
||||||
|
disabled={isUpdating}
|
||||||
|
>
|
||||||
|
<FontAwesome name="plus" size={16} color="#fff" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{collectionEntry.isFoil && (
|
||||||
|
<View style={styles.foilBadge}>
|
||||||
|
<FontAwesome name="star" size={12} color="#FFD700" />
|
||||||
|
<Text style={styles.foilText}>Foil</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Added date */}
|
||||||
|
<Text style={styles.addedDate}>
|
||||||
|
Added {new Date(collectionEntry.addedAt).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.actionButton, styles.removeButton]}
|
||||||
|
onPress={handleRemove}
|
||||||
|
disabled={isUpdating}
|
||||||
|
>
|
||||||
|
<FontAwesome name="trash" size={16} color="#FF6B6B" />
|
||||||
|
<Text style={styles.removeButtonText}>Remove from Collection</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<StatusBar style={Platform.OS === "ios" ? "light" : "auto"} />
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
},
|
||||||
|
imageContainer: {
|
||||||
|
alignItems: "center",
|
||||||
|
paddingTop: 20,
|
||||||
|
paddingBottom: 24,
|
||||||
|
},
|
||||||
|
cardImage: {
|
||||||
|
width: CARD_WIDTH,
|
||||||
|
height: CARD_HEIGHT,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
},
|
||||||
|
cardPlaceholder: {
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
infoContainer: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
cardName: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "bold",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
metaRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 24,
|
||||||
|
},
|
||||||
|
metaItem: {
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
metaLabel: {
|
||||||
|
color: "#666",
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
metaValue: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
artistRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
artistText: {
|
||||||
|
color: "#888",
|
||||||
|
fontSize: 14,
|
||||||
|
fontStyle: "italic",
|
||||||
|
},
|
||||||
|
quantityContainer: {
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
marginHorizontal: 24,
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
quantityLabel: {
|
||||||
|
color: "#888",
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
quantityControls: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 20,
|
||||||
|
},
|
||||||
|
quantityButton: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: "#007AFF",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
quantityDisplay: {
|
||||||
|
width: 60,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
quantityValue: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
foilBadge: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
marginTop: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: "rgba(255,215,0,0.1)",
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
foilText: {
|
||||||
|
color: "#FFD700",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
addedDate: {
|
||||||
|
color: "#666",
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
removeButton: {
|
||||||
|
backgroundColor: "rgba(255,107,107,0.1)",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(255,107,107,0.3)",
|
||||||
|
},
|
||||||
|
removeButtonText: {
|
||||||
|
color: "#FF6B6B",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
31
app/two.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
import EditScreenInfo from '@/components/EditScreenInfo';
|
||||||
|
import { Text, View } from '@/components/Themed';
|
||||||
|
|
||||||
|
export default function TabTwoScreen() {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>Tab Two</Text>
|
||||||
|
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
||||||
|
<EditScreenInfo path="app/(tabs)/two.tsx" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
separator: {
|
||||||
|
marginVertical: 30,
|
||||||
|
height: 1,
|
||||||
|
width: '80%',
|
||||||
|
},
|
||||||
|
});
|
||||||
BIN
assets/fonts/SpaceMono-Regular.ttf
Normal file
BIN
assets/images/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
77
components/EditScreenInfo.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
import { ExternalLink } from './ExternalLink';
|
||||||
|
import { MonoText } from './StyledText';
|
||||||
|
import { Text, View } from './Themed';
|
||||||
|
|
||||||
|
import Colors from '@/constants/Colors';
|
||||||
|
|
||||||
|
export default function EditScreenInfo({ path }: { path: string }) {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View style={styles.getStartedContainer}>
|
||||||
|
<Text
|
||||||
|
style={styles.getStartedText}
|
||||||
|
lightColor="rgba(0,0,0,0.8)"
|
||||||
|
darkColor="rgba(255,255,255,0.8)">
|
||||||
|
Open up the code for this screen:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[styles.codeHighlightContainer, styles.homeScreenFilename]}
|
||||||
|
darkColor="rgba(255,255,255,0.05)"
|
||||||
|
lightColor="rgba(0,0,0,0.05)">
|
||||||
|
<MonoText>{path}</MonoText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={styles.getStartedText}
|
||||||
|
lightColor="rgba(0,0,0,0.8)"
|
||||||
|
darkColor="rgba(255,255,255,0.8)">
|
||||||
|
Change any of the text, save the file, and your app will automatically update.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.helpContainer}>
|
||||||
|
<ExternalLink
|
||||||
|
style={styles.helpLink}
|
||||||
|
href="https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet">
|
||||||
|
<Text style={styles.helpLinkText} lightColor={Colors.light.tint}>
|
||||||
|
Tap here if your app doesn't automatically update after making changes
|
||||||
|
</Text>
|
||||||
|
</ExternalLink>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
getStartedContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginHorizontal: 50,
|
||||||
|
},
|
||||||
|
homeScreenFilename: {
|
||||||
|
marginVertical: 7,
|
||||||
|
},
|
||||||
|
codeHighlightContainer: {
|
||||||
|
borderRadius: 3,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
getStartedText: {
|
||||||
|
fontSize: 17,
|
||||||
|
lineHeight: 24,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
helpContainer: {
|
||||||
|
marginTop: 15,
|
||||||
|
marginHorizontal: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
helpLink: {
|
||||||
|
paddingVertical: 15,
|
||||||
|
},
|
||||||
|
helpLinkText: {
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
25
components/ExternalLink.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { Link } from 'expo-router';
|
||||||
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
|
import React from 'react';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
export function ExternalLink(
|
||||||
|
props: Omit<React.ComponentProps<typeof Link>, 'href'> & { href: string }
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
target="_blank"
|
||||||
|
{...props}
|
||||||
|
// @ts-expect-error: External URLs are not typed.
|
||||||
|
href={props.href}
|
||||||
|
onPress={(e) => {
|
||||||
|
if (Platform.OS !== 'web') {
|
||||||
|
// Prevent the default behavior of linking to the default browser on native.
|
||||||
|
e.preventDefault();
|
||||||
|
// Open the link in an in-app browser.
|
||||||
|
WebBrowser.openBrowserAsync(props.href as string);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
components/StyledText.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { Text, TextProps } from './Themed';
|
||||||
|
|
||||||
|
export function MonoText(props: TextProps) {
|
||||||
|
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
|
||||||
|
}
|
||||||
45
components/Themed.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* Learn more about Light and Dark modes:
|
||||||
|
* https://docs.expo.io/guides/color-schemes/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Text as DefaultText, View as DefaultView } from 'react-native';
|
||||||
|
|
||||||
|
import Colors from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from './useColorScheme';
|
||||||
|
|
||||||
|
type ThemeProps = {
|
||||||
|
lightColor?: string;
|
||||||
|
darkColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TextProps = ThemeProps & DefaultText['props'];
|
||||||
|
export type ViewProps = ThemeProps & DefaultView['props'];
|
||||||
|
|
||||||
|
export function useThemeColor(
|
||||||
|
props: { light?: string; dark?: string },
|
||||||
|
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||||
|
) {
|
||||||
|
const theme = useColorScheme() ?? 'light';
|
||||||
|
const colorFromProps = props[theme];
|
||||||
|
|
||||||
|
if (colorFromProps) {
|
||||||
|
return colorFromProps;
|
||||||
|
} else {
|
||||||
|
return Colors[theme][colorName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Text(props: TextProps) {
|
||||||
|
const { style, lightColor, darkColor, ...otherProps } = props;
|
||||||
|
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
||||||
|
|
||||||
|
return <DefaultText style={[{ color }, style]} {...otherProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function View(props: ViewProps) {
|
||||||
|
const { style, lightColor, darkColor, ...otherProps } = props;
|
||||||
|
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||||
|
|
||||||
|
return <DefaultView style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||||
|
}
|
||||||
10
components/__tests__/StyledText-test.js
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import renderer from 'react-test-renderer';
|
||||||
|
|
||||||
|
import { MonoText } from '../StyledText';
|
||||||
|
|
||||||
|
it(`renders correctly`, () => {
|
||||||
|
const tree = renderer.create(<MonoText>Snapshot test!</MonoText>).toJSON();
|
||||||
|
|
||||||
|
expect(tree).toMatchSnapshot();
|
||||||
|
});
|
||||||
48
components/camera/ExpoCamera.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
/**
|
||||||
|
* Camera component using expo-camera (works in Expo Go).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { forwardRef, useImperativeHandle, useRef } from "react";
|
||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
import { CameraView } from "expo-camera";
|
||||||
|
|
||||||
|
export interface CameraHandle {
|
||||||
|
takePhoto: () => Promise<{ uri: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExpoCameraProps {
|
||||||
|
flashEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExpoCamera = forwardRef<CameraHandle, ExpoCameraProps>(
|
||||||
|
({ flashEnabled = false }, ref) => {
|
||||||
|
const cameraRef = useRef<CameraView>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
takePhoto: async () => {
|
||||||
|
if (!cameraRef.current) {
|
||||||
|
throw new Error("Camera not ready");
|
||||||
|
}
|
||||||
|
const photo = await cameraRef.current.takePictureAsync({
|
||||||
|
quality: 0.8,
|
||||||
|
base64: false,
|
||||||
|
});
|
||||||
|
if (!photo) {
|
||||||
|
throw new Error("Failed to capture photo");
|
||||||
|
}
|
||||||
|
return { uri: photo.uri };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CameraView
|
||||||
|
ref={cameraRef}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
facing="back"
|
||||||
|
flash={flashEnabled ? "on" : "off"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ExpoCamera.displayName = "ExpoCamera";
|
||||||
53
components/camera/VisionCamera.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
/**
|
||||||
|
* Camera component using react-native-vision-camera (production builds).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { forwardRef, useImperativeHandle, useRef } from "react";
|
||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
import { Camera, useCameraDevice } from "react-native-vision-camera";
|
||||||
|
|
||||||
|
export interface CameraHandle {
|
||||||
|
takePhoto: () => Promise<{ uri: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VisionCameraProps {
|
||||||
|
flashEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VisionCamera = forwardRef<CameraHandle, VisionCameraProps>(
|
||||||
|
({ flashEnabled = false }, ref) => {
|
||||||
|
const device = useCameraDevice("back");
|
||||||
|
const cameraRef = useRef<Camera>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
takePhoto: async () => {
|
||||||
|
if (!cameraRef.current) {
|
||||||
|
throw new Error("Camera not ready");
|
||||||
|
}
|
||||||
|
const photo = await cameraRef.current.takePhoto({
|
||||||
|
flash: flashEnabled ? "on" : "off",
|
||||||
|
enableShutterSound: false,
|
||||||
|
});
|
||||||
|
return { uri: photo.path };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Camera
|
||||||
|
ref={cameraRef}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
device={device}
|
||||||
|
isActive={true}
|
||||||
|
photo={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
VisionCamera.displayName = "VisionCamera";
|
||||||
|
|
||||||
|
export { useCameraDevice };
|
||||||
64
components/camera/index.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* Adaptive camera component that uses expo-camera in Expo Go
|
||||||
|
* and react-native-vision-camera in production builds.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { forwardRef, lazy, Suspense } from "react";
|
||||||
|
import { View, ActivityIndicator, StyleSheet, Text } from "react-native";
|
||||||
|
import Constants from "expo-constants";
|
||||||
|
|
||||||
|
export interface CameraHandle {
|
||||||
|
takePhoto: () => Promise<{ uri: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdaptiveCameraProps {
|
||||||
|
flashEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect if running in Expo Go
|
||||||
|
const isExpoGo = Constants.appOwnership === "expo";
|
||||||
|
|
||||||
|
// Lazy load the appropriate camera component
|
||||||
|
const ExpoCamera = lazy(() =>
|
||||||
|
import("./ExpoCamera").then((m) => ({ default: m.ExpoCamera }))
|
||||||
|
);
|
||||||
|
const VisionCamera = lazy(() =>
|
||||||
|
import("./VisionCamera").then((m) => ({ default: m.VisionCamera }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const CameraLoading = () => (
|
||||||
|
<View style={styles.loading}>
|
||||||
|
<ActivityIndicator size="large" color="#007AFF" />
|
||||||
|
<Text style={styles.loadingText}>Initializing camera...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AdaptiveCamera = forwardRef<CameraHandle, AdaptiveCameraProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const CameraComponent = isExpoGo ? ExpoCamera : VisionCamera;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<CameraLoading />}>
|
||||||
|
<CameraComponent ref={ref} {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
AdaptiveCamera.displayName = "AdaptiveCamera";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
loading: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: "#000",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 16,
|
||||||
|
color: "#888",
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { isExpoGo };
|
||||||
4
components/useClientOnlyValue.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// This function is web-only as native doesn't currently support server (or build-time) rendering.
|
||||||
|
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
12
components/useClientOnlyValue.web.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// `useEffect` is not invoked during server rendering, meaning
|
||||||
|
// we can use this to determine if we're on the server or not.
|
||||||
|
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
|
||||||
|
const [value, setValue] = React.useState<S | C>(server);
|
||||||
|
React.useEffect(() => {
|
||||||
|
setValue(client);
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
1
components/useColorScheme.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { useColorScheme } from 'react-native';
|
||||||
8
components/useColorScheme.web.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
// NOTE: The default React Native styling doesn't support server rendering.
|
||||||
|
// Server rendered styles should not change between the first render of the HTML
|
||||||
|
// and the first render on the client. Typically, web developers will use CSS media queries
|
||||||
|
// to render different styles on the client and server, these aren't directly supported in React Native
|
||||||
|
// but can be achieved using a styling library like Nativewind.
|
||||||
|
export function useColorScheme() {
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
19
constants/Colors.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
const tintColorLight = '#2f95dc';
|
||||||
|
const tintColorDark = '#fff';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
light: {
|
||||||
|
text: '#000',
|
||||||
|
background: '#fff',
|
||||||
|
tint: tintColorLight,
|
||||||
|
tabIconDefault: '#ccc',
|
||||||
|
tabIconSelected: tintColorLight,
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
text: '#fff',
|
||||||
|
background: '#000',
|
||||||
|
tint: tintColorDark,
|
||||||
|
tabIconDefault: '#ccc',
|
||||||
|
tabIconSelected: tintColorDark,
|
||||||
|
},
|
||||||
|
};
|
||||||
59
convex/_generated/api.d.ts
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated `api` utility.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type * as auth from "../auth.js";
|
||||||
|
import type * as cards from "../cards.js";
|
||||||
|
import type * as collections from "../collections.js";
|
||||||
|
import type * as http from "../http.js";
|
||||||
|
import type * as scanHistory from "../scanHistory.js";
|
||||||
|
import type * as users from "../users.js";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ApiFromModules,
|
||||||
|
FilterApi,
|
||||||
|
FunctionReference,
|
||||||
|
} from "convex/server";
|
||||||
|
|
||||||
|
declare const fullApi: ApiFromModules<{
|
||||||
|
auth: typeof auth;
|
||||||
|
cards: typeof cards;
|
||||||
|
collections: typeof collections;
|
||||||
|
http: typeof http;
|
||||||
|
scanHistory: typeof scanHistory;
|
||||||
|
users: typeof users;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility for referencing Convex functions in your app's public API.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```js
|
||||||
|
* const myFunctionReference = api.myModule.myFunction;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export declare const api: FilterApi<
|
||||||
|
typeof fullApi,
|
||||||
|
FunctionReference<any, "public">
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility for referencing Convex functions in your app's internal API.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```js
|
||||||
|
* const myFunctionReference = internal.myModule.myFunction;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export declare const internal: FilterApi<
|
||||||
|
typeof fullApi,
|
||||||
|
FunctionReference<any, "internal">
|
||||||
|
>;
|
||||||
|
|
||||||
|
export declare const components: {};
|
||||||
23
convex/_generated/api.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated `api` utility.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { anyApi, componentsGeneric } from "convex/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility for referencing Convex functions in your app's API.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```js
|
||||||
|
* const myFunctionReference = api.myModule.myFunction;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const api = anyApi;
|
||||||
|
export const internal = anyApi;
|
||||||
|
export const components = componentsGeneric();
|
||||||
60
convex/_generated/dataModel.d.ts
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated data model types.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DataModelFromSchemaDefinition,
|
||||||
|
DocumentByName,
|
||||||
|
TableNamesInDataModel,
|
||||||
|
SystemTableNames,
|
||||||
|
} from "convex/server";
|
||||||
|
import type { GenericId } from "convex/values";
|
||||||
|
import schema from "../schema.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The names of all of your Convex tables.
|
||||||
|
*/
|
||||||
|
export type TableNames = TableNamesInDataModel<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of a document stored in Convex.
|
||||||
|
*
|
||||||
|
* @typeParam TableName - A string literal type of the table name (like "users").
|
||||||
|
*/
|
||||||
|
export type Doc<TableName extends TableNames> = DocumentByName<
|
||||||
|
DataModel,
|
||||||
|
TableName
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An identifier for a document in Convex.
|
||||||
|
*
|
||||||
|
* Convex documents are uniquely identified by their `Id`, which is accessible
|
||||||
|
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
|
||||||
|
*
|
||||||
|
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
|
||||||
|
*
|
||||||
|
* IDs are just strings at runtime, but this type can be used to distinguish them from other
|
||||||
|
* strings when type checking.
|
||||||
|
*
|
||||||
|
* @typeParam TableName - A string literal type of the table name (like "users").
|
||||||
|
*/
|
||||||
|
export type Id<TableName extends TableNames | SystemTableNames> =
|
||||||
|
GenericId<TableName>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A type describing your Convex data model.
|
||||||
|
*
|
||||||
|
* This type includes information about what tables you have, the type of
|
||||||
|
* documents stored in those tables, and the indexes defined on them.
|
||||||
|
*
|
||||||
|
* This type is used to parameterize methods like `queryGeneric` and
|
||||||
|
* `mutationGeneric` to make them type-safe.
|
||||||
|
*/
|
||||||
|
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
|
||||||
143
convex/_generated/server.d.ts
vendored
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ActionBuilder,
|
||||||
|
HttpActionBuilder,
|
||||||
|
MutationBuilder,
|
||||||
|
QueryBuilder,
|
||||||
|
GenericActionCtx,
|
||||||
|
GenericMutationCtx,
|
||||||
|
GenericQueryCtx,
|
||||||
|
GenericDatabaseReader,
|
||||||
|
GenericDatabaseWriter,
|
||||||
|
} from "convex/server";
|
||||||
|
import type { DataModel } from "./dataModel.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a query in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* This function will be allowed to read your Convex database and will be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const query: QueryBuilder<DataModel, "public">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a query that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a mutation in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* This function will be allowed to modify your Convex database and will be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const mutation: MutationBuilder<DataModel, "public">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an action in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* An action is a function which can execute any JavaScript code, including non-deterministic
|
||||||
|
* code and code with side-effects, like calling third-party services.
|
||||||
|
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
|
||||||
|
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
|
||||||
|
*
|
||||||
|
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
||||||
|
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const action: ActionBuilder<DataModel, "public">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an action that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
||||||
|
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export declare const internalAction: ActionBuilder<DataModel, "internal">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an HTTP action.
|
||||||
|
*
|
||||||
|
* The wrapped function will be used to respond to HTTP requests received
|
||||||
|
* by a Convex deployment if the requests matches the path and method where
|
||||||
|
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
|
||||||
|
*
|
||||||
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument
|
||||||
|
* and a Fetch API `Request` object as its second.
|
||||||
|
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
||||||
|
*/
|
||||||
|
export declare const httpAction: HttpActionBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A set of services for use within Convex query functions.
|
||||||
|
*
|
||||||
|
* The query context is passed as the first argument to any Convex query
|
||||||
|
* function run on the server.
|
||||||
|
*
|
||||||
|
* This differs from the {@link MutationCtx} because all of the services are
|
||||||
|
* read-only.
|
||||||
|
*/
|
||||||
|
export type QueryCtx = GenericQueryCtx<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A set of services for use within Convex mutation functions.
|
||||||
|
*
|
||||||
|
* The mutation context is passed as the first argument to any Convex mutation
|
||||||
|
* function run on the server.
|
||||||
|
*/
|
||||||
|
export type MutationCtx = GenericMutationCtx<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A set of services for use within Convex action functions.
|
||||||
|
*
|
||||||
|
* The action context is passed as the first argument to any Convex action
|
||||||
|
* function run on the server.
|
||||||
|
*/
|
||||||
|
export type ActionCtx = GenericActionCtx<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interface to read from the database within Convex query functions.
|
||||||
|
*
|
||||||
|
* The two entry points are {@link DatabaseReader.get}, which fetches a single
|
||||||
|
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
|
||||||
|
* building a query.
|
||||||
|
*/
|
||||||
|
export type DatabaseReader = GenericDatabaseReader<DataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interface to read from and write to the database within Convex mutation
|
||||||
|
* functions.
|
||||||
|
*
|
||||||
|
* Convex guarantees that all writes within a single mutation are
|
||||||
|
* executed atomically, so you never have to worry about partial writes leaving
|
||||||
|
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
|
||||||
|
* for the guarantees Convex provides your functions.
|
||||||
|
*/
|
||||||
|
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
|
||||||
93
convex/_generated/server.js
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||||
|
*
|
||||||
|
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||||
|
*
|
||||||
|
* To regenerate, run `npx convex dev`.
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
actionGeneric,
|
||||||
|
httpActionGeneric,
|
||||||
|
queryGeneric,
|
||||||
|
mutationGeneric,
|
||||||
|
internalActionGeneric,
|
||||||
|
internalMutationGeneric,
|
||||||
|
internalQueryGeneric,
|
||||||
|
} from "convex/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a query in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* This function will be allowed to read your Convex database and will be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const query = queryGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a query that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const internalQuery = internalQueryGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a mutation in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* This function will be allowed to modify your Convex database and will be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const mutation = mutationGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
|
||||||
|
*
|
||||||
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const internalMutation = internalMutationGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an action in this Convex app's public API.
|
||||||
|
*
|
||||||
|
* An action is a function which can execute any JavaScript code, including non-deterministic
|
||||||
|
* code and code with side-effects, like calling third-party services.
|
||||||
|
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
|
||||||
|
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
|
||||||
|
*
|
||||||
|
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
||||||
|
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const action = actionGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an action that is only accessible from other Convex functions (but not from the client).
|
||||||
|
*
|
||||||
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
||||||
|
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
||||||
|
*/
|
||||||
|
export const internalAction = internalActionGeneric;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define an HTTP action.
|
||||||
|
*
|
||||||
|
* The wrapped function will be used to respond to HTTP requests received
|
||||||
|
* by a Convex deployment if the requests matches the path and method where
|
||||||
|
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
|
||||||
|
*
|
||||||
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument
|
||||||
|
* and a Fetch API `Request` object as its second.
|
||||||
|
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
||||||
|
*/
|
||||||
|
export const httpAction = httpActionGeneric;
|
||||||
48
convex/auth.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
/**
|
||||||
|
* Convex Auth configuration with Zitadel OIDC.
|
||||||
|
*
|
||||||
|
* GDPR Compliance: No user profile data (name, email, image) is stored.
|
||||||
|
* User details must be fetched from Zitadel userinfo endpoint when needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Zitadel from "@auth/core/providers/zitadel";
|
||||||
|
import { convexAuth } from "@convex-dev/auth/server";
|
||||||
|
|
||||||
|
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
||||||
|
providers: [
|
||||||
|
Zitadel({
|
||||||
|
issuer: process.env.AUTH_ZITADEL_ISSUER,
|
||||||
|
clientId: process.env.AUTH_ZITADEL_ID,
|
||||||
|
clientSecret: process.env.AUTH_ZITADEL_SECRET,
|
||||||
|
// Strip all profile data - return only the subject ID
|
||||||
|
profile(zitadelProfile) {
|
||||||
|
return {
|
||||||
|
id: zitadelProfile.sub,
|
||||||
|
// Intentionally omit: name, email, image
|
||||||
|
// These must be fetched from Zitadel userinfo endpoint
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
// Validate redirect URIs for React Native
|
||||||
|
async redirect({ redirectTo }) {
|
||||||
|
const allowedPrefixes = [
|
||||||
|
"scry://", // App custom scheme
|
||||||
|
"app://", // Default Expo scheme
|
||||||
|
"exp://", // Expo Go
|
||||||
|
"http://localhost",
|
||||||
|
"https://localhost",
|
||||||
|
];
|
||||||
|
|
||||||
|
const isAllowed = allowedPrefixes.some((prefix) =>
|
||||||
|
redirectTo.startsWith(prefix)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAllowed) {
|
||||||
|
throw new Error(`Invalid redirectTo URI: ${redirectTo}`);
|
||||||
|
}
|
||||||
|
return redirectTo;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
146
convex/cards.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { query, mutation } from "./_generated/server";
|
||||||
|
|
||||||
|
// Get all cards (for local caching)
|
||||||
|
export const list = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
return await ctx.db.query("cards").collect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get cards updated after a timestamp (for incremental sync)
|
||||||
|
export const listUpdatedAfter = query({
|
||||||
|
args: { since: v.number() },
|
||||||
|
handler: async (ctx, { since }) => {
|
||||||
|
return await ctx.db
|
||||||
|
.query("cards")
|
||||||
|
.withIndex("by_updated", (q) => q.gt("updatedAt", since))
|
||||||
|
.collect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get a single card by Scryfall ID
|
||||||
|
export const getByScryfallId = query({
|
||||||
|
args: { scryfallId: v.string() },
|
||||||
|
handler: async (ctx, { scryfallId }) => {
|
||||||
|
return await ctx.db
|
||||||
|
.query("cards")
|
||||||
|
.withIndex("by_scryfall", (q) => q.eq("scryfallId", scryfallId))
|
||||||
|
.first();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get cards by oracle ID (all printings of a card)
|
||||||
|
export const getByOracleId = query({
|
||||||
|
args: { oracleId: v.string() },
|
||||||
|
handler: async (ctx, { oracleId }) => {
|
||||||
|
return await ctx.db
|
||||||
|
.query("cards")
|
||||||
|
.withIndex("by_oracle", (q) => q.eq("oracleId", oracleId))
|
||||||
|
.collect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search cards by name
|
||||||
|
export const searchByName = query({
|
||||||
|
args: { name: v.string() },
|
||||||
|
handler: async (ctx, { name }) => {
|
||||||
|
const cards = await ctx.db.query("cards").collect();
|
||||||
|
const lowerName = name.toLowerCase();
|
||||||
|
return cards.filter((card) =>
|
||||||
|
card.name.toLowerCase().includes(lowerName)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get total card count
|
||||||
|
export const count = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const cards = await ctx.db.query("cards").collect();
|
||||||
|
return cards.length;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert a card (used by migration script)
|
||||||
|
export const insert = mutation({
|
||||||
|
args: {
|
||||||
|
scryfallId: v.string(),
|
||||||
|
oracleId: v.string(),
|
||||||
|
name: v.string(),
|
||||||
|
setCode: v.string(),
|
||||||
|
collectorNumber: v.string(),
|
||||||
|
rarity: v.string(),
|
||||||
|
artist: v.optional(v.string()),
|
||||||
|
imageUri: v.optional(v.string()),
|
||||||
|
hash: v.bytes(),
|
||||||
|
hashVersion: v.number(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Check if card already exists
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("cards")
|
||||||
|
.withIndex("by_scryfall", (q) => q.eq("scryfallId", args.scryfallId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update existing card
|
||||||
|
await ctx.db.patch(existing._id, {
|
||||||
|
...args,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return existing._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert new card
|
||||||
|
return await ctx.db.insert("cards", {
|
||||||
|
...args,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch insert cards
|
||||||
|
export const insertBatch = mutation({
|
||||||
|
args: {
|
||||||
|
cards: v.array(
|
||||||
|
v.object({
|
||||||
|
scryfallId: v.string(),
|
||||||
|
oracleId: v.string(),
|
||||||
|
name: v.string(),
|
||||||
|
setCode: v.string(),
|
||||||
|
collectorNumber: v.string(),
|
||||||
|
rarity: v.string(),
|
||||||
|
artist: v.optional(v.string()),
|
||||||
|
imageUri: v.optional(v.string()),
|
||||||
|
hash: v.bytes(),
|
||||||
|
hashVersion: v.number(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { cards }) => {
|
||||||
|
const results = [];
|
||||||
|
for (const card of cards) {
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("cards")
|
||||||
|
.withIndex("by_scryfall", (q) => q.eq("scryfallId", card.scryfallId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, {
|
||||||
|
...card,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
results.push(existing._id);
|
||||||
|
} else {
|
||||||
|
const id = await ctx.db.insert("cards", {
|
||||||
|
...card,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
results.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
});
|
||||||
166
convex/collections.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { query, mutation } from "./_generated/server";
|
||||||
|
|
||||||
|
// Get user's collection (alias for backwards compat)
|
||||||
|
export const getByUser = query({
|
||||||
|
args: { userId: v.id("users") },
|
||||||
|
handler: async (ctx, { userId }) => {
|
||||||
|
const entries = await ctx.db
|
||||||
|
.query("collections")
|
||||||
|
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Enrich with card data
|
||||||
|
const enriched = await Promise.all(
|
||||||
|
entries.map(async (entry) => {
|
||||||
|
const card = await ctx.db.get(entry.cardId);
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
card,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return enriched.filter((e) => e.card !== null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user's collection
|
||||||
|
export const getMyCollection = query({
|
||||||
|
args: { userId: v.id("users") },
|
||||||
|
handler: async (ctx, { userId }) => {
|
||||||
|
const entries = await ctx.db
|
||||||
|
.query("collections")
|
||||||
|
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Enrich with card data
|
||||||
|
const enriched = await Promise.all(
|
||||||
|
entries.map(async (entry) => {
|
||||||
|
const card = await ctx.db.get(entry.cardId);
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
card,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return enriched.filter((e) => e.card !== null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get collection stats
|
||||||
|
export const getStats = query({
|
||||||
|
args: { userId: v.id("users") },
|
||||||
|
handler: async (ctx, { userId }) => {
|
||||||
|
const entries = await ctx.db
|
||||||
|
.query("collections")
|
||||||
|
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const totalCards = entries.reduce((sum, e) => sum + e.quantity, 0);
|
||||||
|
const uniqueCards = entries.length;
|
||||||
|
const foilCount = entries.filter((e) => e.isFoil).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCards,
|
||||||
|
uniqueCards,
|
||||||
|
foilCount,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add card to collection
|
||||||
|
export const addCard = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.id("users"),
|
||||||
|
cardId: v.id("cards"),
|
||||||
|
quantity: v.number(),
|
||||||
|
isFoil: v.boolean(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { userId, cardId, quantity, isFoil }) => {
|
||||||
|
// Check if entry already exists
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("collections")
|
||||||
|
.withIndex("by_user_card", (q) =>
|
||||||
|
q.eq("userId", userId).eq("cardId", cardId).eq("isFoil", isFoil)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update quantity
|
||||||
|
await ctx.db.patch(existing._id, {
|
||||||
|
quantity: existing.quantity + quantity,
|
||||||
|
});
|
||||||
|
return existing._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new entry
|
||||||
|
return await ctx.db.insert("collections", {
|
||||||
|
userId,
|
||||||
|
cardId,
|
||||||
|
quantity,
|
||||||
|
isFoil,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alias for addCard
|
||||||
|
export const add = addCard;
|
||||||
|
|
||||||
|
// Update card quantity
|
||||||
|
export const updateQuantity = mutation({
|
||||||
|
args: {
|
||||||
|
entryId: v.id("collections"),
|
||||||
|
quantity: v.number(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { entryId, quantity }) => {
|
||||||
|
if (quantity <= 0) {
|
||||||
|
await ctx.db.delete(entryId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await ctx.db.patch(entryId, { quantity });
|
||||||
|
return entryId;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove card from collection
|
||||||
|
export const removeCard = mutation({
|
||||||
|
args: { entryId: v.id("collections") },
|
||||||
|
handler: async (ctx, { entryId }) => {
|
||||||
|
await ctx.db.delete(entryId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alias for removeCard
|
||||||
|
export const remove = removeCard;
|
||||||
|
|
||||||
|
// Decrease quantity by 1
|
||||||
|
export const decrementQuantity = mutation({
|
||||||
|
args: { entryId: v.id("collections") },
|
||||||
|
handler: async (ctx, { entryId }) => {
|
||||||
|
const entry = await ctx.db.get(entryId);
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
if (entry.quantity <= 1) {
|
||||||
|
await ctx.db.delete(entryId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(entryId, { quantity: entry.quantity - 1 });
|
||||||
|
return entryId;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Increase quantity by 1
|
||||||
|
export const incrementQuantity = mutation({
|
||||||
|
args: { entryId: v.id("collections") },
|
||||||
|
handler: async (ctx, { entryId }) => {
|
||||||
|
const entry = await ctx.db.get(entryId);
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
await ctx.db.patch(entryId, { quantity: entry.quantity + 1 });
|
||||||
|
return entryId;
|
||||||
|
},
|
||||||
|
});
|
||||||
13
convex/http.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* HTTP routes for Convex Auth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { httpRouter } from "convex/server";
|
||||||
|
import { auth } from "./auth";
|
||||||
|
|
||||||
|
const http = httpRouter();
|
||||||
|
|
||||||
|
// Add Convex Auth routes
|
||||||
|
auth.addHttpRoutes(http);
|
||||||
|
|
||||||
|
export default http;
|
||||||
58
convex/scanHistory.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { query, mutation } from "./_generated/server";
|
||||||
|
|
||||||
|
// Get recent scan history
|
||||||
|
export const getRecent = query({
|
||||||
|
args: { userId: v.id("users"), limit: v.optional(v.number()) },
|
||||||
|
handler: async (ctx, { userId, limit = 50 }) => {
|
||||||
|
const history = await ctx.db
|
||||||
|
.query("scanHistory")
|
||||||
|
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||||
|
.order("desc")
|
||||||
|
.take(limit);
|
||||||
|
|
||||||
|
// Enrich with card data
|
||||||
|
const enriched = await Promise.all(
|
||||||
|
history.map(async (entry) => {
|
||||||
|
const card = entry.cardId ? await ctx.db.get(entry.cardId) : null;
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
card,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return enriched;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Record a scan
|
||||||
|
export const record = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.id("users"),
|
||||||
|
cardId: v.optional(v.id("cards")),
|
||||||
|
confidence: v.number(),
|
||||||
|
addedToCollection: v.boolean(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db.insert("scanHistory", {
|
||||||
|
...args,
|
||||||
|
scannedAt: Date.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear scan history
|
||||||
|
export const clear = mutation({
|
||||||
|
args: { userId: v.id("users") },
|
||||||
|
handler: async (ctx, { userId }) => {
|
||||||
|
const entries = await ctx.db
|
||||||
|
.query("scanHistory")
|
||||||
|
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
await ctx.db.delete(entry._id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
93
convex/schema.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { defineSchema, defineTable } from "convex/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { authTables } from "@convex-dev/auth/server";
|
||||||
|
|
||||||
|
// Override the default users table to store NO personal data (GDPR compliance)
|
||||||
|
// User profile info (name, email, etc.) must be fetched from Zitadel OIDC userinfo endpoint
|
||||||
|
const minimalAuthTables = {
|
||||||
|
...authTables,
|
||||||
|
// Override users table - only store what's required for auth to function
|
||||||
|
users: defineTable({
|
||||||
|
// No name, email, image, or any PII stored
|
||||||
|
// The auth system needs this table to exist but we strip all profile data
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineSchema({
|
||||||
|
...minimalAuthTables,
|
||||||
|
|
||||||
|
// Card printings with perceptual hashes
|
||||||
|
cards: defineTable({
|
||||||
|
scryfallId: v.string(),
|
||||||
|
oracleId: v.string(),
|
||||||
|
name: v.string(),
|
||||||
|
setCode: v.string(),
|
||||||
|
collectorNumber: v.string(),
|
||||||
|
rarity: v.string(),
|
||||||
|
artist: v.optional(v.string()),
|
||||||
|
imageUri: v.optional(v.string()),
|
||||||
|
hash: v.bytes(), // 24-byte perceptual hash
|
||||||
|
hashVersion: v.number(), // Algorithm version for migrations
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_scryfall", ["scryfallId"])
|
||||||
|
.index("by_oracle", ["oracleId"])
|
||||||
|
.index("by_name", ["name"])
|
||||||
|
.index("by_updated", ["updatedAt"]),
|
||||||
|
|
||||||
|
// Oracle cards (abstract game cards)
|
||||||
|
oracles: defineTable({
|
||||||
|
oracleId: v.string(),
|
||||||
|
name: v.string(),
|
||||||
|
manaCost: v.optional(v.string()),
|
||||||
|
cmc: v.optional(v.number()),
|
||||||
|
typeLine: v.optional(v.string()),
|
||||||
|
oracleText: v.optional(v.string()),
|
||||||
|
colors: v.optional(v.array(v.string())),
|
||||||
|
colorIdentity: v.optional(v.array(v.string())),
|
||||||
|
keywords: v.optional(v.array(v.string())),
|
||||||
|
power: v.optional(v.string()),
|
||||||
|
toughness: v.optional(v.string()),
|
||||||
|
})
|
||||||
|
.index("by_oracle", ["oracleId"])
|
||||||
|
.index("by_name", ["name"]),
|
||||||
|
|
||||||
|
// MTG sets
|
||||||
|
sets: defineTable({
|
||||||
|
setId: v.string(),
|
||||||
|
code: v.string(),
|
||||||
|
name: v.string(),
|
||||||
|
setType: v.optional(v.string()),
|
||||||
|
releasedAt: v.optional(v.string()),
|
||||||
|
cardCount: v.optional(v.number()),
|
||||||
|
iconSvgUri: v.optional(v.string()),
|
||||||
|
})
|
||||||
|
.index("by_set", ["setId"])
|
||||||
|
.index("by_code", ["code"]),
|
||||||
|
|
||||||
|
// User collections
|
||||||
|
collections: defineTable({
|
||||||
|
userId: v.id("users"),
|
||||||
|
cardId: v.id("cards"),
|
||||||
|
quantity: v.number(),
|
||||||
|
isFoil: v.boolean(),
|
||||||
|
addedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_user", ["userId"])
|
||||||
|
.index("by_user_card", ["userId", "cardId", "isFoil"]),
|
||||||
|
|
||||||
|
// Scan history
|
||||||
|
scanHistory: defineTable({
|
||||||
|
userId: v.id("users"),
|
||||||
|
cardId: v.optional(v.id("cards")),
|
||||||
|
confidence: v.number(),
|
||||||
|
scannedAt: v.number(),
|
||||||
|
addedToCollection: v.boolean(),
|
||||||
|
}).index("by_user", ["userId"]),
|
||||||
|
|
||||||
|
// Sync metadata
|
||||||
|
metadata: defineTable({
|
||||||
|
key: v.string(),
|
||||||
|
value: v.string(),
|
||||||
|
}).index("by_key", ["key"]),
|
||||||
|
});
|
||||||
20
convex/users.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
/**
|
||||||
|
* User queries and mutations using Convex Auth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query } from "./_generated/server";
|
||||||
|
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently authenticated user.
|
||||||
|
*/
|
||||||
|
export const me = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const userId = await getAuthUserId(ctx);
|
||||||
|
if (!userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await ctx.db.get(userId);
|
||||||
|
},
|
||||||
|
});
|
||||||
64
lib/context/HashCacheContext.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* React Context for card hash cache.
|
||||||
|
* Provides offline-first hash data for card recognition.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useCallback, type ReactNode } from "react";
|
||||||
|
import type { CardHashEntry } from "../recognition";
|
||||||
|
|
||||||
|
interface HashCacheState {
|
||||||
|
cardHashes: CardHashEntry[];
|
||||||
|
hashesLoaded: boolean;
|
||||||
|
lastHashSync: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HashCacheContextValue extends HashCacheState {
|
||||||
|
setCardHashes: (hashes: CardHashEntry[]) => void;
|
||||||
|
clearHashes: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HashCacheContext = createContext<HashCacheContextValue | null>(null);
|
||||||
|
|
||||||
|
export function HashCacheProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [state, setState] = useState<HashCacheState>({
|
||||||
|
cardHashes: [],
|
||||||
|
hashesLoaded: false,
|
||||||
|
lastHashSync: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setCardHashes = useCallback((hashes: CardHashEntry[]) => {
|
||||||
|
setState({
|
||||||
|
cardHashes: hashes,
|
||||||
|
hashesLoaded: true,
|
||||||
|
lastHashSync: Date.now(),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearHashes = useCallback(() => {
|
||||||
|
setState({
|
||||||
|
cardHashes: [],
|
||||||
|
hashesLoaded: false,
|
||||||
|
lastHashSync: 0,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HashCacheContext.Provider
|
||||||
|
value={{
|
||||||
|
...state,
|
||||||
|
setCardHashes,
|
||||||
|
clearHashes,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</HashCacheContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHashCache() {
|
||||||
|
const context = useContext(HashCacheContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useHashCache must be used within HashCacheProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
5
lib/context/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/**
|
||||||
|
* Context exports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { HashCacheProvider, useHashCache } from "./HashCacheContext";
|
||||||
20
lib/db/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
/**
|
||||||
|
* Local database module exports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
initDatabase,
|
||||||
|
getLastSyncTimestamp,
|
||||||
|
setLastSyncTimestamp,
|
||||||
|
getCachedHashes,
|
||||||
|
getCachedCardCount,
|
||||||
|
upsertCards,
|
||||||
|
clearCache,
|
||||||
|
closeDatabase,
|
||||||
|
} from "./localDatabase";
|
||||||
|
|
||||||
|
export {
|
||||||
|
createSyncService,
|
||||||
|
type SyncService,
|
||||||
|
type SyncStatus,
|
||||||
|
} from "./syncService";
|
||||||
180
lib/db/localDatabase.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
/**
|
||||||
|
* Local SQLite database for offline card hash caching.
|
||||||
|
* Syncs with Convex and provides offline recognition support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as SQLite from "expo-sqlite";
|
||||||
|
import type { CardHashEntry } from "../recognition";
|
||||||
|
|
||||||
|
const DB_NAME = "scry_cache.db";
|
||||||
|
|
||||||
|
let db: SQLite.SQLiteDatabase | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the local database.
|
||||||
|
*/
|
||||||
|
export async function initDatabase(): Promise<void> {
|
||||||
|
if (db) return;
|
||||||
|
|
||||||
|
db = await SQLite.openDatabaseAsync(DB_NAME);
|
||||||
|
|
||||||
|
// Create tables
|
||||||
|
await db.execAsync(`
|
||||||
|
CREATE TABLE IF NOT EXISTS cards (
|
||||||
|
scryfall_id TEXT PRIMARY KEY,
|
||||||
|
oracle_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
set_code TEXT NOT NULL,
|
||||||
|
collector_number TEXT,
|
||||||
|
rarity TEXT,
|
||||||
|
artist TEXT,
|
||||||
|
image_uri TEXT,
|
||||||
|
hash BLOB NOT NULL,
|
||||||
|
hash_version INTEGER DEFAULT 1,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_metadata (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cards_name ON cards(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cards_updated ON cards(updated_at);
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log("[LocalDB] Database initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last sync timestamp.
|
||||||
|
*/
|
||||||
|
export async function getLastSyncTimestamp(): Promise<number> {
|
||||||
|
if (!db) await initDatabase();
|
||||||
|
|
||||||
|
const result = await db!.getFirstAsync<{ value: string }>(
|
||||||
|
"SELECT value FROM sync_metadata WHERE key = ?",
|
||||||
|
["last_sync"]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result ? parseInt(result.value, 10) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the last sync timestamp.
|
||||||
|
*/
|
||||||
|
export async function setLastSyncTimestamp(timestamp: number): Promise<void> {
|
||||||
|
if (!db) await initDatabase();
|
||||||
|
|
||||||
|
await db!.runAsync(
|
||||||
|
"INSERT OR REPLACE INTO sync_metadata (key, value) VALUES (?, ?)",
|
||||||
|
["last_sync", timestamp.toString()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all cached card hashes for recognition.
|
||||||
|
*/
|
||||||
|
export async function getCachedHashes(): Promise<CardHashEntry[]> {
|
||||||
|
if (!db) await initDatabase();
|
||||||
|
|
||||||
|
const rows = await db!.getAllAsync<{
|
||||||
|
scryfall_id: string;
|
||||||
|
name: string;
|
||||||
|
set_code: string;
|
||||||
|
collector_number: string | null;
|
||||||
|
image_uri: string | null;
|
||||||
|
hash: Uint8Array;
|
||||||
|
}>("SELECT scryfall_id, name, set_code, collector_number, image_uri, hash FROM cards");
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.scryfall_id,
|
||||||
|
name: row.name,
|
||||||
|
setCode: row.set_code,
|
||||||
|
collectorNumber: row.collector_number || undefined,
|
||||||
|
imageUri: row.image_uri || undefined,
|
||||||
|
hash: new Uint8Array(row.hash),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the count of cached cards.
|
||||||
|
*/
|
||||||
|
export async function getCachedCardCount(): Promise<number> {
|
||||||
|
if (!db) await initDatabase();
|
||||||
|
|
||||||
|
const result = await db!.getFirstAsync<{ count: number }>(
|
||||||
|
"SELECT COUNT(*) as count FROM cards"
|
||||||
|
);
|
||||||
|
|
||||||
|
return result?.count || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert or update cards from Convex.
|
||||||
|
*/
|
||||||
|
export async function upsertCards(cards: Array<{
|
||||||
|
scryfallId: string;
|
||||||
|
oracleId: string;
|
||||||
|
name: string;
|
||||||
|
setCode: string;
|
||||||
|
collectorNumber: string;
|
||||||
|
rarity: string;
|
||||||
|
artist?: string;
|
||||||
|
imageUri?: string;
|
||||||
|
hash: Uint8Array;
|
||||||
|
hashVersion: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}>): Promise<void> {
|
||||||
|
if (!db) await initDatabase();
|
||||||
|
|
||||||
|
// Use a transaction for batch insert
|
||||||
|
await db!.withTransactionAsync(async () => {
|
||||||
|
for (const card of cards) {
|
||||||
|
await db!.runAsync(
|
||||||
|
`INSERT OR REPLACE INTO cards
|
||||||
|
(scryfall_id, oracle_id, name, set_code, collector_number, rarity, artist, image_uri, hash, hash_version, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
card.scryfallId,
|
||||||
|
card.oracleId,
|
||||||
|
card.name,
|
||||||
|
card.setCode,
|
||||||
|
card.collectorNumber,
|
||||||
|
card.rarity,
|
||||||
|
card.artist || null,
|
||||||
|
card.imageUri || null,
|
||||||
|
card.hash,
|
||||||
|
card.hashVersion,
|
||||||
|
card.updatedAt,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[LocalDB] Upserted ${cards.length} cards`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached data.
|
||||||
|
*/
|
||||||
|
export async function clearCache(): Promise<void> {
|
||||||
|
if (!db) await initDatabase();
|
||||||
|
|
||||||
|
await db!.execAsync(`
|
||||||
|
DELETE FROM cards;
|
||||||
|
DELETE FROM sync_metadata;
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log("[LocalDB] Cache cleared");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the database connection.
|
||||||
|
*/
|
||||||
|
export async function closeDatabase(): Promise<void> {
|
||||||
|
if (db) {
|
||||||
|
await db.closeAsync();
|
||||||
|
db = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
183
lib/db/syncService.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
/**
|
||||||
|
* Sync service for keeping local SQLite cache in sync with Convex.
|
||||||
|
* Provides offline-first functionality with background sync.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ConvexReactClient } from "convex/react";
|
||||||
|
import { api } from "../../convex/_generated/api";
|
||||||
|
import {
|
||||||
|
initDatabase,
|
||||||
|
getLastSyncTimestamp,
|
||||||
|
setLastSyncTimestamp,
|
||||||
|
getCachedHashes,
|
||||||
|
getCachedCardCount,
|
||||||
|
upsertCards,
|
||||||
|
clearCache,
|
||||||
|
} from "./localDatabase";
|
||||||
|
import type { CardHashEntry } from "../recognition";
|
||||||
|
|
||||||
|
export interface SyncStatus {
|
||||||
|
isInitialized: boolean;
|
||||||
|
isSyncing: boolean;
|
||||||
|
lastSync: number;
|
||||||
|
localCardCount: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncService {
|
||||||
|
initialize: () => Promise<void>;
|
||||||
|
sync: () => Promise<void>;
|
||||||
|
getHashes: () => Promise<CardHashEntry[]>;
|
||||||
|
getStatus: () => SyncStatus;
|
||||||
|
clearLocalCache: () => Promise<void>;
|
||||||
|
onStatusChange: (callback: (status: SyncStatus) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a sync service instance.
|
||||||
|
*/
|
||||||
|
export function createSyncService(convexClient: ConvexReactClient): SyncService {
|
||||||
|
let status: SyncStatus = {
|
||||||
|
isInitialized: false,
|
||||||
|
isSyncing: false,
|
||||||
|
lastSync: 0,
|
||||||
|
localCardCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const listeners = new Set<(status: SyncStatus) => void>();
|
||||||
|
|
||||||
|
function notifyListeners() {
|
||||||
|
listeners.forEach((cb) => cb(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(partial: Partial<SyncStatus>) {
|
||||||
|
status = { ...status, ...partial };
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Initialize the local database and load cached data.
|
||||||
|
*/
|
||||||
|
async initialize() {
|
||||||
|
try {
|
||||||
|
await initDatabase();
|
||||||
|
|
||||||
|
const [lastSync, cardCount] = await Promise.all([
|
||||||
|
getLastSyncTimestamp(),
|
||||||
|
getCachedCardCount(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
updateStatus({
|
||||||
|
isInitialized: true,
|
||||||
|
lastSync,
|
||||||
|
localCardCount: cardCount,
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[SyncService] Initialized with ${cardCount} cached cards`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SyncService] Initialization failed:", error);
|
||||||
|
updateStatus({
|
||||||
|
error: error instanceof Error ? error.message : "Initialization failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync with Convex - fetch new/updated cards.
|
||||||
|
*/
|
||||||
|
async sync() {
|
||||||
|
if (status.isSyncing) {
|
||||||
|
console.log("[SyncService] Sync already in progress");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus({ isSyncing: true, error: undefined });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get cards updated since last sync
|
||||||
|
const lastSync = status.lastSync;
|
||||||
|
|
||||||
|
// Query Convex for updated cards
|
||||||
|
// Note: This uses the HTTP client for one-off queries
|
||||||
|
const cards = lastSync > 0
|
||||||
|
? await convexClient.query(api.cards.listUpdatedAfter, { since: lastSync })
|
||||||
|
: await convexClient.query(api.cards.list, {});
|
||||||
|
|
||||||
|
if (cards && cards.length > 0) {
|
||||||
|
// Convert to local format and save
|
||||||
|
const localCards = cards.map((card) => ({
|
||||||
|
scryfallId: card.scryfallId,
|
||||||
|
oracleId: card.oracleId,
|
||||||
|
name: card.name,
|
||||||
|
setCode: card.setCode,
|
||||||
|
collectorNumber: card.collectorNumber,
|
||||||
|
rarity: card.rarity,
|
||||||
|
artist: card.artist,
|
||||||
|
imageUri: card.imageUri,
|
||||||
|
hash: new Uint8Array(card.hash),
|
||||||
|
hashVersion: card.hashVersion,
|
||||||
|
updatedAt: card.updatedAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await upsertCards(localCards);
|
||||||
|
console.log(`[SyncService] Synced ${localCards.length} cards`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sync timestamp
|
||||||
|
const now = Date.now();
|
||||||
|
await setLastSyncTimestamp(now);
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
const cardCount = await getCachedCardCount();
|
||||||
|
updateStatus({
|
||||||
|
isSyncing: false,
|
||||||
|
lastSync: now,
|
||||||
|
localCardCount: cardCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[SyncService] Sync complete. ${cardCount} cards cached.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SyncService] Sync failed:", error);
|
||||||
|
updateStatus({
|
||||||
|
isSyncing: false,
|
||||||
|
error: error instanceof Error ? error.message : "Sync failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached hashes for recognition.
|
||||||
|
*/
|
||||||
|
async getHashes() {
|
||||||
|
return getCachedHashes();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current sync status.
|
||||||
|
*/
|
||||||
|
getStatus() {
|
||||||
|
return status;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear local cache and re-sync.
|
||||||
|
*/
|
||||||
|
async clearLocalCache() {
|
||||||
|
await clearCache();
|
||||||
|
updateStatus({
|
||||||
|
lastSync: 0,
|
||||||
|
localCardCount: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to status changes.
|
||||||
|
*/
|
||||||
|
onStatusChange(callback: (status: SyncStatus) => void) {
|
||||||
|
listeners.add(callback);
|
||||||
|
return () => listeners.delete(callback);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
9
lib/hooks/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/**
|
||||||
|
* Hooks module exports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { useCameraPermission } from "./useCamera";
|
||||||
|
export { useCardHashes, useCardCount, useSearchCards, useCollection, useCurrentUser } from "./useConvex";
|
||||||
|
export { useSync, SyncInitializer } from "./useSync";
|
||||||
|
export { useAuth } from "./useAuth";
|
||||||
|
export { useUserProfile, storeAccessToken, clearAccessToken } from "./useUserProfile";
|
||||||
112
lib/hooks/useAuth.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
/**
|
||||||
|
* Authentication hook and utilities.
|
||||||
|
*
|
||||||
|
* NOTE: Auth is currently disabled (using ConvexProvider instead of ConvexAuthProvider).
|
||||||
|
* This hook provides a stub implementation until Zitadel is configured.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for authentication state and actions.
|
||||||
|
* Currently returns unauthenticated state - enable ConvexAuthProvider when Zitadel is ready.
|
||||||
|
*/
|
||||||
|
export function useAuth() {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSignIn = useCallback(async () => {
|
||||||
|
setError("Authentication not configured. Set up Zitadel environment variables.");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSignOut = useCallback(async () => {
|
||||||
|
// No-op when not authenticated
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error,
|
||||||
|
signIn: handleSignIn,
|
||||||
|
signOut: handleSignOut,
|
||||||
|
clearError: () => setError(null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full implementation for when ConvexAuthProvider is enabled:
|
||||||
|
/*
|
||||||
|
import { useConvexAuth } from "convex/react";
|
||||||
|
import { useAuthActions } from "@convex-dev/auth/react";
|
||||||
|
import { makeRedirectUri } from "expo-auth-session";
|
||||||
|
import { openAuthSessionAsync } from "expo-web-browser";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
const redirectUri = makeRedirectUri({
|
||||||
|
scheme: "scry",
|
||||||
|
path: "auth",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||||
|
const { signIn, signOut } = useAuthActions();
|
||||||
|
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSignIn = useCallback(async () => {
|
||||||
|
setIsSigningIn(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await signIn("zitadel", { redirectTo: redirectUri });
|
||||||
|
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.redirect) {
|
||||||
|
const authResult = await openAuthSessionAsync(
|
||||||
|
result.redirect.toString(),
|
||||||
|
redirectUri,
|
||||||
|
{
|
||||||
|
showInRecents: true,
|
||||||
|
preferEphemeralSession: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (authResult.type === "success") {
|
||||||
|
const url = new URL(authResult.url);
|
||||||
|
const code = url.searchParams.get("code");
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
await signIn("zitadel", { code });
|
||||||
|
}
|
||||||
|
} else if (authResult.type === "cancel") {
|
||||||
|
setError("Sign-in was cancelled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Sign-in error:", err);
|
||||||
|
setError(err instanceof Error ? err.message : "Sign-in failed");
|
||||||
|
} finally {
|
||||||
|
setIsSigningIn(false);
|
||||||
|
}
|
||||||
|
}, [signIn]);
|
||||||
|
|
||||||
|
const handleSignOut = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await signOut();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Sign-out error:", err);
|
||||||
|
setError(err instanceof Error ? err.message : "Sign-out failed");
|
||||||
|
}
|
||||||
|
}, [signOut]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading: isLoading || isSigningIn,
|
||||||
|
error,
|
||||||
|
signIn: handleSignIn,
|
||||||
|
signOut: handleSignOut,
|
||||||
|
clearError: () => setError(null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
*/
|
||||||
116
lib/hooks/useCamera.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
/**
|
||||||
|
* Hook for camera permissions.
|
||||||
|
* Supports both expo-camera (Expo Go) and react-native-vision-camera (production).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Platform, Linking, Alert } from "react-native";
|
||||||
|
import Constants from "expo-constants";
|
||||||
|
|
||||||
|
// Detect if running in Expo Go
|
||||||
|
const isExpoGo = Constants.appOwnership === "expo";
|
||||||
|
|
||||||
|
export interface CameraPermissionState {
|
||||||
|
hasPermission: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for camera permissions that works in both Expo Go and production builds.
|
||||||
|
*/
|
||||||
|
export function useCameraPermission(): CameraPermissionState & { requestPermission: () => Promise<void> } {
|
||||||
|
const [hasPermission, setHasPermission] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkPermission();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkPermission = async () => {
|
||||||
|
try {
|
||||||
|
if (isExpoGo) {
|
||||||
|
// Use expo-camera
|
||||||
|
const { Camera } = await import("expo-camera");
|
||||||
|
const { status } = await Camera.getCameraPermissionsAsync();
|
||||||
|
setHasPermission(status === "granted");
|
||||||
|
} else {
|
||||||
|
// Use react-native-vision-camera
|
||||||
|
const { Camera } = await import("react-native-vision-camera");
|
||||||
|
const status = await Camera.getCameraPermissionStatus();
|
||||||
|
setHasPermission(status === "granted");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to check camera permission");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestPermission = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (isExpoGo) {
|
||||||
|
// Use expo-camera
|
||||||
|
const { Camera } = await import("expo-camera");
|
||||||
|
const { status, canAskAgain } = await Camera.requestCameraPermissionsAsync();
|
||||||
|
|
||||||
|
if (status === "granted") {
|
||||||
|
setHasPermission(true);
|
||||||
|
} else {
|
||||||
|
if (!canAskAgain) {
|
||||||
|
showSettingsAlert();
|
||||||
|
}
|
||||||
|
setError("Camera permission denied");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use react-native-vision-camera
|
||||||
|
const { Camera } = await import("react-native-vision-camera");
|
||||||
|
const status = await Camera.requestCameraPermission();
|
||||||
|
|
||||||
|
if (status === "granted") {
|
||||||
|
setHasPermission(true);
|
||||||
|
} else if (status === "denied") {
|
||||||
|
showSettingsAlert();
|
||||||
|
setError("Camera permission denied");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to request camera permission");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { hasPermission, isLoading, error, requestPermission };
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSettingsAlert() {
|
||||||
|
Alert.alert(
|
||||||
|
"Camera Permission Required",
|
||||||
|
"Scry needs camera access to scan cards. Please enable it in Settings.",
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Open Settings",
|
||||||
|
onPress: () => {
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
Linking.openURL("app-settings:");
|
||||||
|
} else {
|
||||||
|
Linking.openSettings();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if using expo-camera (Expo Go), false for Vision Camera (production).
|
||||||
|
*/
|
||||||
|
export function useIsExpoGo(): boolean {
|
||||||
|
return isExpoGo;
|
||||||
|
}
|
||||||
101
lib/hooks/useConvex.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
/**
|
||||||
|
* Hooks for Convex data access.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery, useMutation } from "convex/react";
|
||||||
|
import { api } from "../../convex/_generated/api";
|
||||||
|
import { useHashCache } from "../context";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import type { CardHashEntry } from "../recognition";
|
||||||
|
import type { Id } from "../../convex/_generated/dataModel";
|
||||||
|
|
||||||
|
type User = { _id: Id<"users"> } | null | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get the current authenticated user.
|
||||||
|
* Returns unauthenticated state when auth is not configured.
|
||||||
|
*/
|
||||||
|
export function useCurrentUser(): { user: User; isLoading: boolean; isAuthenticated: boolean } {
|
||||||
|
// When using ConvexProvider (no auth), just return unauthenticated state
|
||||||
|
// Switch to ConvexAuthProvider + useConvexAuth when Zitadel is configured
|
||||||
|
return {
|
||||||
|
user: null,
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch and cache card hashes from Convex.
|
||||||
|
*/
|
||||||
|
export function useCardHashes() {
|
||||||
|
const { setCardHashes, hashesLoaded, lastHashSync, cardHashes } = useHashCache();
|
||||||
|
|
||||||
|
// Fetch all cards from Convex
|
||||||
|
const cards = useQuery(api.cards.list);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cards) {
|
||||||
|
// Convert Convex cards to CardHashEntry format
|
||||||
|
const entries: CardHashEntry[] = cards.map((card) => ({
|
||||||
|
id: card.scryfallId,
|
||||||
|
name: card.name,
|
||||||
|
setCode: card.setCode,
|
||||||
|
collectorNumber: card.collectorNumber,
|
||||||
|
imageUri: card.imageUri,
|
||||||
|
hash: new Uint8Array(card.hash),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setCardHashes(entries);
|
||||||
|
}
|
||||||
|
}, [cards, setCardHashes]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading: cards === undefined && !hashesLoaded,
|
||||||
|
loaded: hashesLoaded,
|
||||||
|
count: cardHashes.length,
|
||||||
|
lastSync: lastHashSync,
|
||||||
|
hashes: cardHashes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get card count.
|
||||||
|
*/
|
||||||
|
export function useCardCount() {
|
||||||
|
const count = useQuery(api.cards.count);
|
||||||
|
return count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to search cards by name.
|
||||||
|
*/
|
||||||
|
export function useSearchCards(query: string) {
|
||||||
|
const results = useQuery(
|
||||||
|
api.cards.searchByName,
|
||||||
|
query.length >= 2 ? { name: query } : "skip"
|
||||||
|
);
|
||||||
|
return results ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage collection.
|
||||||
|
*/
|
||||||
|
export function useCollection(userId?: string) {
|
||||||
|
const collection = useQuery(
|
||||||
|
api.collections.getByUser,
|
||||||
|
userId ? { userId: userId as any } : "skip"
|
||||||
|
);
|
||||||
|
|
||||||
|
const addCard = useMutation(api.collections.add);
|
||||||
|
const removeCard = useMutation(api.collections.remove);
|
||||||
|
const updateQuantity = useMutation(api.collections.updateQuantity);
|
||||||
|
|
||||||
|
return {
|
||||||
|
collection: collection ?? [],
|
||||||
|
addCard,
|
||||||
|
removeCard,
|
||||||
|
updateQuantity,
|
||||||
|
loading: collection === undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
102
lib/hooks/useSync.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
/**
|
||||||
|
* Hook for managing offline sync with local SQLite cache.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
|
import { useConvex } from "convex/react";
|
||||||
|
import { createSyncService, type SyncService, type SyncStatus } from "../db/syncService";
|
||||||
|
import { useHashCache } from "../context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage sync service lifecycle and status.
|
||||||
|
* Initializes local database, syncs with Convex, and provides hashes for recognition.
|
||||||
|
*/
|
||||||
|
export function useSync() {
|
||||||
|
const convex = useConvex();
|
||||||
|
const syncServiceRef = useRef<SyncService | null>(null);
|
||||||
|
const [status, setStatus] = useState<SyncStatus>({
|
||||||
|
isInitialized: false,
|
||||||
|
isSyncing: false,
|
||||||
|
lastSync: 0,
|
||||||
|
localCardCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setCardHashes } = useHashCache();
|
||||||
|
|
||||||
|
// Initialize sync service
|
||||||
|
useEffect(() => {
|
||||||
|
if (!convex || syncServiceRef.current) return;
|
||||||
|
|
||||||
|
const service = createSyncService(convex);
|
||||||
|
syncServiceRef.current = service;
|
||||||
|
|
||||||
|
// Subscribe to status changes
|
||||||
|
const unsubscribe = service.onStatusChange(setStatus);
|
||||||
|
|
||||||
|
// Initialize and load cached hashes
|
||||||
|
const init = async () => {
|
||||||
|
await service.initialize();
|
||||||
|
|
||||||
|
// Load cached hashes into app store
|
||||||
|
const hashes = await service.getHashes();
|
||||||
|
if (hashes.length > 0) {
|
||||||
|
setCardHashes(hashes);
|
||||||
|
console.log(`[useSync] Loaded ${hashes.length} cached hashes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start background sync
|
||||||
|
service.sync().then(async () => {
|
||||||
|
// Reload hashes after sync
|
||||||
|
const updatedHashes = await service.getHashes();
|
||||||
|
setCardHashes(updatedHashes);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [convex, setCardHashes]);
|
||||||
|
|
||||||
|
// Manual sync trigger
|
||||||
|
const sync = useCallback(async () => {
|
||||||
|
if (!syncServiceRef.current) return;
|
||||||
|
|
||||||
|
await syncServiceRef.current.sync();
|
||||||
|
|
||||||
|
// Reload hashes after sync
|
||||||
|
const hashes = await syncServiceRef.current.getHashes();
|
||||||
|
setCardHashes(hashes);
|
||||||
|
}, [setCardHashes]);
|
||||||
|
|
||||||
|
// Clear local cache
|
||||||
|
const clearCache = useCallback(async () => {
|
||||||
|
if (!syncServiceRef.current) return;
|
||||||
|
|
||||||
|
await syncServiceRef.current.clearLocalCache();
|
||||||
|
setCardHashes([]);
|
||||||
|
}, [setCardHashes]);
|
||||||
|
|
||||||
|
// Get current hashes from cache
|
||||||
|
const getHashes = useCallback(async () => {
|
||||||
|
if (!syncServiceRef.current) return [];
|
||||||
|
return syncServiceRef.current.getHashes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...status,
|
||||||
|
sync,
|
||||||
|
clearCache,
|
||||||
|
getHashes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context-free sync initialization for use in _layout.tsx
|
||||||
|
* Returns a component that initializes sync when mounted.
|
||||||
|
*/
|
||||||
|
export function SyncInitializer() {
|
||||||
|
useSync();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
115
lib/hooks/useUserProfile.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
/**
|
||||||
|
* Hook for fetching user profile from Zitadel OIDC userinfo endpoint.
|
||||||
|
*
|
||||||
|
* GDPR Compliance: User profile data is never stored in our database.
|
||||||
|
* This hook fetches it directly from Zitadel when needed for display.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
sub: string;
|
||||||
|
name?: string;
|
||||||
|
preferredUsername?: string;
|
||||||
|
email?: string;
|
||||||
|
emailVerified?: boolean;
|
||||||
|
picture?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_KEY = "zitadel_access_token";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch and cache user profile from Zitadel userinfo endpoint.
|
||||||
|
* Profile is held in memory only - never persisted to our database.
|
||||||
|
*/
|
||||||
|
export function useUserProfile() {
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchProfile = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get stored access token
|
||||||
|
const accessToken = await SecureStore.getItemAsync(ACCESS_TOKEN_KEY);
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
setProfile(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuer = process.env.EXPO_PUBLIC_ZITADEL_ISSUER;
|
||||||
|
if (!issuer) {
|
||||||
|
throw new Error("ZITADEL_ISSUER not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from OIDC userinfo endpoint
|
||||||
|
const response = await fetch(`${issuer}/oidc/v1/userinfo`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
// Token expired - clear it
|
||||||
|
await SecureStore.deleteItemAsync(ACCESS_TOKEN_KEY);
|
||||||
|
setProfile(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch user profile: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const userProfile: UserProfile = {
|
||||||
|
sub: data.sub,
|
||||||
|
name: data.name,
|
||||||
|
preferredUsername: data.preferred_username,
|
||||||
|
email: data.email,
|
||||||
|
emailVerified: data.email_verified,
|
||||||
|
picture: data.picture,
|
||||||
|
};
|
||||||
|
|
||||||
|
setProfile(userProfile);
|
||||||
|
return userProfile;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to fetch profile";
|
||||||
|
setError(message);
|
||||||
|
console.error("[useUserProfile]", message);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearProfile = useCallback(() => {
|
||||||
|
setProfile(null);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
fetchProfile,
|
||||||
|
clearProfile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store access token after successful auth (called from useAuth).
|
||||||
|
*/
|
||||||
|
export async function storeAccessToken(token: string): Promise<void> {
|
||||||
|
await SecureStore.setItemAsync(ACCESS_TOKEN_KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear access token on sign out.
|
||||||
|
*/
|
||||||
|
export async function clearAccessToken(): Promise<void> {
|
||||||
|
await SecureStore.deleteItemAsync(ACCESS_TOKEN_KEY);
|
||||||
|
}
|
||||||
492
lib/recognition/cardDetector.ts
Normal file
|
|
@ -0,0 +1,492 @@
|
||||||
|
/**
|
||||||
|
* Card detection using edge detection and contour analysis.
|
||||||
|
* Detects card boundaries in images and returns the four corner points.
|
||||||
|
*
|
||||||
|
* This is a pure TypeScript implementation for when OpenCV is not available.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Point,
|
||||||
|
toGrayscale,
|
||||||
|
gaussianBlur,
|
||||||
|
distance,
|
||||||
|
crossProduct,
|
||||||
|
pointToLineDistance,
|
||||||
|
} from "./imageUtils";
|
||||||
|
|
||||||
|
/** Standard MTG card aspect ratio (height / width). Cards are 63mm x 88mm. */
|
||||||
|
const CARD_ASPECT_RATIO = 88 / 63; // ~1.397
|
||||||
|
const ASPECT_RATIO_TOLERANCE = 0.25;
|
||||||
|
const MIN_CARD_AREA_RATIO = 0.05;
|
||||||
|
const MAX_CARD_AREA_RATIO = 0.98;
|
||||||
|
|
||||||
|
export interface CardDetectionResult {
|
||||||
|
found: boolean;
|
||||||
|
corners: Point[];
|
||||||
|
confidence: number;
|
||||||
|
debugMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect a card in the image and return its corner points.
|
||||||
|
*/
|
||||||
|
export function detectCard(
|
||||||
|
pixels: Uint8Array | Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): CardDetectionResult {
|
||||||
|
// Step 1: Convert to grayscale
|
||||||
|
const grayscale = toGrayscale(pixels);
|
||||||
|
|
||||||
|
// Step 2: Apply Gaussian blur to reduce noise
|
||||||
|
const blurred = gaussianBlur(grayscale, width, height, 5);
|
||||||
|
|
||||||
|
// Step 3: Apply Canny edge detection
|
||||||
|
const edges = applyCannyEdgeDetection(blurred, width, height, 50, 150);
|
||||||
|
|
||||||
|
// Step 4: Find contours
|
||||||
|
const contours = findContours(edges, width, height);
|
||||||
|
|
||||||
|
if (contours.length === 0) {
|
||||||
|
return { found: false, corners: [], confidence: 0, debugMessage: "No contours found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Find the best card-like quadrilateral
|
||||||
|
const imageArea = width * height;
|
||||||
|
const bestQuad = findBestCardQuadrilateral(contours, imageArea);
|
||||||
|
|
||||||
|
if (!bestQuad) {
|
||||||
|
return { found: false, corners: [], confidence: 0, debugMessage: "No card-like quadrilateral found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Order corners consistently
|
||||||
|
const orderedCorners = orderCorners(bestQuad);
|
||||||
|
|
||||||
|
// Calculate confidence
|
||||||
|
const confidence = calculateConfidence(orderedCorners, imageArea);
|
||||||
|
|
||||||
|
return { found: true, corners: orderedCorners, confidence };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply Canny edge detection.
|
||||||
|
*/
|
||||||
|
function applyCannyEdgeDetection(
|
||||||
|
gray: Uint8Array,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
lowThreshold: number,
|
||||||
|
highThreshold: number
|
||||||
|
): Uint8Array {
|
||||||
|
// Step 1: Compute gradients using Sobel operators
|
||||||
|
const gradientX = new Float32Array(width * height);
|
||||||
|
const gradientY = new Float32Array(width * height);
|
||||||
|
const magnitude = new Float32Array(width * height);
|
||||||
|
const direction = new Float32Array(width * height);
|
||||||
|
|
||||||
|
// Sobel kernels
|
||||||
|
const sobelX = [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]];
|
||||||
|
const sobelY = [[-1, -2, -1], [0, 0, 0], [1, 2, 1]];
|
||||||
|
|
||||||
|
for (let y = 1; y < height - 1; y++) {
|
||||||
|
for (let x = 1; x < width - 1; x++) {
|
||||||
|
let gx = 0, gy = 0;
|
||||||
|
|
||||||
|
for (let ky = -1; ky <= 1; ky++) {
|
||||||
|
for (let kx = -1; kx <= 1; kx++) {
|
||||||
|
const pixel = gray[(y + ky) * width + (x + kx)];
|
||||||
|
gx += pixel * sobelX[ky + 1][kx + 1];
|
||||||
|
gy += pixel * sobelY[ky + 1][kx + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = y * width + x;
|
||||||
|
gradientX[idx] = gx;
|
||||||
|
gradientY[idx] = gy;
|
||||||
|
magnitude[idx] = Math.sqrt(gx * gx + gy * gy);
|
||||||
|
direction[idx] = Math.atan2(gy, gx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Non-maximum suppression
|
||||||
|
const suppressed = new Float32Array(width * height);
|
||||||
|
|
||||||
|
for (let y = 1; y < height - 1; y++) {
|
||||||
|
for (let x = 1; x < width - 1; x++) {
|
||||||
|
const idx = y * width + x;
|
||||||
|
let angle = direction[idx] * 180 / Math.PI;
|
||||||
|
if (angle < 0) angle += 180;
|
||||||
|
|
||||||
|
let neighbor1: number, neighbor2: number;
|
||||||
|
|
||||||
|
if (angle < 22.5 || angle >= 157.5) {
|
||||||
|
neighbor1 = magnitude[y * width + (x - 1)];
|
||||||
|
neighbor2 = magnitude[y * width + (x + 1)];
|
||||||
|
} else if (angle >= 22.5 && angle < 67.5) {
|
||||||
|
neighbor1 = magnitude[(y - 1) * width + (x + 1)];
|
||||||
|
neighbor2 = magnitude[(y + 1) * width + (x - 1)];
|
||||||
|
} else if (angle >= 67.5 && angle < 112.5) {
|
||||||
|
neighbor1 = magnitude[(y - 1) * width + x];
|
||||||
|
neighbor2 = magnitude[(y + 1) * width + x];
|
||||||
|
} else {
|
||||||
|
neighbor1 = magnitude[(y - 1) * width + (x - 1)];
|
||||||
|
neighbor2 = magnitude[(y + 1) * width + (x + 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (magnitude[idx] >= neighbor1 && magnitude[idx] >= neighbor2) {
|
||||||
|
suppressed[idx] = magnitude[idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Double thresholding and edge tracking
|
||||||
|
const result = new Uint8Array(width * height);
|
||||||
|
const strong = new Uint8Array(width * height);
|
||||||
|
const weak = new Uint8Array(width * height);
|
||||||
|
|
||||||
|
for (let i = 0; i < width * height; i++) {
|
||||||
|
if (suppressed[i] >= highThreshold) {
|
||||||
|
strong[i] = 1;
|
||||||
|
} else if (suppressed[i] >= lowThreshold) {
|
||||||
|
weak[i] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge tracking by hysteresis
|
||||||
|
for (let y = 1; y < height - 1; y++) {
|
||||||
|
for (let x = 1; x < width - 1; x++) {
|
||||||
|
const idx = y * width + x;
|
||||||
|
|
||||||
|
if (strong[idx]) {
|
||||||
|
result[idx] = 255;
|
||||||
|
} else if (weak[idx]) {
|
||||||
|
// Check if connected to strong edge
|
||||||
|
outer: for (let dy = -1; dy <= 1; dy++) {
|
||||||
|
for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
if (strong[(y + dy) * width + (x + dx)]) {
|
||||||
|
result[idx] = 255;
|
||||||
|
break outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find contours in a binary edge image using flood fill.
|
||||||
|
*/
|
||||||
|
function findContours(
|
||||||
|
edges: Uint8Array,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): Point[][] {
|
||||||
|
const visited = new Uint8Array(width * height);
|
||||||
|
const contours: Point[][] = [];
|
||||||
|
|
||||||
|
const dx = [-1, 0, 1, 1, 1, 0, -1, -1];
|
||||||
|
const dy = [-1, -1, -1, 0, 1, 1, 1, 0];
|
||||||
|
|
||||||
|
for (let y = 1; y < height - 1; y++) {
|
||||||
|
for (let x = 1; x < width - 1; x++) {
|
||||||
|
const idx = y * width + x;
|
||||||
|
if (visited[idx]) continue;
|
||||||
|
if (edges[idx] < 128) continue;
|
||||||
|
|
||||||
|
// Trace contour using BFS
|
||||||
|
const contour: Point[] = [];
|
||||||
|
const queue: Array<{ x: number; y: number }> = [{ x, y }];
|
||||||
|
|
||||||
|
while (queue.length > 0 && contour.length < 10000) {
|
||||||
|
const point = queue.shift()!;
|
||||||
|
const pidx = point.y * width + point.x;
|
||||||
|
|
||||||
|
if (point.x < 0 || point.x >= width || point.y < 0 || point.y >= height) continue;
|
||||||
|
if (visited[pidx]) continue;
|
||||||
|
if (edges[pidx] < 128) continue;
|
||||||
|
|
||||||
|
visited[pidx] = 1;
|
||||||
|
contour.push(point);
|
||||||
|
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
queue.push({ x: point.x + dx[i], y: point.y + dy[i] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contour.length >= 4) {
|
||||||
|
contours.push(contour);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return contours;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the best quadrilateral that matches a card shape.
|
||||||
|
*/
|
||||||
|
function findBestCardQuadrilateral(
|
||||||
|
contours: Point[][],
|
||||||
|
imageArea: number
|
||||||
|
): Point[] | null {
|
||||||
|
let bestQuad: Point[] | null = null;
|
||||||
|
let bestScore = -Infinity;
|
||||||
|
|
||||||
|
for (const contour of contours) {
|
||||||
|
// Simplify contour using Douglas-Peucker
|
||||||
|
const simplified = simplifyContour(contour, contour.length * 0.02);
|
||||||
|
|
||||||
|
// Try to approximate as quadrilateral
|
||||||
|
const quad = approximateQuadrilateral(simplified);
|
||||||
|
if (!quad) continue;
|
||||||
|
|
||||||
|
// Check if valid card shape
|
||||||
|
const area = calculateQuadArea(quad);
|
||||||
|
const areaRatio = area / imageArea;
|
||||||
|
|
||||||
|
if (areaRatio < MIN_CARD_AREA_RATIO || areaRatio > MAX_CARD_AREA_RATIO) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check aspect ratio
|
||||||
|
const aspectScore = calculateAspectRatioScore(quad);
|
||||||
|
if (aspectScore < 0.5) continue;
|
||||||
|
|
||||||
|
// Check convexity
|
||||||
|
if (!isConvex(quad)) continue;
|
||||||
|
|
||||||
|
// Score based on area and aspect ratio
|
||||||
|
const score = areaRatio * aspectScore;
|
||||||
|
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestQuad = quad;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestQuad;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simplify a contour using Douglas-Peucker algorithm.
|
||||||
|
*/
|
||||||
|
function simplifyContour(contour: Point[], epsilon: number): Point[] {
|
||||||
|
if (contour.length < 3) return contour;
|
||||||
|
|
||||||
|
const first = contour[0];
|
||||||
|
const last = contour[contour.length - 1];
|
||||||
|
|
||||||
|
let maxDist = 0;
|
||||||
|
let maxIndex = 0;
|
||||||
|
|
||||||
|
for (let i = 1; i < contour.length - 1; i++) {
|
||||||
|
const dist = pointToLineDistance(contour[i], first, last);
|
||||||
|
if (dist > maxDist) {
|
||||||
|
maxDist = dist;
|
||||||
|
maxIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxDist > epsilon) {
|
||||||
|
const left = simplifyContour(contour.slice(0, maxIndex + 1), epsilon);
|
||||||
|
const right = simplifyContour(contour.slice(maxIndex), epsilon);
|
||||||
|
return [...left.slice(0, -1), ...right];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [first, last];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to approximate a contour as a quadrilateral.
|
||||||
|
*/
|
||||||
|
function approximateQuadrilateral(contour: Point[]): Point[] | null {
|
||||||
|
if (contour.length < 4) return null;
|
||||||
|
if (contour.length === 4) return contour;
|
||||||
|
|
||||||
|
// Find convex hull
|
||||||
|
const hull = convexHull(contour);
|
||||||
|
if (hull.length < 4) return null;
|
||||||
|
if (hull.length === 4) return hull;
|
||||||
|
|
||||||
|
// Find 4 extreme points
|
||||||
|
return findExtremePoints(hull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate convex hull using Graham scan.
|
||||||
|
*/
|
||||||
|
function convexHull(points: Point[]): Point[] {
|
||||||
|
if (points.length < 3) return points;
|
||||||
|
|
||||||
|
// Find bottom-most point
|
||||||
|
const start = points.reduce((min, p) =>
|
||||||
|
p.y < min.y || (p.y === min.y && p.x < min.x) ? p : min
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort by polar angle
|
||||||
|
const sorted = points
|
||||||
|
.filter(p => p !== start)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const angleA = Math.atan2(a.y - start.y, a.x - start.x);
|
||||||
|
const angleB = Math.atan2(b.y - start.y, b.x - start.x);
|
||||||
|
if (angleA !== angleB) return angleA - angleB;
|
||||||
|
return distance(a, start) - distance(b, start);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hull: Point[] = [start];
|
||||||
|
|
||||||
|
for (const point of sorted) {
|
||||||
|
while (hull.length > 1 && crossProduct(hull[hull.length - 2], hull[hull.length - 1], point) <= 0) {
|
||||||
|
hull.pop();
|
||||||
|
}
|
||||||
|
hull.push(point);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hull;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find 4 extreme points of a convex hull.
|
||||||
|
*/
|
||||||
|
function findExtremePoints(hull: Point[]): Point[] {
|
||||||
|
if (hull.length <= 4) return hull;
|
||||||
|
|
||||||
|
const minX = hull.reduce((min, p) => p.x < min.x ? p : min, hull[0]);
|
||||||
|
const maxX = hull.reduce((max, p) => p.x > max.x ? p : max, hull[0]);
|
||||||
|
const minY = hull.reduce((min, p) => p.y < min.y ? p : min, hull[0]);
|
||||||
|
const maxY = hull.reduce((max, p) => p.y > max.y ? p : max, hull[0]);
|
||||||
|
|
||||||
|
const extremes = new Set([minX, maxX, minY, maxY]);
|
||||||
|
|
||||||
|
if (extremes.size === 4) {
|
||||||
|
return Array.from(extremes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: take points at regular angular intervals
|
||||||
|
const centerX = hull.reduce((s, p) => s + p.x, 0) / hull.length;
|
||||||
|
const centerY = hull.reduce((s, p) => s + p.y, 0) / hull.length;
|
||||||
|
|
||||||
|
const sorted = [...hull].sort((a, b) =>
|
||||||
|
Math.atan2(a.y - centerY, a.x - centerX) -
|
||||||
|
Math.atan2(b.y - centerY, b.x - centerX)
|
||||||
|
);
|
||||||
|
|
||||||
|
const step = Math.floor(sorted.length / 4);
|
||||||
|
return [sorted[0], sorted[step], sorted[step * 2], sorted[step * 3]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate area of a quadrilateral using shoelace formula.
|
||||||
|
*/
|
||||||
|
function calculateQuadArea(quad: Point[]): number {
|
||||||
|
let area = 0;
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const j = (i + 1) % 4;
|
||||||
|
area += quad[i].x * quad[j].y;
|
||||||
|
area -= quad[j].x * quad[i].y;
|
||||||
|
}
|
||||||
|
return Math.abs(area) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate how well the aspect ratio matches a card.
|
||||||
|
*/
|
||||||
|
function calculateAspectRatioScore(quad: Point[]): number {
|
||||||
|
const width1 = distance(quad[0], quad[1]);
|
||||||
|
const width2 = distance(quad[2], quad[3]);
|
||||||
|
const height1 = distance(quad[1], quad[2]);
|
||||||
|
const height2 = distance(quad[3], quad[0]);
|
||||||
|
|
||||||
|
const avgWidth = (width1 + width2) / 2;
|
||||||
|
const avgHeight = (height1 + height2) / 2;
|
||||||
|
|
||||||
|
// Ensure we get the right ratio regardless of orientation
|
||||||
|
const aspectRatio = avgWidth > avgHeight
|
||||||
|
? avgWidth / avgHeight
|
||||||
|
: avgHeight / avgWidth;
|
||||||
|
|
||||||
|
const deviation = Math.abs(aspectRatio - CARD_ASPECT_RATIO) / CARD_ASPECT_RATIO;
|
||||||
|
|
||||||
|
return Math.max(0, 1 - deviation / ASPECT_RATIO_TOLERANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a quadrilateral is convex.
|
||||||
|
*/
|
||||||
|
function isConvex(quad: Point[]): boolean {
|
||||||
|
let sign = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const cross = crossProduct(
|
||||||
|
quad[i],
|
||||||
|
quad[(i + 1) % 4],
|
||||||
|
quad[(i + 2) % 4]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Math.abs(cross) < 0.0001) continue;
|
||||||
|
|
||||||
|
const currentSign = cross > 0 ? 1 : -1;
|
||||||
|
|
||||||
|
if (sign === 0) {
|
||||||
|
sign = currentSign;
|
||||||
|
} else if (sign !== currentSign) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order corners consistently: top-left, top-right, bottom-right, bottom-left.
|
||||||
|
*/
|
||||||
|
function orderCorners(corners: Point[]): Point[] {
|
||||||
|
const centerX = corners.reduce((s, c) => s + c.x, 0) / 4;
|
||||||
|
const centerY = corners.reduce((s, c) => s + c.y, 0) / 4;
|
||||||
|
|
||||||
|
const topLeft = corners.filter(c => c.x < centerX && c.y < centerY)
|
||||||
|
.sort((a, b) => (a.x + a.y) - (b.x + b.y))[0];
|
||||||
|
const topRight = corners.filter(c => c.x >= centerX && c.y < centerY)
|
||||||
|
.sort((a, b) => (a.y - a.x) - (b.y - b.x))[0];
|
||||||
|
const bottomRight = corners.filter(c => c.x >= centerX && c.y >= centerY)
|
||||||
|
.sort((a, b) => (b.x + b.y) - (a.x + a.y))[0];
|
||||||
|
const bottomLeft = corners.filter(c => c.x < centerX && c.y >= centerY)
|
||||||
|
.sort((a, b) => (b.y - b.x) - (a.y - a.x))[0];
|
||||||
|
|
||||||
|
// Handle edge cases by sorting by angle
|
||||||
|
if (!topLeft || !topRight || !bottomRight || !bottomLeft) {
|
||||||
|
const sorted = [...corners].sort((a, b) =>
|
||||||
|
Math.atan2(a.y - centerY, a.x - centerX) -
|
||||||
|
Math.atan2(b.y - centerY, b.x - centerX)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find index of point with minimum sum (top-left)
|
||||||
|
let minSumIdx = 0;
|
||||||
|
let minSum = Infinity;
|
||||||
|
sorted.forEach((c, i) => {
|
||||||
|
const sum = c.x + c.y;
|
||||||
|
if (sum < minSum) {
|
||||||
|
minSum = sum;
|
||||||
|
minSumIdx = i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...sorted.slice(minSumIdx), ...sorted.slice(0, minSumIdx)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [topLeft, topRight, bottomRight, bottomLeft];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate confidence of the detection.
|
||||||
|
*/
|
||||||
|
function calculateConfidence(corners: Point[], imageArea: number): number {
|
||||||
|
const area = calculateQuadArea(corners);
|
||||||
|
const areaScore = Math.min(area / imageArea / 0.5, 1);
|
||||||
|
const aspectScore = calculateAspectRatioScore(corners);
|
||||||
|
|
||||||
|
return areaScore * 0.4 + aspectScore * 0.6;
|
||||||
|
}
|
||||||
278
lib/recognition/clahe.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
/**
|
||||||
|
* CLAHE (Contrast Limited Adaptive Histogram Equalization) implementation.
|
||||||
|
*
|
||||||
|
* This is a pure TypeScript implementation for when OpenCV is not available.
|
||||||
|
* For better performance, use OpenCV's createCLAHE when available.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface CLAHEOptions {
|
||||||
|
clipLimit?: number;
|
||||||
|
tileGridSize?: { width: number; height: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: Required<CLAHEOptions> = {
|
||||||
|
clipLimit: 2.0,
|
||||||
|
tileGridSize: { width: 8, height: 8 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert RGB to LAB color space.
|
||||||
|
* Returns L channel (0-100 range, scaled to 0-255 for processing).
|
||||||
|
*/
|
||||||
|
function rgbToLab(r: number, g: number, b: number): { l: number; a: number; b: number } {
|
||||||
|
// Normalize RGB to 0-1
|
||||||
|
let rNorm = r / 255;
|
||||||
|
let gNorm = g / 255;
|
||||||
|
let bNorm = b / 255;
|
||||||
|
|
||||||
|
// Apply gamma correction
|
||||||
|
rNorm = rNorm > 0.04045 ? Math.pow((rNorm + 0.055) / 1.055, 2.4) : rNorm / 12.92;
|
||||||
|
gNorm = gNorm > 0.04045 ? Math.pow((gNorm + 0.055) / 1.055, 2.4) : gNorm / 12.92;
|
||||||
|
bNorm = bNorm > 0.04045 ? Math.pow((bNorm + 0.055) / 1.055, 2.4) : bNorm / 12.92;
|
||||||
|
|
||||||
|
// Convert to XYZ
|
||||||
|
const x = (rNorm * 0.4124564 + gNorm * 0.3575761 + bNorm * 0.1804375) / 0.95047;
|
||||||
|
const y = rNorm * 0.2126729 + gNorm * 0.7151522 + bNorm * 0.0721750;
|
||||||
|
const z = (rNorm * 0.0193339 + gNorm * 0.1191920 + bNorm * 0.9503041) / 1.08883;
|
||||||
|
|
||||||
|
// Convert to LAB
|
||||||
|
const xNorm = x > 0.008856 ? Math.pow(x, 1 / 3) : 7.787 * x + 16 / 116;
|
||||||
|
const yNorm = y > 0.008856 ? Math.pow(y, 1 / 3) : 7.787 * y + 16 / 116;
|
||||||
|
const zNorm = z > 0.008856 ? Math.pow(z, 1 / 3) : 7.787 * z + 16 / 116;
|
||||||
|
|
||||||
|
return {
|
||||||
|
l: Math.max(0, 116 * yNorm - 16), // 0-100
|
||||||
|
a: 500 * (xNorm - yNorm),
|
||||||
|
b: 200 * (yNorm - zNorm),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert LAB to RGB color space.
|
||||||
|
*/
|
||||||
|
function labToRgb(l: number, a: number, b: number): { r: number; g: number; b: number } {
|
||||||
|
// Convert LAB to XYZ
|
||||||
|
const yNorm = (l + 16) / 116;
|
||||||
|
const xNorm = a / 500 + yNorm;
|
||||||
|
const zNorm = yNorm - b / 200;
|
||||||
|
|
||||||
|
const x3 = Math.pow(xNorm, 3);
|
||||||
|
const y3 = Math.pow(yNorm, 3);
|
||||||
|
const z3 = Math.pow(zNorm, 3);
|
||||||
|
|
||||||
|
const x = (x3 > 0.008856 ? x3 : (xNorm - 16 / 116) / 7.787) * 0.95047;
|
||||||
|
const y = y3 > 0.008856 ? y3 : (yNorm - 16 / 116) / 7.787;
|
||||||
|
const z = (z3 > 0.008856 ? z3 : (zNorm - 16 / 116) / 7.787) * 1.08883;
|
||||||
|
|
||||||
|
// Convert XYZ to RGB
|
||||||
|
let rNorm = x * 3.2404542 + y * -1.5371385 + z * -0.4985314;
|
||||||
|
let gNorm = x * -0.9692660 + y * 1.8760108 + z * 0.0415560;
|
||||||
|
let bNorm = x * 0.0556434 + y * -0.2040259 + z * 1.0572252;
|
||||||
|
|
||||||
|
// Apply gamma correction
|
||||||
|
rNorm = rNorm > 0.0031308 ? 1.055 * Math.pow(rNorm, 1 / 2.4) - 0.055 : 12.92 * rNorm;
|
||||||
|
gNorm = gNorm > 0.0031308 ? 1.055 * Math.pow(gNorm, 1 / 2.4) - 0.055 : 12.92 * gNorm;
|
||||||
|
bNorm = bNorm > 0.0031308 ? 1.055 * Math.pow(bNorm, 1 / 2.4) - 0.055 : 12.92 * bNorm;
|
||||||
|
|
||||||
|
return {
|
||||||
|
r: Math.round(Math.max(0, Math.min(255, rNorm * 255))),
|
||||||
|
g: Math.round(Math.max(0, Math.min(255, gNorm * 255))),
|
||||||
|
b: Math.round(Math.max(0, Math.min(255, bNorm * 255))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute histogram for a region.
|
||||||
|
*/
|
||||||
|
function computeHistogram(
|
||||||
|
data: Uint8Array,
|
||||||
|
startX: number,
|
||||||
|
startY: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
stride: number
|
||||||
|
): number[] {
|
||||||
|
const histogram = new Array(256).fill(0);
|
||||||
|
|
||||||
|
for (let y = startY; y < startY + height; y++) {
|
||||||
|
for (let x = startX; x < startX + width; x++) {
|
||||||
|
const value = data[y * stride + x];
|
||||||
|
histogram[value]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return histogram;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clip histogram and redistribute excess.
|
||||||
|
*/
|
||||||
|
function clipHistogram(histogram: number[], clipLimit: number, numPixels: number): number[] {
|
||||||
|
const limit = Math.floor(clipLimit * numPixels / 256);
|
||||||
|
const clipped = [...histogram];
|
||||||
|
|
||||||
|
let excess = 0;
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
if (clipped[i] > limit) {
|
||||||
|
excess += clipped[i] - limit;
|
||||||
|
clipped[i] = limit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redistribute excess
|
||||||
|
const increment = Math.floor(excess / 256);
|
||||||
|
const remainder = excess % 256;
|
||||||
|
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
clipped[i] += increment;
|
||||||
|
if (i < remainder) {
|
||||||
|
clipped[i]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create lookup table from clipped histogram.
|
||||||
|
*/
|
||||||
|
function createLUT(histogram: number[], numPixels: number): Uint8Array {
|
||||||
|
const lut = new Uint8Array(256);
|
||||||
|
let cumSum = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
cumSum += histogram[i];
|
||||||
|
lut[i] = Math.round((cumSum / numPixels) * 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lut;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bilinear interpolation between four values.
|
||||||
|
*/
|
||||||
|
function bilinearInterpolate(
|
||||||
|
topLeft: number,
|
||||||
|
topRight: number,
|
||||||
|
bottomLeft: number,
|
||||||
|
bottomRight: number,
|
||||||
|
xFrac: number,
|
||||||
|
yFrac: number
|
||||||
|
): number {
|
||||||
|
const top = topLeft + (topRight - topLeft) * xFrac;
|
||||||
|
const bottom = bottomLeft + (bottomRight - bottomLeft) * xFrac;
|
||||||
|
return Math.round(top + (bottom - top) * yFrac);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply CLAHE to RGBA pixel data.
|
||||||
|
* Processes only the L channel in LAB color space.
|
||||||
|
*
|
||||||
|
* @param pixels RGBA pixel data
|
||||||
|
* @param width Image width
|
||||||
|
* @param height Image height
|
||||||
|
* @param options CLAHE options
|
||||||
|
* @returns Processed RGBA pixel data
|
||||||
|
*/
|
||||||
|
export function applyCLAHE(
|
||||||
|
pixels: Uint8Array | Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
options: CLAHEOptions = {}
|
||||||
|
): Uint8Array {
|
||||||
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||||
|
const { clipLimit, tileGridSize } = opts;
|
||||||
|
|
||||||
|
const tileWidth = Math.floor(width / tileGridSize.width);
|
||||||
|
const tileHeight = Math.floor(height / tileGridSize.height);
|
||||||
|
|
||||||
|
// Extract L channel from LAB
|
||||||
|
const lChannel = new Uint8Array(width * height);
|
||||||
|
const aChannel = new Float32Array(width * height);
|
||||||
|
const bChannel = new Float32Array(width * height);
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const idx = (y * width + x) * 4;
|
||||||
|
const lab = rgbToLab(pixels[idx], pixels[idx + 1], pixels[idx + 2]);
|
||||||
|
const pixelIdx = y * width + x;
|
||||||
|
lChannel[pixelIdx] = Math.round((lab.l / 100) * 255);
|
||||||
|
aChannel[pixelIdx] = lab.a;
|
||||||
|
bChannel[pixelIdx] = lab.b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute LUTs for each tile
|
||||||
|
const luts: Uint8Array[][] = [];
|
||||||
|
const numPixelsPerTile = tileWidth * tileHeight;
|
||||||
|
|
||||||
|
for (let ty = 0; ty < tileGridSize.height; ty++) {
|
||||||
|
luts[ty] = [];
|
||||||
|
for (let tx = 0; tx < tileGridSize.width; tx++) {
|
||||||
|
const startX = tx * tileWidth;
|
||||||
|
const startY = ty * tileHeight;
|
||||||
|
|
||||||
|
const histogram = computeHistogram(
|
||||||
|
lChannel,
|
||||||
|
startX,
|
||||||
|
startY,
|
||||||
|
tileWidth,
|
||||||
|
tileHeight,
|
||||||
|
width
|
||||||
|
);
|
||||||
|
|
||||||
|
const clipped = clipHistogram(histogram, clipLimit, numPixelsPerTile);
|
||||||
|
luts[ty][tx] = createLUT(clipped, numPixelsPerTile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply CLAHE with bilinear interpolation
|
||||||
|
const result = new Uint8Array(pixels.length);
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const pixelIdx = y * width + x;
|
||||||
|
const rgbaIdx = pixelIdx * 4;
|
||||||
|
|
||||||
|
// Determine tile position
|
||||||
|
const tileX = Math.min(x / tileWidth, tileGridSize.width - 1);
|
||||||
|
const tileY = Math.min(y / tileHeight, tileGridSize.height - 1);
|
||||||
|
|
||||||
|
const tx = Math.floor(tileX);
|
||||||
|
const ty = Math.floor(tileY);
|
||||||
|
|
||||||
|
const xFrac = tileX - tx;
|
||||||
|
const yFrac = tileY - ty;
|
||||||
|
|
||||||
|
// Get surrounding tiles (handle edges)
|
||||||
|
const tx1 = Math.min(tx + 1, tileGridSize.width - 1);
|
||||||
|
const ty1 = Math.min(ty + 1, tileGridSize.height - 1);
|
||||||
|
|
||||||
|
const lValue = lChannel[pixelIdx];
|
||||||
|
|
||||||
|
// Interpolate LUT values
|
||||||
|
const newL = bilinearInterpolate(
|
||||||
|
luts[ty][tx][lValue],
|
||||||
|
luts[ty][tx1][lValue],
|
||||||
|
luts[ty1][tx][lValue],
|
||||||
|
luts[ty1][tx1][lValue],
|
||||||
|
xFrac,
|
||||||
|
yFrac
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert back to RGB
|
||||||
|
const rgb = labToRgb(
|
||||||
|
(newL / 255) * 100,
|
||||||
|
aChannel[pixelIdx],
|
||||||
|
bChannel[pixelIdx]
|
||||||
|
);
|
||||||
|
|
||||||
|
result[rgbaIdx] = rgb.r;
|
||||||
|
result[rgbaIdx + 1] = rgb.g;
|
||||||
|
result[rgbaIdx + 2] = rgb.b;
|
||||||
|
result[rgbaIdx + 3] = pixels[rgbaIdx + 3]; // Preserve alpha
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
95
lib/recognition/imageLoader.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
/**
|
||||||
|
* Image loader utilities for React Native.
|
||||||
|
* Loads images from file paths and returns pixel data for recognition.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as ImageManipulator from "expo-image-manipulator";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
export interface LoadedImage {
|
||||||
|
pixels: Uint8Array;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load image from a file path and return RGBA pixel data.
|
||||||
|
* Uses expo-image-manipulator to resize the image to a manageable size first.
|
||||||
|
*
|
||||||
|
* Note: expo-image-manipulator doesn't directly provide pixel data.
|
||||||
|
* We need to use a canvas (web) or Skia (native) to decode pixels.
|
||||||
|
* For now, this provides the resized image URI for the Skia-based decoder.
|
||||||
|
*/
|
||||||
|
export async function loadImageForRecognition(
|
||||||
|
uri: string,
|
||||||
|
targetWidth: number = 480, // Reasonable size for processing
|
||||||
|
targetHeight: number = 640
|
||||||
|
): Promise<{ uri: string; width: number; height: number }> {
|
||||||
|
// Normalize the URI for the platform
|
||||||
|
const normalizedUri =
|
||||||
|
Platform.OS === "android" && !uri.startsWith("file://")
|
||||||
|
? `file://${uri}`
|
||||||
|
: uri;
|
||||||
|
|
||||||
|
// Resize the image for faster processing
|
||||||
|
const result = await ImageManipulator.manipulateAsync(
|
||||||
|
normalizedUri,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
resize: {
|
||||||
|
width: targetWidth,
|
||||||
|
height: targetHeight,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
compress: 1,
|
||||||
|
format: ImageManipulator.SaveFormat.PNG,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
uri: result.uri,
|
||||||
|
width: result.width,
|
||||||
|
height: result.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load image and get base64 data.
|
||||||
|
* Useful for passing to native modules or Skia.
|
||||||
|
*/
|
||||||
|
export async function loadImageAsBase64(
|
||||||
|
uri: string,
|
||||||
|
targetWidth: number = 480,
|
||||||
|
targetHeight: number = 640
|
||||||
|
): Promise<{ base64: string; width: number; height: number }> {
|
||||||
|
// Normalize the URI
|
||||||
|
const normalizedUri =
|
||||||
|
Platform.OS === "android" && !uri.startsWith("file://")
|
||||||
|
? `file://${uri}`
|
||||||
|
: uri;
|
||||||
|
|
||||||
|
const result = await ImageManipulator.manipulateAsync(
|
||||||
|
normalizedUri,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
resize: {
|
||||||
|
width: targetWidth,
|
||||||
|
height: targetHeight,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
compress: 1,
|
||||||
|
format: ImageManipulator.SaveFormat.PNG,
|
||||||
|
base64: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
base64: result.base64 || "",
|
||||||
|
width: result.width,
|
||||||
|
height: result.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
292
lib/recognition/imageUtils.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
/**
|
||||||
|
* Image utility functions for the recognition pipeline.
|
||||||
|
* Provides resize, rotation, and pixel manipulation helpers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Point {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Size {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize RGBA pixel data to target dimensions using bilinear interpolation.
|
||||||
|
*/
|
||||||
|
export function resizeImage(
|
||||||
|
pixels: Uint8Array | Uint8ClampedArray,
|
||||||
|
srcWidth: number,
|
||||||
|
srcHeight: number,
|
||||||
|
dstWidth: number,
|
||||||
|
dstHeight: number
|
||||||
|
): Uint8Array {
|
||||||
|
const result = new Uint8Array(dstWidth * dstHeight * 4);
|
||||||
|
|
||||||
|
const xRatio = srcWidth / dstWidth;
|
||||||
|
const yRatio = srcHeight / dstHeight;
|
||||||
|
|
||||||
|
for (let y = 0; y < dstHeight; y++) {
|
||||||
|
for (let x = 0; x < dstWidth; x++) {
|
||||||
|
const srcX = x * xRatio;
|
||||||
|
const srcY = y * yRatio;
|
||||||
|
|
||||||
|
const x0 = Math.floor(srcX);
|
||||||
|
const y0 = Math.floor(srcY);
|
||||||
|
const x1 = Math.min(x0 + 1, srcWidth - 1);
|
||||||
|
const y1 = Math.min(y0 + 1, srcHeight - 1);
|
||||||
|
|
||||||
|
const xFrac = srcX - x0;
|
||||||
|
const yFrac = srcY - y0;
|
||||||
|
|
||||||
|
const dstIdx = (y * dstWidth + x) * 4;
|
||||||
|
|
||||||
|
for (let c = 0; c < 4; c++) {
|
||||||
|
const idx00 = (y0 * srcWidth + x0) * 4 + c;
|
||||||
|
const idx10 = (y0 * srcWidth + x1) * 4 + c;
|
||||||
|
const idx01 = (y1 * srcWidth + x0) * 4 + c;
|
||||||
|
const idx11 = (y1 * srcWidth + x1) * 4 + c;
|
||||||
|
|
||||||
|
const top = pixels[idx00] + (pixels[idx10] - pixels[idx00]) * xFrac;
|
||||||
|
const bottom = pixels[idx01] + (pixels[idx11] - pixels[idx01]) * xFrac;
|
||||||
|
result[dstIdx + c] = Math.round(top + (bottom - top) * yFrac);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate RGBA pixel data by 90, 180, or 270 degrees.
|
||||||
|
*/
|
||||||
|
export function rotateImage(
|
||||||
|
pixels: Uint8Array | Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
degrees: 0 | 90 | 180 | 270
|
||||||
|
): { pixels: Uint8Array; width: number; height: number } {
|
||||||
|
if (degrees === 0) {
|
||||||
|
return { pixels: new Uint8Array(pixels), width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newWidth, newHeight] = degrees === 90 || degrees === 270
|
||||||
|
? [height, width]
|
||||||
|
: [width, height];
|
||||||
|
|
||||||
|
const result = new Uint8Array(newWidth * newHeight * 4);
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const srcIdx = (y * width + x) * 4;
|
||||||
|
|
||||||
|
let dstX: number, dstY: number;
|
||||||
|
|
||||||
|
switch (degrees) {
|
||||||
|
case 90:
|
||||||
|
dstX = height - 1 - y;
|
||||||
|
dstY = x;
|
||||||
|
break;
|
||||||
|
case 180:
|
||||||
|
dstX = width - 1 - x;
|
||||||
|
dstY = height - 1 - y;
|
||||||
|
break;
|
||||||
|
case 270:
|
||||||
|
dstX = y;
|
||||||
|
dstY = width - 1 - x;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
dstX = x;
|
||||||
|
dstY = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dstIdx = (dstY * newWidth + dstX) * 4;
|
||||||
|
result[dstIdx] = pixels[srcIdx];
|
||||||
|
result[dstIdx + 1] = pixels[srcIdx + 1];
|
||||||
|
result[dstIdx + 2] = pixels[srcIdx + 2];
|
||||||
|
result[dstIdx + 3] = pixels[srcIdx + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pixels: result, width: newWidth, height: newHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert RGBA to grayscale using luminance formula.
|
||||||
|
*/
|
||||||
|
export function toGrayscale(pixels: Uint8Array | Uint8ClampedArray): Uint8Array {
|
||||||
|
const numPixels = pixels.length / 4;
|
||||||
|
const gray = new Uint8Array(numPixels);
|
||||||
|
|
||||||
|
for (let i = 0; i < numPixels; i++) {
|
||||||
|
const idx = i * 4;
|
||||||
|
gray[i] = Math.round(
|
||||||
|
0.299 * pixels[idx] +
|
||||||
|
0.587 * pixels[idx + 1] +
|
||||||
|
0.114 * pixels[idx + 2]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert grayscale back to RGBA.
|
||||||
|
*/
|
||||||
|
export function grayscaleToRgba(
|
||||||
|
gray: Uint8Array,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): Uint8Array {
|
||||||
|
const rgba = new Uint8Array(width * height * 4);
|
||||||
|
|
||||||
|
for (let i = 0; i < gray.length; i++) {
|
||||||
|
const idx = i * 4;
|
||||||
|
rgba[idx] = gray[i];
|
||||||
|
rgba[idx + 1] = gray[i];
|
||||||
|
rgba[idx + 2] = gray[i];
|
||||||
|
rgba[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rgba;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply Gaussian blur to grayscale image.
|
||||||
|
* Uses separable 1D convolution for efficiency.
|
||||||
|
*/
|
||||||
|
export function gaussianBlur(
|
||||||
|
gray: Uint8Array,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
radius: number = 5
|
||||||
|
): Uint8Array {
|
||||||
|
// Generate Gaussian kernel
|
||||||
|
const sigma = radius / 2;
|
||||||
|
const size = radius * 2 + 1;
|
||||||
|
const kernel = new Float32Array(size);
|
||||||
|
let sum = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < size; i++) {
|
||||||
|
const x = i - radius;
|
||||||
|
kernel[i] = Math.exp(-(x * x) / (2 * sigma * sigma));
|
||||||
|
sum += kernel[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize
|
||||||
|
for (let i = 0; i < size; i++) {
|
||||||
|
kernel[i] /= sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal pass
|
||||||
|
const temp = new Uint8Array(width * height);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
let value = 0;
|
||||||
|
for (let k = -radius; k <= radius; k++) {
|
||||||
|
const sx = Math.max(0, Math.min(width - 1, x + k));
|
||||||
|
value += gray[y * width + sx] * kernel[k + radius];
|
||||||
|
}
|
||||||
|
temp[y * width + x] = Math.round(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical pass
|
||||||
|
const result = new Uint8Array(width * height);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
let value = 0;
|
||||||
|
for (let k = -radius; k <= radius; k++) {
|
||||||
|
const sy = Math.max(0, Math.min(height - 1, y + k));
|
||||||
|
value += temp[sy * width + x] * kernel[k + radius];
|
||||||
|
}
|
||||||
|
result[y * width + x] = Math.round(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate distance between two points.
|
||||||
|
*/
|
||||||
|
export function distance(a: Point, b: Point): number {
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dy = b.y - a.y;
|
||||||
|
return Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate cross product of vectors (b-a) and (c-b).
|
||||||
|
*/
|
||||||
|
export function crossProduct(a: Point, b: Point, c: Point): number {
|
||||||
|
return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate point-to-line distance.
|
||||||
|
*/
|
||||||
|
export function pointToLineDistance(point: Point, lineStart: Point, lineEnd: Point): number {
|
||||||
|
const dx = lineEnd.x - lineStart.x;
|
||||||
|
const dy = lineEnd.y - lineStart.y;
|
||||||
|
const lengthSquared = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
if (lengthSquared === 0) {
|
||||||
|
return distance(point, lineStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
let t = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) / lengthSquared;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
|
||||||
|
const projection = {
|
||||||
|
x: lineStart.x + t * dx,
|
||||||
|
y: lineStart.y + t * dy,
|
||||||
|
};
|
||||||
|
|
||||||
|
return distance(point, projection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample a pixel from RGBA data using bilinear interpolation.
|
||||||
|
*/
|
||||||
|
export function sampleBilinear(
|
||||||
|
pixels: Uint8Array | Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
x: number,
|
||||||
|
y: number
|
||||||
|
): [number, number, number, number] {
|
||||||
|
// Clamp to valid range
|
||||||
|
x = Math.max(0, Math.min(width - 1, x));
|
||||||
|
y = Math.max(0, Math.min(height - 1, y));
|
||||||
|
|
||||||
|
const x0 = Math.floor(x);
|
||||||
|
const y0 = Math.floor(y);
|
||||||
|
const x1 = Math.min(x0 + 1, width - 1);
|
||||||
|
const y1 = Math.min(y0 + 1, height - 1);
|
||||||
|
|
||||||
|
const xFrac = x - x0;
|
||||||
|
const yFrac = y - y0;
|
||||||
|
|
||||||
|
const idx00 = (y0 * width + x0) * 4;
|
||||||
|
const idx10 = (y0 * width + x1) * 4;
|
||||||
|
const idx01 = (y1 * width + x0) * 4;
|
||||||
|
const idx11 = (y1 * width + x1) * 4;
|
||||||
|
|
||||||
|
const result: [number, number, number, number] = [0, 0, 0, 0];
|
||||||
|
|
||||||
|
for (let c = 0; c < 4; c++) {
|
||||||
|
const c00 = pixels[idx00 + c];
|
||||||
|
const c10 = pixels[idx10 + c];
|
||||||
|
const c01 = pixels[idx01 + c];
|
||||||
|
const c11 = pixels[idx11 + c];
|
||||||
|
|
||||||
|
const top = c00 + (c10 - c00) * xFrac;
|
||||||
|
const bottom = c01 + (c11 - c01) * xFrac;
|
||||||
|
result[c] = Math.round(top + (bottom - top) * yFrac);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
67
lib/recognition/index.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* Card recognition module exports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Recognition service
|
||||||
|
export {
|
||||||
|
recognizeCard,
|
||||||
|
computeImageHash,
|
||||||
|
calculateConfidence,
|
||||||
|
createRecognitionService,
|
||||||
|
type RecognitionOptions,
|
||||||
|
type RecognitionResult,
|
||||||
|
type CardMatch,
|
||||||
|
type CardHashEntry,
|
||||||
|
} from "./recognitionService";
|
||||||
|
|
||||||
|
// Card detection
|
||||||
|
export {
|
||||||
|
detectCard,
|
||||||
|
type CardDetectionResult,
|
||||||
|
} from "./cardDetector";
|
||||||
|
|
||||||
|
// Perspective correction
|
||||||
|
export {
|
||||||
|
warpPerspective,
|
||||||
|
OUTPUT_WIDTH,
|
||||||
|
OUTPUT_HEIGHT,
|
||||||
|
} from "./perspectiveCorrection";
|
||||||
|
|
||||||
|
// CLAHE preprocessing
|
||||||
|
export { applyCLAHE } from "./clahe";
|
||||||
|
|
||||||
|
// Perceptual hashing
|
||||||
|
export {
|
||||||
|
computeColorHash,
|
||||||
|
hammingDistance,
|
||||||
|
hashToHex,
|
||||||
|
hexToHash,
|
||||||
|
HASH_VERSION,
|
||||||
|
MATCH_THRESHOLD,
|
||||||
|
HASH_BITS,
|
||||||
|
} from "./perceptualHash";
|
||||||
|
|
||||||
|
// Image utilities
|
||||||
|
export {
|
||||||
|
resizeImage,
|
||||||
|
rotateImage,
|
||||||
|
toGrayscale,
|
||||||
|
grayscaleToRgba,
|
||||||
|
gaussianBlur,
|
||||||
|
distance,
|
||||||
|
type Point,
|
||||||
|
type Size,
|
||||||
|
} from "./imageUtils";
|
||||||
|
|
||||||
|
// Image loading
|
||||||
|
export {
|
||||||
|
loadImageForRecognition,
|
||||||
|
loadImageAsBase64,
|
||||||
|
} from "./imageLoader";
|
||||||
|
|
||||||
|
// Skia image decoding
|
||||||
|
export {
|
||||||
|
decodeImageBase64,
|
||||||
|
decodeImageFromUri,
|
||||||
|
useDecodedImage,
|
||||||
|
} from "./skiaDecoder";
|
||||||
211
lib/recognition/perceptualHash.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
/**
|
||||||
|
* Perceptual hashing implementation using DCT (Discrete Cosine Transform).
|
||||||
|
* Computes a 192-bit (24 byte) color hash from an image.
|
||||||
|
*
|
||||||
|
* The hash is computed by:
|
||||||
|
* 1. Resizing to 32x32
|
||||||
|
* 2. For each RGB channel:
|
||||||
|
* - Apply 2D DCT
|
||||||
|
* - Extract 8x8 low-frequency coefficients (skip DC)
|
||||||
|
* - Compare each to median -> 63 bits per channel
|
||||||
|
* 3. Concatenate R, G, B hashes -> 24 bytes (192 bits)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DCT_SIZE = 32;
|
||||||
|
const HASH_SIZE = 8;
|
||||||
|
const BITS_PER_CHANNEL = 63; // 8x8 - 1 (skip DC)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Precomputed cosine values for DCT.
|
||||||
|
*/
|
||||||
|
const cosineCache: number[][] = [];
|
||||||
|
|
||||||
|
function initCosineCache(): void {
|
||||||
|
if (cosineCache.length > 0) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < DCT_SIZE; i++) {
|
||||||
|
cosineCache[i] = [];
|
||||||
|
for (let j = 0; j < DCT_SIZE; j++) {
|
||||||
|
cosineCache[i][j] = Math.cos((Math.PI / DCT_SIZE) * (j + 0.5) * i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply 2D DCT to a matrix.
|
||||||
|
*/
|
||||||
|
function applyDCT2D(matrix: number[][]): number[][] {
|
||||||
|
initCosineCache();
|
||||||
|
|
||||||
|
const result: number[][] = [];
|
||||||
|
|
||||||
|
for (let u = 0; u < DCT_SIZE; u++) {
|
||||||
|
result[u] = [];
|
||||||
|
for (let v = 0; v < DCT_SIZE; v++) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < DCT_SIZE; i++) {
|
||||||
|
for (let j = 0; j < DCT_SIZE; j++) {
|
||||||
|
sum += matrix[i][j] * cosineCache[u][i] * cosineCache[v][j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cu = u === 0 ? 1 / Math.sqrt(2) : 1;
|
||||||
|
const cv = v === 0 ? 1 / Math.sqrt(2) : 1;
|
||||||
|
result[u][v] = (cu * cv * sum) / 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the median of an array of numbers.
|
||||||
|
*/
|
||||||
|
function getMedian(values: number[]): number {
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const mid = Math.floor(sorted.length / 2);
|
||||||
|
return sorted.length % 2 !== 0
|
||||||
|
? sorted[mid]
|
||||||
|
: (sorted[mid - 1] + sorted[mid]) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a BigInt to a Uint8Array of specified length.
|
||||||
|
*/
|
||||||
|
function bigintToBytes(value: bigint, length: number): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(length);
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
bytes[i] = Number((value >> BigInt(i * 8)) & 0xFFn);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute hash for a single color channel.
|
||||||
|
*/
|
||||||
|
function computeChannelHash(channel: number[][]): Uint8Array {
|
||||||
|
const dct = applyDCT2D(channel);
|
||||||
|
|
||||||
|
// Extract 8x8 low-frequency coefficients, skip DC (0,0)
|
||||||
|
const lowFreq: number[] = [];
|
||||||
|
for (let i = 0; i < HASH_SIZE; i++) {
|
||||||
|
for (let j = 0; j < HASH_SIZE; j++) {
|
||||||
|
if (i === 0 && j === 0) continue; // Skip DC component
|
||||||
|
lowFreq.push(dct[i][j]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const median = getMedian(lowFreq);
|
||||||
|
|
||||||
|
// Generate 63-bit hash
|
||||||
|
let bits = 0n;
|
||||||
|
for (let i = 0; i < lowFreq.length; i++) {
|
||||||
|
if (lowFreq[i] > median) {
|
||||||
|
bits |= 1n << BigInt(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bigintToBytes(bits, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a color channel from RGBA pixel data.
|
||||||
|
* @param pixels RGBA pixel data (width * height * 4)
|
||||||
|
* @param width Image width
|
||||||
|
* @param height Image height
|
||||||
|
* @param channel 0=R, 1=G, 2=B
|
||||||
|
*/
|
||||||
|
function extractChannel(
|
||||||
|
pixels: Uint8Array | Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
channel: 0 | 1 | 2
|
||||||
|
): number[][] {
|
||||||
|
const matrix: number[][] = [];
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
matrix[y] = [];
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const idx = (y * width + x) * 4;
|
||||||
|
matrix[y][x] = pixels[idx + channel];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a 192-bit perceptual color hash from RGBA pixel data.
|
||||||
|
* The image should already be resized to 32x32.
|
||||||
|
*
|
||||||
|
* @param pixels RGBA pixel data (32 * 32 * 4 = 4096 bytes)
|
||||||
|
* @returns 24-byte hash (8 bytes per RGB channel)
|
||||||
|
*/
|
||||||
|
export function computeColorHash(pixels: Uint8Array | Uint8ClampedArray): Uint8Array {
|
||||||
|
if (pixels.length !== DCT_SIZE * DCT_SIZE * 4) {
|
||||||
|
throw new Error(`Expected ${DCT_SIZE * DCT_SIZE * 4} bytes, got ${pixels.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rChannel = extractChannel(pixels, DCT_SIZE, DCT_SIZE, 0);
|
||||||
|
const gChannel = extractChannel(pixels, DCT_SIZE, DCT_SIZE, 1);
|
||||||
|
const bChannel = extractChannel(pixels, DCT_SIZE, DCT_SIZE, 2);
|
||||||
|
|
||||||
|
const rHash = computeChannelHash(rChannel);
|
||||||
|
const gHash = computeChannelHash(gChannel);
|
||||||
|
const bHash = computeChannelHash(bChannel);
|
||||||
|
|
||||||
|
// Combine all channels
|
||||||
|
const combined = new Uint8Array(24);
|
||||||
|
combined.set(rHash, 0);
|
||||||
|
combined.set(gHash, 8);
|
||||||
|
combined.set(bHash, 16);
|
||||||
|
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute Hamming distance between two hashes.
|
||||||
|
* Lower distance = more similar.
|
||||||
|
*/
|
||||||
|
export function hammingDistance(a: Uint8Array, b: Uint8Array): number {
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
throw new Error(`Hash length mismatch: ${a.length} vs ${b.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let distance = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
let xor = a[i] ^ b[i];
|
||||||
|
while (xor) {
|
||||||
|
distance += xor & 1;
|
||||||
|
xor >>>= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert hash to hex string for display/storage.
|
||||||
|
*/
|
||||||
|
export function hashToHex(hash: Uint8Array): string {
|
||||||
|
return Array.from(hash)
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert hex string back to hash.
|
||||||
|
*/
|
||||||
|
export function hexToHash(hex: string): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash algorithm version for migrations
|
||||||
|
export const HASH_VERSION = 1;
|
||||||
|
|
||||||
|
// Matching thresholds
|
||||||
|
export const MATCH_THRESHOLD = 25; // Max Hamming distance for a match
|
||||||
|
export const HASH_BITS = 192; // Total bits in hash
|
||||||
207
lib/recognition/perspectiveCorrection.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
/**
|
||||||
|
* Perspective correction to transform a quadrilateral region into a rectangle.
|
||||||
|
* Uses homography transformation with bilinear sampling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Point, sampleBilinear } from "./imageUtils";
|
||||||
|
|
||||||
|
/** Standard output size for corrected card images (63:88 aspect ratio). */
|
||||||
|
export const OUTPUT_WIDTH = 480;
|
||||||
|
export const OUTPUT_HEIGHT = 670;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply perspective correction to extract and normalize a card from an image.
|
||||||
|
*
|
||||||
|
* @param pixels - Source RGBA pixel data
|
||||||
|
* @param width - Source image width
|
||||||
|
* @param height - Source image height
|
||||||
|
* @param corners - Four corners in order: top-left, top-right, bottom-right, bottom-left
|
||||||
|
* @param outputWidth - Width of output image
|
||||||
|
* @param outputHeight - Height of output image
|
||||||
|
* @returns Perspective-corrected RGBA pixel data
|
||||||
|
*/
|
||||||
|
export function warpPerspective(
|
||||||
|
pixels: Uint8Array | Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
corners: Point[],
|
||||||
|
outputWidth: number = OUTPUT_WIDTH,
|
||||||
|
outputHeight: number = OUTPUT_HEIGHT
|
||||||
|
): { pixels: Uint8Array; width: number; height: number } {
|
||||||
|
if (corners.length !== 4) {
|
||||||
|
throw new Error("Exactly 4 corners required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if card is landscape (rotated 90°)
|
||||||
|
const width1 = distance(corners[0], corners[1]);
|
||||||
|
const height1 = distance(corners[1], corners[2]);
|
||||||
|
|
||||||
|
let orderedCorners: Point[];
|
||||||
|
|
||||||
|
if (width1 > height1) {
|
||||||
|
// Card is landscape - rotate corners to portrait
|
||||||
|
orderedCorners = [corners[1], corners[2], corners[3], corners[0]];
|
||||||
|
} else {
|
||||||
|
orderedCorners = corners;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the perspective transform matrix
|
||||||
|
const matrix = computePerspectiveTransform(orderedCorners, outputWidth, outputHeight);
|
||||||
|
const inverse = invertMatrix3x3(matrix);
|
||||||
|
|
||||||
|
// Apply the transform
|
||||||
|
const result = new Uint8Array(outputWidth * outputHeight * 4);
|
||||||
|
|
||||||
|
for (let y = 0; y < outputHeight; y++) {
|
||||||
|
for (let x = 0; x < outputWidth; x++) {
|
||||||
|
// Apply inverse transform to find source coordinates
|
||||||
|
const srcPoint = applyTransform(inverse, x, y);
|
||||||
|
|
||||||
|
// Bilinear interpolation for smooth sampling
|
||||||
|
const [r, g, b, a] = sampleBilinear(pixels, width, height, srcPoint.x, srcPoint.y);
|
||||||
|
|
||||||
|
const idx = (y * outputWidth + x) * 4;
|
||||||
|
result[idx] = r;
|
||||||
|
result[idx + 1] = g;
|
||||||
|
result[idx + 2] = b;
|
||||||
|
result[idx + 3] = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pixels: result, width: outputWidth, height: outputHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a perspective transform matrix from quad corners to rectangle.
|
||||||
|
* Uses the Direct Linear Transform (DLT) algorithm.
|
||||||
|
*/
|
||||||
|
function computePerspectiveTransform(
|
||||||
|
src: Point[],
|
||||||
|
dstWidth: number,
|
||||||
|
dstHeight: number
|
||||||
|
): number[] {
|
||||||
|
// Destination corners (rectangle)
|
||||||
|
const dst: Point[] = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: dstWidth - 1, y: 0 },
|
||||||
|
{ x: dstWidth - 1, y: dstHeight - 1 },
|
||||||
|
{ x: 0, y: dstHeight - 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build the 8x8 matrix for solving the homography
|
||||||
|
const A: number[][] = [];
|
||||||
|
const b: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const sx = src[i].x;
|
||||||
|
const sy = src[i].y;
|
||||||
|
const dx = dst[i].x;
|
||||||
|
const dy = dst[i].y;
|
||||||
|
|
||||||
|
A.push([sx, sy, 1, 0, 0, 0, -dx * sx, -dx * sy]);
|
||||||
|
b.push(dx);
|
||||||
|
|
||||||
|
A.push([0, 0, 0, sx, sy, 1, -dy * sx, -dy * sy]);
|
||||||
|
b.push(dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solve using Gaussian elimination
|
||||||
|
const h = solveLinearSystem(A, b);
|
||||||
|
|
||||||
|
// Return 3x3 matrix as flat array [h11, h12, h13, h21, h22, h23, h31, h32, h33]
|
||||||
|
return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solve a linear system Ax = b using Gaussian elimination with partial pivoting.
|
||||||
|
*/
|
||||||
|
function solveLinearSystem(A: number[][], b: number[]): number[] {
|
||||||
|
const n = b.length;
|
||||||
|
|
||||||
|
// Create augmented matrix
|
||||||
|
const augmented: number[][] = A.map((row, i) => [...row, b[i]]);
|
||||||
|
|
||||||
|
// Forward elimination with partial pivoting
|
||||||
|
for (let col = 0; col < n; col++) {
|
||||||
|
// Find pivot
|
||||||
|
let maxRow = col;
|
||||||
|
for (let row = col + 1; row < n; row++) {
|
||||||
|
if (Math.abs(augmented[row][col]) > Math.abs(augmented[maxRow][col])) {
|
||||||
|
maxRow = row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap rows
|
||||||
|
[augmented[col], augmented[maxRow]] = [augmented[maxRow], augmented[col]];
|
||||||
|
|
||||||
|
// Eliminate
|
||||||
|
for (let row = col + 1; row < n; row++) {
|
||||||
|
if (Math.abs(augmented[col][col]) < 1e-10) continue;
|
||||||
|
|
||||||
|
const factor = augmented[row][col] / augmented[col][col];
|
||||||
|
for (let j = col; j <= n; j++) {
|
||||||
|
augmented[row][j] -= factor * augmented[col][j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back substitution
|
||||||
|
const x = new Array(n).fill(0);
|
||||||
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
|
x[i] = augmented[i][n];
|
||||||
|
for (let j = i + 1; j < n; j++) {
|
||||||
|
x[i] -= augmented[i][j] * x[j];
|
||||||
|
}
|
||||||
|
if (Math.abs(augmented[i][i]) > 1e-10) {
|
||||||
|
x[i] /= augmented[i][i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a 3x3 transform matrix to a point.
|
||||||
|
*/
|
||||||
|
function applyTransform(H: number[], x: number, y: number): Point {
|
||||||
|
let w = H[6] * x + H[7] * y + H[8];
|
||||||
|
if (Math.abs(w) < 1e-10) w = 1e-10;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: (H[0] * x + H[1] * y + H[2]) / w,
|
||||||
|
y: (H[3] * x + H[4] * y + H[5]) / w,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invert a 3x3 matrix.
|
||||||
|
*/
|
||||||
|
function invertMatrix3x3(m: number[]): number[] {
|
||||||
|
const det =
|
||||||
|
m[0] * (m[4] * m[8] - m[5] * m[7]) -
|
||||||
|
m[1] * (m[3] * m[8] - m[5] * m[6]) +
|
||||||
|
m[2] * (m[3] * m[7] - m[4] * m[6]);
|
||||||
|
|
||||||
|
const invDet = Math.abs(det) < 1e-10 ? 1e10 : 1 / det;
|
||||||
|
|
||||||
|
return [
|
||||||
|
(m[4] * m[8] - m[5] * m[7]) * invDet,
|
||||||
|
(m[2] * m[7] - m[1] * m[8]) * invDet,
|
||||||
|
(m[1] * m[5] - m[2] * m[4]) * invDet,
|
||||||
|
(m[5] * m[6] - m[3] * m[8]) * invDet,
|
||||||
|
(m[0] * m[8] - m[2] * m[6]) * invDet,
|
||||||
|
(m[2] * m[3] - m[0] * m[5]) * invDet,
|
||||||
|
(m[3] * m[7] - m[4] * m[6]) * invDet,
|
||||||
|
(m[1] * m[6] - m[0] * m[7]) * invDet,
|
||||||
|
(m[0] * m[4] - m[1] * m[3]) * invDet,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate distance between two points.
|
||||||
|
*/
|
||||||
|
function distance(a: Point, b: Point): number {
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dy = b.y - a.y;
|
||||||
|
return Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
313
lib/recognition/recognitionService.ts
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
/**
|
||||||
|
* Card recognition service that orchestrates the full pipeline.
|
||||||
|
*
|
||||||
|
* Pipeline:
|
||||||
|
* 1. Card detection (optional) - find card boundaries
|
||||||
|
* 2. Perspective correction - warp to rectangle
|
||||||
|
* 3. CLAHE preprocessing - normalize lighting
|
||||||
|
* 4. Resize to 32x32
|
||||||
|
* 5. Compute perceptual hash
|
||||||
|
* 6. Match against database via Hamming distance
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { detectCard, CardDetectionResult } from "./cardDetector";
|
||||||
|
import { warpPerspective } from "./perspectiveCorrection";
|
||||||
|
import { applyCLAHE } from "./clahe";
|
||||||
|
import {
|
||||||
|
computeColorHash,
|
||||||
|
hammingDistance,
|
||||||
|
MATCH_THRESHOLD,
|
||||||
|
HASH_BITS,
|
||||||
|
} from "./perceptualHash";
|
||||||
|
import { resizeImage, rotateImage } from "./imageUtils";
|
||||||
|
|
||||||
|
export interface RecognitionOptions {
|
||||||
|
/** Enable card detection and perspective correction. */
|
||||||
|
enableCardDetection?: boolean;
|
||||||
|
/** Enable rotation matching (try 0°, 90°, 180°, 270°). */
|
||||||
|
enableRotationMatching?: boolean;
|
||||||
|
/** Minimum confidence to accept a match (0-1). */
|
||||||
|
minConfidence?: number;
|
||||||
|
/** Maximum Hamming distance to accept a match. */
|
||||||
|
matchThreshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardMatch {
|
||||||
|
/** Card ID from database. */
|
||||||
|
cardId: string;
|
||||||
|
/** Match confidence (0-1). */
|
||||||
|
confidence: number;
|
||||||
|
/** Hamming distance between hashes. */
|
||||||
|
distance: number;
|
||||||
|
/** Rotation used for match (0, 90, 180, or 270). */
|
||||||
|
rotation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecognitionResult {
|
||||||
|
success: boolean;
|
||||||
|
match?: CardMatch;
|
||||||
|
cardDetection?: CardDetectionResult;
|
||||||
|
error?: string;
|
||||||
|
processingTimeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardHashEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
setCode: string;
|
||||||
|
collectorNumber?: string;
|
||||||
|
imageUri?: string;
|
||||||
|
hash: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: Required<RecognitionOptions> = {
|
||||||
|
enableCardDetection: true,
|
||||||
|
enableRotationMatching: true,
|
||||||
|
minConfidence: 0.85,
|
||||||
|
matchThreshold: MATCH_THRESHOLD,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate confidence from Hamming distance.
|
||||||
|
*/
|
||||||
|
export function calculateConfidence(distance: number, totalBits: number = HASH_BITS): number {
|
||||||
|
return 1 - distance / totalBits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recognize a card from RGBA pixel data.
|
||||||
|
*
|
||||||
|
* @param pixels - RGBA pixel data
|
||||||
|
* @param width - Image width
|
||||||
|
* @param height - Image height
|
||||||
|
* @param cardHashes - Array of card hashes to match against
|
||||||
|
* @param options - Recognition options
|
||||||
|
* @returns Recognition result with best match
|
||||||
|
*/
|
||||||
|
export function recognizeCard(
|
||||||
|
pixels: Uint8Array | Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
cardHashes: CardHashEntry[],
|
||||||
|
options: RecognitionOptions = {}
|
||||||
|
): RecognitionResult {
|
||||||
|
const startTime = performance.now();
|
||||||
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (cardHashes.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "No cards in database",
|
||||||
|
processingTimeMs: performance.now() - startTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let cardPixels = pixels;
|
||||||
|
let cardWidth = width;
|
||||||
|
let cardHeight = height;
|
||||||
|
let detection: CardDetectionResult | undefined;
|
||||||
|
|
||||||
|
// Step 1: Detect and extract card (if enabled)
|
||||||
|
if (opts.enableCardDetection) {
|
||||||
|
detection = detectCard(pixels, width, height);
|
||||||
|
|
||||||
|
if (detection.found) {
|
||||||
|
const warped = warpPerspective(
|
||||||
|
pixels,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
detection.corners
|
||||||
|
);
|
||||||
|
cardPixels = warped.pixels;
|
||||||
|
cardWidth = warped.width;
|
||||||
|
cardHeight = warped.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Find best match (with or without rotation)
|
||||||
|
const match = opts.enableRotationMatching
|
||||||
|
? findBestMatchWithRotations(cardPixels, cardWidth, cardHeight, cardHashes, opts)
|
||||||
|
: findBestMatchSingle(cardPixels, cardWidth, cardHeight, cardHashes, opts);
|
||||||
|
|
||||||
|
const processingTimeMs = performance.now() - startTime;
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
cardDetection: detection,
|
||||||
|
error: "No match found",
|
||||||
|
processingTimeMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
match,
|
||||||
|
cardDetection: detection,
|
||||||
|
processingTimeMs,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
processingTimeMs: performance.now() - startTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute hash for an image with full preprocessing pipeline.
|
||||||
|
*/
|
||||||
|
export function computeImageHash(
|
||||||
|
pixels: Uint8Array | Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): Uint8Array {
|
||||||
|
// Apply CLAHE
|
||||||
|
const clahePixels = applyCLAHE(pixels, width, height);
|
||||||
|
|
||||||
|
// Resize to 32x32
|
||||||
|
const resized = resizeImage(clahePixels, width, height, 32, 32);
|
||||||
|
|
||||||
|
// Compute hash
|
||||||
|
return computeColorHash(resized);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find best match trying all 4 rotations.
|
||||||
|
*/
|
||||||
|
function findBestMatchWithRotations(
|
||||||
|
pixels: Uint8Array | Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
cardHashes: CardHashEntry[],
|
||||||
|
opts: Required<RecognitionOptions>
|
||||||
|
): CardMatch | null {
|
||||||
|
let bestMatch: CardMatch | null = null;
|
||||||
|
|
||||||
|
const rotations: Array<0 | 90 | 180 | 270> = [0, 90, 180, 270];
|
||||||
|
|
||||||
|
for (const rotation of rotations) {
|
||||||
|
const { pixels: rotatedPixels, width: rotatedWidth, height: rotatedHeight } =
|
||||||
|
rotation === 0
|
||||||
|
? { pixels, width, height }
|
||||||
|
: rotateImage(pixels, width, height, rotation);
|
||||||
|
|
||||||
|
// Apply CLAHE
|
||||||
|
const clahePixels = applyCLAHE(rotatedPixels, rotatedWidth, rotatedHeight);
|
||||||
|
|
||||||
|
// Resize to 32x32
|
||||||
|
const resized = resizeImage(clahePixels, rotatedWidth, rotatedHeight, 32, 32);
|
||||||
|
|
||||||
|
// Compute hash
|
||||||
|
const queryHash = computeColorHash(resized);
|
||||||
|
|
||||||
|
// Find best match for this rotation
|
||||||
|
for (const card of cardHashes) {
|
||||||
|
if (!card.hash || card.hash.length !== queryHash.length) continue;
|
||||||
|
|
||||||
|
const distance = hammingDistance(queryHash, card.hash);
|
||||||
|
const confidence = calculateConfidence(distance);
|
||||||
|
|
||||||
|
if (distance <= opts.matchThreshold && confidence >= opts.minConfidence) {
|
||||||
|
if (!bestMatch || distance < bestMatch.distance) {
|
||||||
|
bestMatch = {
|
||||||
|
cardId: card.id,
|
||||||
|
confidence,
|
||||||
|
distance,
|
||||||
|
rotation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Early exit on perfect match
|
||||||
|
if (distance === 0) {
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find best match without rotation.
|
||||||
|
*/
|
||||||
|
function findBestMatchSingle(
|
||||||
|
pixels: Uint8Array | Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
cardHashes: CardHashEntry[],
|
||||||
|
opts: Required<RecognitionOptions>
|
||||||
|
): CardMatch | null {
|
||||||
|
// Apply CLAHE
|
||||||
|
const clahePixels = applyCLAHE(pixels, width, height);
|
||||||
|
|
||||||
|
// Resize to 32x32
|
||||||
|
const resized = resizeImage(clahePixels, width, height, 32, 32);
|
||||||
|
|
||||||
|
// Compute hash
|
||||||
|
const queryHash = computeColorHash(resized);
|
||||||
|
|
||||||
|
let bestMatch: CardMatch | null = null;
|
||||||
|
|
||||||
|
for (const card of cardHashes) {
|
||||||
|
if (!card.hash || card.hash.length !== queryHash.length) continue;
|
||||||
|
|
||||||
|
const distance = hammingDistance(queryHash, card.hash);
|
||||||
|
const confidence = calculateConfidence(distance);
|
||||||
|
|
||||||
|
if (distance <= opts.matchThreshold && confidence >= opts.minConfidence) {
|
||||||
|
if (!bestMatch || distance < bestMatch.distance) {
|
||||||
|
bestMatch = {
|
||||||
|
cardId: card.id,
|
||||||
|
confidence,
|
||||||
|
distance,
|
||||||
|
rotation: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (distance === 0) {
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a recognition service instance with cached card hashes.
|
||||||
|
*/
|
||||||
|
export function createRecognitionService(cardHashes: CardHashEntry[]) {
|
||||||
|
let cachedHashes = cardHashes;
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Recognize a card from RGBA pixel data.
|
||||||
|
*/
|
||||||
|
recognize(
|
||||||
|
pixels: Uint8Array | Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
options?: RecognitionOptions
|
||||||
|
): RecognitionResult {
|
||||||
|
return recognizeCard(pixels, width, height, cachedHashes, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the cached card hashes.
|
||||||
|
*/
|
||||||
|
updateHashes(hashes: CardHashEntry[]) {
|
||||||
|
cachedHashes = hashes;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of cached hashes.
|
||||||
|
*/
|
||||||
|
getHashCount(): number {
|
||||||
|
return cachedHashes.length;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
121
lib/recognition/skiaDecoder.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
/**
|
||||||
|
* Skia-based image decoder for getting RGBA pixel data.
|
||||||
|
* Uses react-native-skia to decode images and extract pixel buffers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Skia,
|
||||||
|
useImage,
|
||||||
|
SkImage,
|
||||||
|
} from "@shopify/react-native-skia";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a base64 PNG/JPEG image and return RGBA pixel data.
|
||||||
|
*
|
||||||
|
* @param base64 - Base64 encoded image data (without data URI prefix)
|
||||||
|
* @returns RGBA pixel data with dimensions
|
||||||
|
*/
|
||||||
|
export function decodeImageBase64(base64: string): {
|
||||||
|
pixels: Uint8Array;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null {
|
||||||
|
try {
|
||||||
|
// Decode base64 to data
|
||||||
|
const data = Skia.Data.fromBase64(base64);
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
// Create image from data
|
||||||
|
const image = Skia.Image.MakeImageFromEncoded(data);
|
||||||
|
if (!image) return null;
|
||||||
|
|
||||||
|
const width = image.width();
|
||||||
|
const height = image.height();
|
||||||
|
|
||||||
|
// Read pixels from the image
|
||||||
|
// Note: Skia images are in RGBA format
|
||||||
|
const pixels = image.readPixels(0, 0, {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
colorType: 4, // RGBA_8888
|
||||||
|
alphaType: 1, // Unpremultiplied
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pixels) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
pixels: new Uint8Array(pixels),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SkiaDecoder] Failed to decode image:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode an image from a file URI and return RGBA pixel data.
|
||||||
|
*
|
||||||
|
* @param uri - File URI (e.g., file:///path/to/image.png)
|
||||||
|
* @returns Promise with RGBA pixel data
|
||||||
|
*/
|
||||||
|
export async function decodeImageFromUri(uri: string): Promise<{
|
||||||
|
pixels: Uint8Array;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
// Fetch the image data
|
||||||
|
const response = await fetch(uri);
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
const base64 = arrayBufferToBase64(arrayBuffer);
|
||||||
|
|
||||||
|
return decodeImageBase64(base64);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SkiaDecoder] Failed to load image from URI:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert ArrayBuffer to base64 string.
|
||||||
|
*/
|
||||||
|
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
|
let binary = "";
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
const len = bytes.byteLength;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook to decode an image from URI.
|
||||||
|
* Uses Skia's useImage hook for caching.
|
||||||
|
*/
|
||||||
|
export function useDecodedImage(uri: string | null) {
|
||||||
|
const skiaImage = useImage(uri);
|
||||||
|
|
||||||
|
if (!skiaImage) {
|
||||||
|
return { loading: true, pixels: null, width: 0, height: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = skiaImage.width();
|
||||||
|
const height = skiaImage.height();
|
||||||
|
|
||||||
|
const pixels = skiaImage.readPixels(0, 0, {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
colorType: 4, // RGBA_8888
|
||||||
|
alphaType: 1, // Unpremultiplied
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
pixels: pixels ? new Uint8Array(pixels) : null,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
}
|
||||||
10536
package-lock.json
generated
Normal file
61
package.json
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"dev": "concurrently \"bunx convex dev\" \"expo start --android\"",
|
||||||
|
"dev:convex": "bunx convex dev",
|
||||||
|
"dev:expo": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"migrate": "tsx scripts/migrate-hashes.ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/core": "^0.37.4",
|
||||||
|
"@convex-dev/auth": "^0.0.90",
|
||||||
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@react-navigation/native": "^7.1.8",
|
||||||
|
"@shopify/react-native-skia": "^2.4.18",
|
||||||
|
"convex": "^1.31.7",
|
||||||
|
"expo": "~54.0.33",
|
||||||
|
"expo-auth-session": "^7.0.10",
|
||||||
|
"expo-camera": "^17.0.10",
|
||||||
|
"expo-constants": "~18.0.13",
|
||||||
|
"expo-crypto": "^15.0.8",
|
||||||
|
"expo-font": "~14.0.11",
|
||||||
|
"expo-image-manipulator": "^14.0.8",
|
||||||
|
"expo-linking": "~8.0.11",
|
||||||
|
"expo-router": "~6.0.23",
|
||||||
|
"expo-secure-store": "^15.0.8",
|
||||||
|
"expo-splash-screen": "~31.0.13",
|
||||||
|
"expo-sqlite": "^16.0.10",
|
||||||
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"expo-web-browser": "~15.0.10",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
"react-native-fast-opencv": "^0.4.7",
|
||||||
|
"react-native-reanimated": "~4.1.1",
|
||||||
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-vision-camera": "^4.7.3",
|
||||||
|
"react-native-web": "~0.21.0",
|
||||||
|
"react-native-worklets": "0.5.1",
|
||||||
|
"react-native-worklets-core": "^1.6.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/react": "~19.1.0",
|
||||||
|
"better-sqlite3": "^11.6.0",
|
||||||
|
"concurrently": "^9.1.0",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"react-test-renderer": "19.1.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
116
scripts/migrate-hashes.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
/**
|
||||||
|
* Migration script to upload card hashes from SQLite to Convex.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx tsx scripts/migrate-hashes.ts
|
||||||
|
*
|
||||||
|
* Prerequisites:
|
||||||
|
* - Run `npx convex dev` first to set up the Convex project
|
||||||
|
* - Ensure CONVEX_URL is set in .env.local
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ConvexHttpClient } from "convex/browser";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { api } from "../convex/_generated/api";
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as fs from "fs";
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
dotenv.config({ path: path.join(__dirname, "..", ".env.local") });
|
||||||
|
|
||||||
|
const CONVEX_URL = process.env.EXPO_PUBLIC_CONVEX_URL || process.env.CONVEX_URL;
|
||||||
|
const DB_PATH =
|
||||||
|
process.env.DB_PATH || path.join(__dirname, "..", "card_hashes.db");
|
||||||
|
const BATCH_SIZE = 50;
|
||||||
|
const HASH_VERSION = 1;
|
||||||
|
|
||||||
|
interface CardRow {
|
||||||
|
id: string;
|
||||||
|
oracle_id: string;
|
||||||
|
name: string;
|
||||||
|
set_code: string;
|
||||||
|
collector_number: string | null;
|
||||||
|
rarity: string | null;
|
||||||
|
artist: string | null;
|
||||||
|
image_uri: string | null;
|
||||||
|
hash: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!CONVEX_URL) {
|
||||||
|
console.error("Error: CONVEX_URL not set in .env.local");
|
||||||
|
console.error("Run 'npx convex dev --once --configure=new' first");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(DB_PATH)) {
|
||||||
|
console.error(`Error: Database not found at ${DB_PATH}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Connecting to Convex: ${CONVEX_URL}`);
|
||||||
|
console.log(`Reading from SQLite: ${DB_PATH}`);
|
||||||
|
|
||||||
|
const client = new ConvexHttpClient(CONVEX_URL);
|
||||||
|
const db = new Database(DB_PATH, { readonly: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get total count
|
||||||
|
const countRow = db
|
||||||
|
.prepare("SELECT COUNT(*) as count FROM cards WHERE hash IS NOT NULL")
|
||||||
|
.get() as { count: number };
|
||||||
|
const totalCards = countRow.count;
|
||||||
|
console.log(`Found ${totalCards} cards with hashes to migrate`);
|
||||||
|
|
||||||
|
// Query cards with hashes
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
SELECT id, oracle_id, name, set_code, collector_number, rarity, artist, image_uri, hash
|
||||||
|
FROM cards
|
||||||
|
WHERE hash IS NOT NULL
|
||||||
|
ORDER BY name
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cards = stmt.all() as CardRow[];
|
||||||
|
let migrated = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
// Process in batches
|
||||||
|
for (let i = 0; i < cards.length; i += BATCH_SIZE) {
|
||||||
|
const batch = cards.slice(i, i + BATCH_SIZE);
|
||||||
|
const convexCards = batch.map((card) => ({
|
||||||
|
scryfallId: card.id,
|
||||||
|
oracleId: card.oracle_id,
|
||||||
|
name: card.name,
|
||||||
|
setCode: card.set_code,
|
||||||
|
collectorNumber: card.collector_number || "",
|
||||||
|
rarity: card.rarity || "common",
|
||||||
|
artist: card.artist || undefined,
|
||||||
|
imageUri: card.image_uri || undefined,
|
||||||
|
hash: new Uint8Array(card.hash).buffer as ArrayBuffer,
|
||||||
|
hashVersion: HASH_VERSION,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.mutation(api.cards.insertBatch, { cards: convexCards });
|
||||||
|
migrated += batch.length;
|
||||||
|
const pct = ((migrated / totalCards) * 100).toFixed(1);
|
||||||
|
process.stdout.write(`\rMigrated ${migrated}/${totalCards} (${pct}%)`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`\nError migrating batch starting at ${i}:`, err);
|
||||||
|
errors += batch.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n\nMigration complete!`);
|
||||||
|
console.log(` Migrated: ${migrated}`);
|
||||||
|
console.log(` Errors: ${errors}`);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Migration failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" ?>
|
|
||||||
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
|
||||||
x:Class="Scry.App">
|
|
||||||
<Application.Resources>
|
|
||||||
<ResourceDictionary>
|
|
||||||
<ResourceDictionary.MergedDictionaries>
|
|
||||||
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
|
|
||||||
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
|
|
||||||
</ResourceDictionary.MergedDictionaries>
|
|
||||||
</ResourceDictionary>
|
|
||||||
</Application.Resources>
|
|
||||||
</Application>
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
namespace Scry;
|
|
||||||
|
|
||||||
public partial class App : Application
|
|
||||||
{
|
|
||||||
public App()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Window CreateWindow(IActivationState? activationState)
|
|
||||||
{
|
|
||||||
return new Window(new AppShell());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" ?>
|
|
||||||
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
|
||||||
xmlns:views="clr-namespace:Scry.Views"
|
|
||||||
x:Class="Scry.AppShell"
|
|
||||||
Shell.FlyoutBehavior="Disabled">
|
|
||||||
|
|
||||||
<TabBar>
|
|
||||||
<ShellContent Title="Scan"
|
|
||||||
Icon="{OnPlatform Android=camera, Default=camera.png}"
|
|
||||||
ContentTemplate="{DataTemplate views:ScanPage}" />
|
|
||||||
<ShellContent Title="Collection"
|
|
||||||
Icon="{OnPlatform Android=collections, Default=collections.png}"
|
|
||||||
ContentTemplate="{DataTemplate views:CollectionPage}" />
|
|
||||||
<ShellContent Title="Settings"
|
|
||||||
Icon="{OnPlatform Android=settings, Default=settings.png}"
|
|
||||||
ContentTemplate="{DataTemplate views:SettingsPage}" />
|
|
||||||
</TabBar>
|
|
||||||
|
|
||||||
<!-- Route for card detail -->
|
|
||||||
<Shell.Resources>
|
|
||||||
<ResourceDictionary>
|
|
||||||
<Style TargetType="TabBar">
|
|
||||||
<Setter Property="Shell.TabBarBackgroundColor" Value="{StaticResource Primary}" />
|
|
||||||
<Setter Property="Shell.TabBarTitleColor" Value="White" />
|
|
||||||
<Setter Property="Shell.TabBarUnselectedColor" Value="#80FFFFFF" />
|
|
||||||
</Style>
|
|
||||||
</ResourceDictionary>
|
|
||||||
</Shell.Resources>
|
|
||||||
|
|
||||||
</Shell>
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
using Scry.Views;
|
|
||||||
|
|
||||||
namespace Scry;
|
|
||||||
|
|
||||||
public partial class AppShell : Shell
|
|
||||||
{
|
|
||||||
public AppShell()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
|
|
||||||
Routing.RegisterRoute(nameof(CardDetailPage), typeof(CardDetailPage));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
using System.Globalization;
|
|
||||||
|
|
||||||
namespace Scry.Converters;
|
|
||||||
|
|
||||||
public class BoolToScanTextConverter : IValueConverter
|
|
||||||
{
|
|
||||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
|
||||||
{
|
|
||||||
if (value is bool isProcessing)
|
|
||||||
return isProcessing ? "Scanning..." : "Scan Card";
|
|
||||||
return "Scan Card";
|
|
||||||
}
|
|
||||||
|
|
||||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
using System.Globalization;
|
|
||||||
|
|
||||||
namespace Scry.Converters;
|
|
||||||
|
|
||||||
public class InverseBoolConverter : IValueConverter
|
|
||||||
{
|
|
||||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
|
||||||
{
|
|
||||||
if (value is bool b)
|
|
||||||
return !b;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
|
||||||
{
|
|
||||||
if (value is bool b)
|
|
||||||
return !b;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
using System.Globalization;
|
|
||||||
|
|
||||||
namespace Scry.Converters;
|
|
||||||
|
|
||||||
public class StringNotEmptyConverter : IValueConverter
|
|
||||||
{
|
|
||||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
|
||||||
{
|
|
||||||
return !string.IsNullOrWhiteSpace(value as string);
|
|
||||||
}
|
|
||||||
|
|
||||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
using CommunityToolkit.Maui;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Scry.Core.Data;
|
|
||||||
using Scry.Core.Recognition;
|
|
||||||
using Scry.Services;
|
|
||||||
using Scry.ViewModels;
|
|
||||||
using Scry.Views;
|
|
||||||
|
|
||||||
namespace Scry;
|
|
||||||
|
|
||||||
public static class MauiProgram
|
|
||||||
{
|
|
||||||
public static MauiApp CreateMauiApp()
|
|
||||||
{
|
|
||||||
var builder = MauiApp.CreateBuilder();
|
|
||||||
builder
|
|
||||||
.UseMauiApp<App>()
|
|
||||||
.UseMauiCommunityToolkit()
|
|
||||||
.UseMauiCommunityToolkitCamera()
|
|
||||||
.ConfigureFonts(fonts =>
|
|
||||||
{
|
|
||||||
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
|
|
||||||
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Core Services (from Scry.Core)
|
|
||||||
builder.Services.AddSingleton<CardDatabase>(sp =>
|
|
||||||
{
|
|
||||||
var dbPath = Path.Combine(FileSystem.AppDataDirectory, "card_hashes.db");
|
|
||||||
EnsureDatabaseCopied(dbPath);
|
|
||||||
return new CardDatabase(dbPath);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Recognition options - configure debug output in DEBUG builds
|
|
||||||
builder.Services.Configure<RecognitionOptions>(options =>
|
|
||||||
{
|
|
||||||
#if DEBUG && ANDROID
|
|
||||||
// Use Download folder for easy adb pull access
|
|
||||||
options.DebugOutputDirectory = "/sdcard/Download/scry-debug";
|
|
||||||
#elif DEBUG
|
|
||||||
options.DebugOutputDirectory = "./debug";
|
|
||||||
#endif
|
|
||||||
});
|
|
||||||
builder.Services.AddSingleton<CardRecognitionService>();
|
|
||||||
|
|
||||||
// App Services
|
|
||||||
builder.Services.AddSingleton<ICardRepository, InMemoryCardRepository>();
|
|
||||||
builder.Services.AddSingleton<ICardRecognitionService, RealCardRecognitionService>();
|
|
||||||
|
|
||||||
// ViewModels
|
|
||||||
builder.Services.AddTransient<ScanViewModel>();
|
|
||||||
builder.Services.AddTransient<CollectionViewModel>();
|
|
||||||
builder.Services.AddTransient<CardDetailViewModel>();
|
|
||||||
builder.Services.AddTransient<SettingsViewModel>();
|
|
||||||
|
|
||||||
// Views
|
|
||||||
builder.Services.AddTransient<ScanPage>();
|
|
||||||
builder.Services.AddTransient<CollectionPage>();
|
|
||||||
builder.Services.AddTransient<CardDetailPage>();
|
|
||||||
builder.Services.AddTransient<SettingsPage>();
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
builder.Logging.AddDebug();
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return builder.Build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void EnsureDatabaseCopied(string targetPath)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var bundledStream = FileSystem.OpenAppPackageFileAsync("card_hashes.db").GetAwaiter().GetResult();
|
|
||||||
|
|
||||||
if (File.Exists(targetPath))
|
|
||||||
{
|
|
||||||
// Compare sizes - if bundled is larger, replace
|
|
||||||
var existingSize = new FileInfo(targetPath).Length;
|
|
||||||
var bundledSize = bundledStream.Length;
|
|
||||||
|
|
||||||
if (bundledSize <= existingSize)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Bundled db is larger, delete old and copy new
|
|
||||||
File.Delete(targetPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
using var fileStream = File.Create(targetPath);
|
|
||||||
bundledStream.CopyTo(fileStream);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Database not bundled, will be empty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
using Scry.Core.Models;
|
|
||||||
|
|
||||||
namespace Scry.Models;
|
|
||||||
|
|
||||||
public class CollectionEntry
|
|
||||||
{
|
|
||||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
|
||||||
public Card Card { get; set; } = null!;
|
|
||||||
public int Quantity { get; set; } = 1;
|
|
||||||
public bool IsFoil { get; set; }
|
|
||||||
public DateTime AddedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<application android:allowBackup="true"
|
|
||||||
android:icon="@mipmap/appicon"
|
|
||||||
android:roundIcon="@mipmap/appicon_round"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:label="Scry">
|
|
||||||
</application>
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="true" />
|
|
||||||
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
|
||||||
|
|
||||||
<queries>
|
|
||||||
<intent>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<data android:scheme="https" />
|
|
||||||
</intent>
|
|
||||||
</queries>
|
|
||||||
</manifest>
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
using Android.App;
|
|
||||||
using Android.Content.PM;
|
|
||||||
using Android.OS;
|
|
||||||
|
|
||||||
namespace Scry;
|
|
||||||
|
|
||||||
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop,
|
|
||||||
ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode |
|
|
||||||
ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
|
|
||||||
public class MainActivity : MauiAppCompatActivity
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
using Android.App;
|
|
||||||
using Android.Runtime;
|
|
||||||
|
|
||||||
namespace Scry;
|
|
||||||
|
|
||||||
[Application]
|
|
||||||
public class MainApplication : MauiApplication
|
|
||||||
{
|
|
||||||
public MainApplication(IntPtr handle, JniHandleOwnership ownership)
|
|
||||||
: base(handle, ownership)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="colorPrimary">#512BD4</color>
|
|
||||||
<color name="colorPrimaryDark">#3B1F9E</color>
|
|
||||||
<color name="colorAccent">#512BD4</color>
|
|
||||||
</resources>
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<svg width="456" height="456" viewBox="0 0 456 456" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="456" height="456" fill="#512BD4"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 200 B |
|
|
@ -1,10 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<svg width="456" height="456" viewBox="0 0 456 456" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<!-- Scry/Crystal ball icon -->
|
|
||||||
<circle cx="228" cy="200" r="100" fill="white" opacity="0.9"/>
|
|
||||||
<ellipse cx="228" cy="200" rx="80" ry="80" fill="none" stroke="white" stroke-width="8"/>
|
|
||||||
<path d="M178 280 L148 350 L308 350 L278 280" fill="white" opacity="0.9"/>
|
|
||||||
<!-- Eye symbol inside -->
|
|
||||||
<ellipse cx="228" cy="200" rx="40" ry="25" fill="none" stroke="#512BD4" stroke-width="6"/>
|
|
||||||
<circle cx="228" cy="200" r="12" fill="#512BD4"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 591 B |
|
|
@ -1 +0,0 @@
|
||||||
# Placeholder
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
# Placeholder
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<svg width="456" height="456" viewBox="0 0 456 456" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<!-- Scry/Crystal ball icon for splash -->
|
|
||||||
<circle cx="228" cy="180" r="80" fill="white" opacity="0.9"/>
|
|
||||||
<ellipse cx="228" cy="180" rx="60" ry="60" fill="none" stroke="white" stroke-width="6"/>
|
|
||||||
<path d="M188 240 L163 300 L293 300 L268 240" fill="white" opacity="0.9"/>
|
|
||||||
<!-- Eye symbol inside -->
|
|
||||||
<ellipse cx="228" cy="180" rx="30" ry="18" fill="none" stroke="#512BD4" stroke-width="4"/>
|
|
||||||
<circle cx="228" cy="180" r="8" fill="#512BD4"/>
|
|
||||||
<!-- Text -->
|
|
||||||
<text x="228" y="370" font-family="sans-serif" font-size="48" font-weight="bold" fill="white" text-anchor="middle">SCRY</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 745 B |
|
|
@ -1,47 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" ?>
|
|
||||||
<?xaml-comp compile="true" ?>
|
|
||||||
<ResourceDictionary
|
|
||||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
|
|
||||||
|
|
||||||
<Color x:Key="Primary">#512BD4</Color>
|
|
||||||
<Color x:Key="PrimaryDark">#3B1F9E</Color>
|
|
||||||
<Color x:Key="PrimaryDarkText">White</Color>
|
|
||||||
<Color x:Key="Secondary">#DFD8F7</Color>
|
|
||||||
<Color x:Key="SecondaryDarkText">#3B1F9E</Color>
|
|
||||||
<Color x:Key="Tertiary">#2B0B98</Color>
|
|
||||||
|
|
||||||
<Color x:Key="White">White</Color>
|
|
||||||
<Color x:Key="Black">Black</Color>
|
|
||||||
<Color x:Key="Gray100">#E1E1E1</Color>
|
|
||||||
<Color x:Key="Gray200">#C8C8C8</Color>
|
|
||||||
<Color x:Key="Gray300">#ACACAC</Color>
|
|
||||||
<Color x:Key="Gray400">#919191</Color>
|
|
||||||
<Color x:Key="Gray500">#6E6E6E</Color>
|
|
||||||
<Color x:Key="Gray600">#404040</Color>
|
|
||||||
<Color x:Key="Gray800">#2A2A2A</Color>
|
|
||||||
<Color x:Key="Gray900">#1A1A1A</Color>
|
|
||||||
<Color x:Key="Gray950">#141414</Color>
|
|
||||||
|
|
||||||
<Color x:Key="Yellow100Accent">#F7B548</Color>
|
|
||||||
<Color x:Key="Yellow200Accent">#FFD590</Color>
|
|
||||||
<Color x:Key="Yellow300Accent">#FFE5B9</Color>
|
|
||||||
<Color x:Key="Cyan100Accent">#28C2D1</Color>
|
|
||||||
<Color x:Key="Cyan200Accent">#7BDDEF</Color>
|
|
||||||
<Color x:Key="Cyan300Accent">#C3F2F4</Color>
|
|
||||||
|
|
||||||
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource Primary}"/>
|
|
||||||
<SolidColorBrush x:Key="SecondaryBrush" Color="{StaticResource Secondary}"/>
|
|
||||||
<SolidColorBrush x:Key="TertiaryBrush" Color="{StaticResource Tertiary}"/>
|
|
||||||
<SolidColorBrush x:Key="WhiteBrush" Color="{StaticResource White}"/>
|
|
||||||
<SolidColorBrush x:Key="BlackBrush" Color="{StaticResource Black}"/>
|
|
||||||
<SolidColorBrush x:Key="Gray100Brush" Color="{StaticResource Gray100}"/>
|
|
||||||
<SolidColorBrush x:Key="Gray200Brush" Color="{StaticResource Gray200}"/>
|
|
||||||
<SolidColorBrush x:Key="Gray300Brush" Color="{StaticResource Gray300}"/>
|
|
||||||
<SolidColorBrush x:Key="Gray400Brush" Color="{StaticResource Gray400}"/>
|
|
||||||
<SolidColorBrush x:Key="Gray500Brush" Color="{StaticResource Gray500}"/>
|
|
||||||
<SolidColorBrush x:Key="Gray600Brush" Color="{StaticResource Gray600}"/>
|
|
||||||
<SolidColorBrush x:Key="Gray900Brush" Color="{StaticResource Gray900}"/>
|
|
||||||
<SolidColorBrush x:Key="Gray950Brush" Color="{StaticResource Gray950}"/>
|
|
||||||
|
|
||||||
</ResourceDictionary>
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" ?>
|
|
||||||
<?xaml-comp compile="true" ?>
|
|
||||||
<ResourceDictionary
|
|
||||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
|
||||||
xmlns:converters="clr-namespace:Scry.Converters">
|
|
||||||
|
|
||||||
<!-- Converters -->
|
|
||||||
<converters:InverseBoolConverter x:Key="InverseBool" />
|
|
||||||
<converters:BoolToScanTextConverter x:Key="BoolToScanText" />
|
|
||||||
<converters:StringNotEmptyConverter x:Key="StringNotEmpty" />
|
|
||||||
|
|
||||||
<Style TargetType="ActivityIndicator">
|
|
||||||
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style TargetType="IndicatorView">
|
|
||||||
<Setter Property="IndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}"/>
|
|
||||||
<Setter Property="SelectedIndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray100}}"/>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style TargetType="Border">
|
|
||||||
<Setter Property="Stroke" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
|
|
||||||
<Setter Property="StrokeShape" Value="Rectangle"/>
|
|
||||||
<Setter Property="StrokeThickness" Value="1"/>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style TargetType="BoxView">
|
|
||||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style TargetType="Button">
|
|
||||||
<Setter Property="TextColor" Value="White"/>
|
|
||||||
<Setter Property="BackgroundColor" Value="{StaticResource Primary}"/>
|
|
||||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
|
||||||
<Setter Property="FontSize" Value="14"/>
|
|
||||||
<Setter Property="BorderWidth" Value="0"/>
|
|
||||||
<Setter Property="CornerRadius" Value="8"/>
|
|
||||||
<Setter Property="Padding" Value="14,10"/>
|
|
||||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
|
||||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
|
||||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
|
||||||
<VisualStateGroupList>
|
|
||||||
<VisualStateGroup x:Name="CommonStates">
|
|
||||||
<VisualState x:Name="Normal" />
|
|
||||||
<VisualState x:Name="Disabled">
|
|
||||||
<VisualState.Setters>
|
|
||||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
|
||||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
|
|
||||||
</VisualState.Setters>
|
|
||||||
</VisualState>
|
|
||||||
<VisualState x:Name="PointerOver" />
|
|
||||||
</VisualStateGroup>
|
|
||||||
</VisualStateGroupList>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style x:Key="OutlineButton" TargetType="Button">
|
|
||||||
<Setter Property="TextColor" Value="White"/>
|
|
||||||
<Setter Property="BackgroundColor" Value="Transparent"/>
|
|
||||||
<Setter Property="BorderColor" Value="White"/>
|
|
||||||
<Setter Property="BorderWidth" Value="2"/>
|
|
||||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
|
||||||
<Setter Property="FontSize" Value="14"/>
|
|
||||||
<Setter Property="CornerRadius" Value="8"/>
|
|
||||||
<Setter Property="Padding" Value="14,10"/>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style TargetType="CheckBox">
|
|
||||||
<Setter Property="Color" Value="{StaticResource Primary}" />
|
|
||||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
|
||||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
|
||||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
|
||||||
<VisualStateGroupList>
|
|
||||||
<VisualStateGroup x:Name="CommonStates">
|
|
||||||
<VisualState x:Name="Normal" />
|
|
||||||
<VisualState x:Name="Disabled">
|
|
||||||
<VisualState.Setters>
|
|
||||||
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
|
||||||
</VisualState.Setters>
|
|
||||||
</VisualState>
|
|
||||||
</VisualStateGroup>
|
|
||||||
</VisualStateGroupList>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style TargetType="Entry">
|
|
||||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
|
||||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
|
||||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
|
||||||
<Setter Property="FontSize" Value="14" />
|
|
||||||
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
|
||||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
|
||||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
|
||||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
|
||||||
<VisualStateGroupList>
|
|
||||||
<VisualStateGroup x:Name="CommonStates">
|
|
||||||
<VisualState x:Name="Normal" />
|
|
||||||
<VisualState x:Name="Disabled">
|
|
||||||
<VisualState.Setters>
|
|
||||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
|
||||||
</VisualState.Setters>
|
|
||||||
</VisualState>
|
|
||||||
</VisualStateGroup>
|
|
||||||
</VisualStateGroupList>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style TargetType="Label">
|
|
||||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
|
||||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
|
||||||
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
|
||||||
<Setter Property="FontSize" Value="14" />
|
|
||||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
|
||||||
<VisualStateGroupList>
|
|
||||||
<VisualStateGroup x:Name="CommonStates">
|
|
||||||
<VisualState x:Name="Normal" />
|
|
||||||
<VisualState x:Name="Disabled">
|
|
||||||
<VisualState.Setters>
|
|
||||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
|
||||||
</VisualState.Setters>
|
|
||||||
</VisualState>
|
|
||||||
</VisualStateGroup>
|
|
||||||
</VisualStateGroupList>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style TargetType="Span">
|
|
||||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style TargetType="Switch">
|
|
||||||
<Setter Property="OnColor" Value="{StaticResource Primary}" />
|
|
||||||
<Setter Property="ThumbColor" Value="{StaticResource White}" />
|
|
||||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
|
||||||
<VisualStateGroupList>
|
|
||||||
<VisualStateGroup x:Name="CommonStates">
|
|
||||||
<VisualState x:Name="Normal" />
|
|
||||||
<VisualState x:Name="Disabled">
|
|
||||||
<VisualState.Setters>
|
|
||||||
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
|
||||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
|
||||||
</VisualState.Setters>
|
|
||||||
</VisualState>
|
|
||||||
<VisualState x:Name="On">
|
|
||||||
<VisualState.Setters>
|
|
||||||
<Setter Property="OnColor" Value="{StaticResource Primary}" />
|
|
||||||
<Setter Property="ThumbColor" Value="{StaticResource White}" />
|
|
||||||
</VisualState.Setters>
|
|
||||||
</VisualState>
|
|
||||||
<VisualState x:Name="Off">
|
|
||||||
<VisualState.Setters>
|
|
||||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
|
|
||||||
</VisualState.Setters>
|
|
||||||
</VisualState>
|
|
||||||
</VisualStateGroup>
|
|
||||||
</VisualStateGroupList>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style TargetType="Page" ApplyToDerivedTypes="True">
|
|
||||||
<Setter Property="Padding" Value="0"/>
|
|
||||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style TargetType="Shell" ApplyToDerivedTypes="True">
|
|
||||||
<Setter Property="Shell.BackgroundColor" Value="{StaticResource Primary}" />
|
|
||||||
<Setter Property="Shell.ForegroundColor" Value="{OnPlatform WinUI={StaticResource Primary}, Default={StaticResource White}}" />
|
|
||||||
<Setter Property="Shell.TitleColor" Value="{StaticResource White}" />
|
|
||||||
<Setter Property="Shell.DisabledColor" Value="{StaticResource Gray200}" />
|
|
||||||
<Setter Property="Shell.UnselectedColor" Value="#80FFFFFF" />
|
|
||||||
<Setter Property="Shell.NavBarHasShadow" Value="False" />
|
|
||||||
<Setter Property="Shell.TabBarBackgroundColor" Value="{StaticResource Primary}" />
|
|
||||||
<Setter Property="Shell.TabBarForegroundColor" Value="{StaticResource White}" />
|
|
||||||
<Setter Property="Shell.TabBarTitleColor" Value="{StaticResource White}" />
|
|
||||||
<Setter Property="Shell.TabBarUnselectedColor" Value="#80FFFFFF" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style TargetType="NavigationPage">
|
|
||||||
<Setter Property="BarBackgroundColor" Value="{StaticResource Primary}" />
|
|
||||||
<Setter Property="BarTextColor" Value="{StaticResource White}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
</ResourceDictionary>
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<!-- Android SDK and JDK paths -->
|
|
||||||
<AndroidSdkDirectory Condition="Exists('$(LOCALAPPDATA)\Android\Sdk')">$(LOCALAPPDATA)\Android\Sdk</AndroidSdkDirectory>
|
|
||||||
<JavaSdkDirectory Condition="Exists('C:\Program Files\Microsoft\jdk-21.0.10.7-hotspot')">C:\Program Files\Microsoft\jdk-21.0.10.7-hotspot</JavaSdkDirectory>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFrameworks>net10.0-android</TargetFrameworks>
|
|
||||||
<RuntimeIdentifiers>android-arm64;android-x64</RuntimeIdentifiers>
|
|
||||||
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
|
|
||||||
<!-- Uncomment to add iOS/desktop support later:
|
|
||||||
<TargetFrameworks>$(TargetFrameworks);net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
|
|
||||||
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.19041.0</TargetFrameworks>
|
|
||||||
-->
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<RootNamespace>Scry</RootNamespace>
|
|
||||||
<UseMaui>true</UseMaui>
|
|
||||||
<SingleProject>true</SingleProject>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<!-- Hot Reload for Debug builds -->
|
|
||||||
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
|
|
||||||
<MauiEnableXamlHotReload>true</MauiEnableXamlHotReload>
|
|
||||||
<EnableHotReload>true</EnableHotReload>
|
|
||||||
<UseInterpreter>true</UseInterpreter>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<!-- Watch additional files for dotnet watch -->
|
|
||||||
<ItemGroup>
|
|
||||||
<Watch Include="Resources\Raw\card_hashes.db" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<!-- Display name -->
|
|
||||||
<ApplicationTitle>Scry</ApplicationTitle>
|
|
||||||
|
|
||||||
<!-- App Identifier -->
|
|
||||||
<ApplicationId>land.charm.scry</ApplicationId>
|
|
||||||
|
|
||||||
<!-- Versions -->
|
|
||||||
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
|
||||||
<ApplicationVersion>1</ApplicationVersion>
|
|
||||||
|
|
||||||
<SupportedOSPlatformVersion
|
|
||||||
Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'"
|
|
||||||
>21.0</SupportedOSPlatformVersion>
|
|
||||||
<SupportedOSPlatformVersion
|
|
||||||
Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'"
|
|
||||||
>15.0</SupportedOSPlatformVersion>
|
|
||||||
<SupportedOSPlatformVersion
|
|
||||||
Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'"
|
|
||||||
>15.0</SupportedOSPlatformVersion>
|
|
||||||
<SupportedOSPlatformVersion
|
|
||||||
Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'"
|
|
||||||
>10.0.17763.0</SupportedOSPlatformVersion>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- App Icon -->
|
|
||||||
<MauiIcon
|
|
||||||
Include="Resources\AppIcon\appicon.svg"
|
|
||||||
ForegroundFile="Resources\AppIcon\appiconfg.svg"
|
|
||||||
Color="#512BD4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Splash Screen -->
|
|
||||||
<MauiSplashScreen
|
|
||||||
Include="Resources\Splash\splash.svg"
|
|
||||||
Color="#512BD4"
|
|
||||||
BaseSize="128,128"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Images -->
|
|
||||||
<MauiImage Include="Resources\Images\*" />
|
|
||||||
|
|
||||||
<!-- Custom Fonts -->
|
|
||||||
<MauiFont Include="Resources\Fonts\*" />
|
|
||||||
|
|
||||||
<!-- Raw Assets -->
|
|
||||||
<MauiAsset
|
|
||||||
Include="Resources\Raw\**"
|
|
||||||
LogicalName="%(RecursiveDir)%(Filename)%(Extension)"
|
|
||||||
/>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="CommunityToolkit.Maui" />
|
|
||||||
<PackageReference Include="CommunityToolkit.Maui.Camera" />
|
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
|
||||||
<PackageReference Include="Microsoft.Maui.Controls" />
|
|
||||||
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Scry.Core\Scry.Core.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
using Scry.Core.Models;
|
|
||||||
|
|
||||||
namespace Scry.Services;
|
|
||||||
|
|
||||||
public interface ICardRecognitionService
|
|
||||||
{
|
|
||||||
Task<ScanResult> RecognizeCardAsync(Stream imageStream, CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
using Scry.Core.Models;
|
|
||||||
using Scry.Models;
|
|
||||||
|
|
||||||
namespace Scry.Services;
|
|
||||||
|
|
||||||
public interface ICardRepository
|
|
||||||
{
|
|
||||||
IReadOnlyList<CollectionEntry> GetAll();
|
|
||||||
CollectionEntry? GetById(string id);
|
|
||||||
void Add(Card card, int quantity = 1, bool isFoil = false);
|
|
||||||
void UpdateQuantity(string entryId, int newQuantity);
|
|
||||||
void Remove(string entryId);
|
|
||||||
int TotalCards { get; }
|
|
||||||
int UniqueCards { get; }
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
using Scry.Core.Models;
|
|
||||||
using Scry.Models;
|
|
||||||
|
|
||||||
namespace Scry.Services;
|
|
||||||
|
|
||||||
public class InMemoryCardRepository : ICardRepository
|
|
||||||
{
|
|
||||||
private readonly List<CollectionEntry> _entries = [];
|
|
||||||
private readonly object _lock = new();
|
|
||||||
|
|
||||||
public IReadOnlyList<CollectionEntry> GetAll()
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
return _entries.ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public CollectionEntry? GetById(string id)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
return _entries.FirstOrDefault(e => e.Id == id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Add(Card card, int quantity = 1, bool isFoil = false)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
// Check if we already have this exact card (same id + foil status)
|
|
||||||
var existing = _entries.FirstOrDefault(e =>
|
|
||||||
e.Card.Id == card.Id && e.IsFoil == isFoil);
|
|
||||||
|
|
||||||
if (existing != null)
|
|
||||||
{
|
|
||||||
existing.Quantity += quantity;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_entries.Add(new CollectionEntry
|
|
||||||
{
|
|
||||||
Card = card,
|
|
||||||
Quantity = quantity,
|
|
||||||
IsFoil = isFoil
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void UpdateQuantity(string entryId, int newQuantity)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
var entry = _entries.FirstOrDefault(e => e.Id == entryId);
|
|
||||||
if (entry != null)
|
|
||||||
{
|
|
||||||
if (newQuantity <= 0)
|
|
||||||
{
|
|
||||||
_entries.Remove(entry);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
entry.Quantity = newQuantity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Remove(string entryId)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
var entry = _entries.FirstOrDefault(e => e.Id == entryId);
|
|
||||||
if (entry != null)
|
|
||||||
{
|
|
||||||
_entries.Remove(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public int TotalCards
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
return _entries.Sum(e => e.Quantity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public int UniqueCards
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
return _entries.Count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
using Scry.Core.Models;
|
|
||||||
|
|
||||||
namespace Scry.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Mock implementation that returns random MTG cards for testing.
|
|
||||||
/// Replace with RealCardRecognitionService for production use.
|
|
||||||
/// </summary>
|
|
||||||
public class MockCardRecognitionService : ICardRecognitionService
|
|
||||||
{
|
|
||||||
private static readonly Card[] SampleCards =
|
|
||||||
[
|
|
||||||
new Card
|
|
||||||
{
|
|
||||||
Id = "4cbc6901-6a4a-4d0a-83ea-7eefa3b35021",
|
|
||||||
OracleId = "orb-sol-ring",
|
|
||||||
SetId = "set-c21",
|
|
||||||
Name = "Sol Ring",
|
|
||||||
SetCode = "C21",
|
|
||||||
SetName = "Commander 2021",
|
|
||||||
CollectorNumber = "263",
|
|
||||||
ImageUri = "https://cards.scryfall.io/normal/front/4/c/4cbc6901-6a4a-4d0a-83ea-7eefa3b35021.jpg",
|
|
||||||
ManaCost = "{1}",
|
|
||||||
TypeLine = "Artifact",
|
|
||||||
OracleText = "{T}: Add {C}{C}.",
|
|
||||||
Rarity = "uncommon",
|
|
||||||
PricesUsd = 1.50m
|
|
||||||
},
|
|
||||||
new Card
|
|
||||||
{
|
|
||||||
Id = "e3285e6b-3e79-4d7c-bf96-d920f973b122",
|
|
||||||
OracleId = "orb-lightning-bolt",
|
|
||||||
SetId = "set-2xm",
|
|
||||||
Name = "Lightning Bolt",
|
|
||||||
SetCode = "2XM",
|
|
||||||
SetName = "Double Masters",
|
|
||||||
CollectorNumber = "129",
|
|
||||||
ImageUri = "https://cards.scryfall.io/normal/front/e/3/e3285e6b-3e79-4d7c-bf96-d920f973b122.jpg",
|
|
||||||
ManaCost = "{R}",
|
|
||||||
TypeLine = "Instant",
|
|
||||||
OracleText = "Lightning Bolt deals 3 damage to any target.",
|
|
||||||
Rarity = "uncommon",
|
|
||||||
PricesUsd = 2.00m
|
|
||||||
},
|
|
||||||
new Card
|
|
||||||
{
|
|
||||||
Id = "ce30f926-bc06-46ee-9f35-0c32659a1b1c",
|
|
||||||
OracleId = "orb-counterspell",
|
|
||||||
SetId = "set-cmr",
|
|
||||||
Name = "Counterspell",
|
|
||||||
SetCode = "CMR",
|
|
||||||
SetName = "Commander Legends",
|
|
||||||
CollectorNumber = "395",
|
|
||||||
ImageUri = "https://cards.scryfall.io/normal/front/c/e/ce30f926-bc06-46ee-9f35-0c32659a1b1c.jpg",
|
|
||||||
ManaCost = "{U}{U}",
|
|
||||||
TypeLine = "Instant",
|
|
||||||
OracleText = "Counter target spell.",
|
|
||||||
Rarity = "uncommon",
|
|
||||||
PricesUsd = 1.25m
|
|
||||||
},
|
|
||||||
new Card
|
|
||||||
{
|
|
||||||
Id = "73542c66-eb3a-46e8-a8f6-5f02087b28cf",
|
|
||||||
OracleId = "orb-llanowar-elves",
|
|
||||||
SetId = "set-m19",
|
|
||||||
Name = "Llanowar Elves",
|
|
||||||
SetCode = "M19",
|
|
||||||
SetName = "Core Set 2019",
|
|
||||||
CollectorNumber = "314",
|
|
||||||
ImageUri = "https://cards.scryfall.io/normal/front/7/3/73542c66-eb3a-46e8-a8f6-5f02087b28cf.jpg",
|
|
||||||
ManaCost = "{G}",
|
|
||||||
TypeLine = "Creature — Elf Druid",
|
|
||||||
OracleText = "{T}: Add {G}.",
|
|
||||||
Rarity = "common",
|
|
||||||
PricesUsd = 0.25m
|
|
||||||
},
|
|
||||||
new Card
|
|
||||||
{
|
|
||||||
Id = "b8dcd4a6-5c0b-4e1e-8e1d-2e2e5c1d6a1e",
|
|
||||||
OracleId = "orb-swords-to-plowshares",
|
|
||||||
SetId = "set-cmr",
|
|
||||||
Name = "Swords to Plowshares",
|
|
||||||
SetCode = "CMR",
|
|
||||||
SetName = "Commander Legends",
|
|
||||||
CollectorNumber = "387",
|
|
||||||
ImageUri = "https://cards.scryfall.io/normal/front/b/8/b8dcd4a6-5c0b-4e1e-8e1d-2e2e5c1d6a1e.jpg",
|
|
||||||
ManaCost = "{W}",
|
|
||||||
TypeLine = "Instant",
|
|
||||||
OracleText = "Exile target creature. Its controller gains life equal to its power.",
|
|
||||||
Rarity = "uncommon",
|
|
||||||
PricesUsd = 3.50m
|
|
||||||
},
|
|
||||||
new Card
|
|
||||||
{
|
|
||||||
Id = "bd8fa327-dd41-4737-8f19-2cf5eb1f7c2e",
|
|
||||||
OracleId = "orb-black-lotus",
|
|
||||||
SetId = "set-lea",
|
|
||||||
Name = "Black Lotus",
|
|
||||||
SetCode = "LEA",
|
|
||||||
SetName = "Limited Edition Alpha",
|
|
||||||
CollectorNumber = "232",
|
|
||||||
ImageUri = "https://cards.scryfall.io/normal/front/b/d/bd8fa327-dd41-4737-8f19-2cf5eb1f7c2e.jpg",
|
|
||||||
ManaCost = "{0}",
|
|
||||||
TypeLine = "Artifact",
|
|
||||||
OracleText = "{T}, Sacrifice Black Lotus: Add three mana of any one color.",
|
|
||||||
Rarity = "rare",
|
|
||||||
PricesUsd = 500000.00m
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
private readonly Random _random = new();
|
|
||||||
|
|
||||||
public async Task<ScanResult> RecognizeCardAsync(Stream imageStream, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Simulate processing delay
|
|
||||||
await Task.Delay(500 + _random.Next(500), cancellationToken);
|
|
||||||
|
|
||||||
// 90% success rate
|
|
||||||
if (_random.NextDouble() < 0.1)
|
|
||||||
{
|
|
||||||
return ScanResult.Failed("Could not recognize card. Please try again with better lighting.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var card = SampleCards[_random.Next(SampleCards.Length)];
|
|
||||||
var confidence = 0.75f + (float)_random.NextDouble() * 0.24f; // 75-99% confidence
|
|
||||||
|
|
||||||
return ScanResult.Matched(card, confidence, 10, TimeSpan.FromMilliseconds(500));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
using Scry.Core.Models;
|
|
||||||
using Scry.Core.Recognition;
|
|
||||||
|
|
||||||
namespace Scry.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Real implementation that uses Scry.Core's perceptual hash-based card recognition.
|
|
||||||
/// </summary>
|
|
||||||
public class RealCardRecognitionService : ICardRecognitionService
|
|
||||||
{
|
|
||||||
private readonly CardRecognitionService _recognitionService;
|
|
||||||
|
|
||||||
public RealCardRecognitionService(CardRecognitionService recognitionService)
|
|
||||||
{
|
|
||||||
_recognitionService = recognitionService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ScanResult> RecognizeCardAsync(Stream imageStream, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await _recognitionService.RecognizeAsync(imageStream, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||