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>
This commit is contained in:
Chris Kruining 2026-02-09 16:16:34 +01:00
parent 56499d5af9
commit 83ab4df537
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
138 changed files with 19136 additions and 7681 deletions

View 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
View 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
View file

@ -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
View 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

View file

@ -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
View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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%',
},
});

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/images/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

1737
bun.lock Normal file

File diff suppressed because it is too large Load diff

View 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',
},
});

View 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);
}
}}
/>
);
}

View 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
View 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} />;
}

View 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();
});

View 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";

View 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 };

View 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 };

View 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;
}

View 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;
}

View file

@ -0,0 +1 @@
export { useColorScheme } from 'react-native';

View 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
View 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
View 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
View 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
View 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
View 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>;

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
},
});

View 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
View file

@ -0,0 +1,5 @@
/**
* Context exports.
*/
export { HashCacheProvider, useHashCache } from "./HashCacheContext";

20
lib/db/index.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}

View 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
View 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;
}

View 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,
};
}

View 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
View 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";

View 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

View 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);
}

View 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;
},
};
}

View 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

File diff suppressed because it is too large Load diff

61
package.json Normal file
View 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
View 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);
});

View file

@ -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>

View file

@ -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());
}
}

View file

@ -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>

View file

@ -1,13 +0,0 @@
using Scry.Views;
namespace Scry;
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
Routing.RegisterRoute(nameof(CardDetailPage), typeof(CardDetailPage));
}
}

View file

@ -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();
}
}

View file

@ -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;
}
}

View file

@ -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();
}
}

View file

@ -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
}
}
}

View file

@ -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;
}

View file

@ -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>

View file

@ -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
{
}

View file

@ -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();
}

View file

@ -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>

View file

@ -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

View file

@ -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

View file

@ -1 +0,0 @@
# Placeholder

View file

@ -1 +0,0 @@
# Placeholder

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -1,8 +0,0 @@
using Scry.Core.Models;
namespace Scry.Services;
public interface ICardRecognitionService
{
Task<ScanResult> RecognizeCardAsync(Stream imageStream, CancellationToken cancellationToken = default);
}

View file

@ -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; }
}

View file

@ -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;
}
}
}
}

View file

@ -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));
}
}

View file

@ -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);
}
}

Some files were not shown because too many files have changed in this diff Show more