diff --git a/.agents/skills/frontend-designer.md b/.agents/skills/frontend-designer.md new file mode 100644 index 0000000..afcbfd4 --- /dev/null +++ b/.agents/skills/frontend-designer.md @@ -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. diff --git a/.crush.json b/.crush.json new file mode 100644 index 0000000..2ca2fe9 --- /dev/null +++ b/.crush.json @@ -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\""] + } + } +} diff --git a/.gitignore b/.gitignore index 6facb8c..d914c32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,41 @@ -# .NET -bin/ -obj/ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ dist/ -*.dll -*.exe -*.pdb +web-build/ +expo-env.d.ts -# IDE -.vs/ -.vscode/ -.idea/ -*.user -*.suo +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision -# OS +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS .DS_Store -Thumbs.db +*.pem -# Project specific -*.csv -*.dlens -*.apk -debug/ +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +# generated native folders +/ios +/android diff --git a/.just/emu.just b/.just/emu.just new file mode 100644 index 0000000..7152410 --- /dev/null +++ b/.just/emu.just @@ -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 diff --git a/.justfile b/.justfile index 055de56..df476fa 100644 --- a/.justfile +++ b/.justfile @@ -3,45 +3,17 @@ set shell := ["C:/Program Files/Git/usr/bin/bash.exe", "-c"] set unstable := true +mod emu '.just/emu.just' + # Android SDK paths android_sdk := replace(env('LOCALAPPDATA'), '\', '/') / "Android/Sdk" 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] @default: 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 project="src/Scry.App" target="net10.0-android": @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 @echo "Completed generating the database" -# Full workflow: start emulator, wait, run with hot reload +# Start Expo dev server with Convex (hot reload) 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 diff --git a/AGENTS.md b/AGENTS.md index a04e7bf..78b4cc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,13 +2,15 @@ ## 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:** -- Mobile scanning app (MAUI/Android) -- Card recognition via perceptual hashing (not OCR) -- SQLite database with pre-computed hashes -- Scryfall API integration for card data +**Tech Stack:** +- **Frontend**: Expo/React Native with Expo Router (file-based routing) +- **Backend**: Convex (serverless functions + real-time database) +- **Image Processing**: React Native Skia, fast-opencv +- **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 @@ -16,55 +18,84 @@ Use `just` commands (defined in `.justfile`): | Task | Command | Notes | |------|---------|-------| -| Build project | `just build` | Builds Android debug | -| Run tests | `just test` | Runs xUnit tests | -| Generate card database | `just gen-db` | Downloads from Scryfall, computes hashes | -| Publish app | `just publish` | Creates release APK | -| Hot reload dev | `just dev` | Uses `dotnet watch` | -| Start emulator | `just emu` | Virtual camera with Serra Angel | -| Install to device | `just install` | Installs release APK | +| Start dev server | `just dev` | Runs Convex + Expo together | +| Expo only | `just start` | Just the Expo dev server | +| Convex only | `just convex-dev` | Just the Convex dev server | +| Run on Android | `just android` | Starts Android emulator | +| Install deps | `just expo-install` | Runs `bun install` | +| Migrate hashes | `just expo-migrate` | Migrate card hashes to Convex | +| Type check | `just expo-typecheck` | TypeScript check | +| Start emulator | `just emu` | Virtual camera (submodule) | -### Database Generator Options +### Direct Bun Commands ```bash -just gen-db # Default: 500 cards with test images -dotnet run --project tools/DbGenerator -- -c 1000 # More cards -dotnet run --project tools/DbGenerator -- --force # Rebuild from scratch -dotnet run --project tools/DbGenerator -- --no-test-cards # Skip priority test cards +bun install # Install dependencies +bun run dev # Convex + Expo hot reload +bun run dev:convex # Convex dev server only +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 ``` -src/ -├── Scry.App/ # MAUI mobile app (Android target) -│ ├── Views/ # XAML pages (ScanPage, CollectionPage, etc.) -│ ├── ViewModels/ # MVVM ViewModels using CommunityToolkit.Mvvm -│ ├── Services/ # App-layer services (ICardRecognitionService, ICardRepository) -│ ├── Converters/ # XAML value converters -│ ├── Models/ # App-specific models (CollectionEntry) -│ └── Resources/Raw/ # Bundled card_hashes.db -│ -└── 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 +app/ # Expo Router pages +├── _layout.tsx # Root layout (Convex + HashCache providers) +├── modal.tsx # Card details modal +├── +not-found.tsx # 404 page +└── (tabs)/ # Tab navigation group + ├── _layout.tsx # Tab bar layout + ├── index.tsx # Collection tab (home) + ├── scan.tsx # Camera scan tab + └── settings.tsx # Settings tab -test/ -└── Scry.Tests/ # xUnit tests +components/ +├── 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/ -└── DbGenerator/ # CLI tool to generate card_hashes.db +convex/ # Backend (Convex functions + schema) +├── 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 -├── reference_alpha/ # Alpha/Beta cards for testing -├── single_cards/ # Individual card photos -├── varying_quality/ # Different lighting/quality -├── hands/ # Cards held in hand -├── foil/ # Foil cards with glare -└── ... # More categories +lib/ +├── recognition/ # Card recognition pipeline +│ ├── recognitionService.ts # Main recognition logic +│ ├── cardDetector.ts # Edge detection, find card quad +│ ├── perspectiveCorrection.ts # Warp to rectangle +│ ├── clahe.ts # CLAHE lighting normalization +│ ├── 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 @@ -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) │ └─────────────────────┘ │ ▼ ┌─────────────────────┐ -│ PerspectiveCorrection│ ← Warp to rectangle +│ warpPerspective │ ← Warp detected quad to rectangle └─────────────────────┘ │ ▼ ┌─────────────────────┐ -│ ImagePreprocessor │ ← CLAHE for lighting normalization -│ (ApplyClahe) │ +│ applyCLAHE │ ← Lighting normalization └─────────────────────┘ │ ▼ ┌─────────────────────┐ -│ PerceptualHash │ ← Compute 192-bit color hash (24 bytes) -│ (ComputeColorHash) │ +│ computeColorHash │ ← Compute 192-bit color hash (24 bytes) └─────────────────────┘ │ ▼ ┌─────────────────────┐ -│ CardRecognitionService│ ← Hamming distance match against DB +│ recognizeCard │ ← Hamming distance match against cache └─────────────────────┘ ``` -### Data Model +### Data Model (Convex Schema) -Three-table schema mirroring Scryfall: - -- **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 | +| Table | Purpose | |-------|---------| -| `CardRecognitionService` | Main recognition logic, caches DB, handles rotation matching | -| `PerceptualHash` | DCT-based color hash (192-bit = 8 bytes × 3 RGB channels) | -| `ImagePreprocessor` | CLAHE, resize, grayscale conversions | -| `CardDetector` | Edge detection + contour analysis to find card boundaries | -| `PerspectiveCorrection` | Warp detected quad to rectangle | -| `CardDatabase` | SQLite wrapper with batch insert, queries | -| `ScryfallClient` | Bulk data streaming, image downloads | +| `users` | Minimal auth (no PII, GDPR compliant) | +| `cards` | Card printings with 24-byte perceptual hashes | +| `oracles` | Abstract game cards (one per unique card name) | +| `sets` | MTG sets with metadata | +| `collections` | User card collections | +| `scanHistory` | Scan history with confidence scores | +| `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) -- **Nullable**: Enabled everywhere (`enable`) -- **Warnings as errors**: `true` -- **Central package management**: Versions in `Directory.Packages.props` +### Adaptive Camera System -### C# Style +The app detects its runtime environment and uses the appropriate camera: -- Records for data models (`record Card`, `record ScanResult`) -- `required` properties for non-nullable required fields -- 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 +- **Expo Go** (`Constants.appOwnership === "expo"`): Uses `expo-camera` +- **Production builds**: Uses `react-native-vision-camera` -### MVVM (App layer) - -- `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 +Both expose the same `CameraHandle` interface with `takePhoto()`. ## Key Algorithms @@ -206,86 +190,99 @@ Applied in LAB color space to L channel only: - Clip limit prevents over-amplification of noise - Bilinear interpolation between tiles for smooth output -### Card Detection +## Code Conventions -Pure SkiaSharp implementation: -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 +### General -## Debug Mode +- **TypeScript**: Strict mode enabled +- **Formatting**: Prettier defaults +- **Package Manager**: Bun (never use npm/yarn) -Set `RecognitionOptions.DebugOutputDirectory` to save pipeline stages: -- `01_input.png` - Original image -- `02_detection.png` - Card detection visualization -- `03_perspective_corrected.png` - Warped card -- `05_clahe_*.png` - After CLAHE preprocessing +### React/React Native -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 -- **Microsoft.Data.Sqlite** - SQLite database -- **Microsoft.Extensions.Options** - Options pattern +### Convex Backend -### 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 -- **CommunityToolkit.Maui.Camera** - Camera integration -- **CommunityToolkit.Mvvm** - MVVM source generators +## Environment Variables -### 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 -### Adding a New Card to Priority Test Set +### Adding a New Convex Function -1. Add image to `TestImages/reference_alpha/` or appropriate folder -2. Add entry to `GenerateCommand.PriorityCardsWithSets` dictionary -3. Run `just gen-db` to regenerate database +1. Add function to appropriate file in `convex/` (e.g., `cards.ts`) +2. Run `bunx convex dev` to regenerate types +3. Import from `convex/_generated/api` in client code -### Debugging Recognition Failures +### Testing Recognition -1. Enable debug output in `MauiProgram.cs`: - ```csharp - options.DebugOutputDirectory = "/sdcard/Download/scry-debug"; - ``` -2. Run recognition -3. Pull debug images: `adb pull /sdcard/Download/scry-debug` -4. Compare `05_clahe_*.png` with reference images in database +1. Add test images to `TestImages/` +2. Use the scan tab in the app +3. Check console logs for `[Scry]` prefixed messages -### Modifying Hash Algorithm +### Debugging Camera Issues -1. Update `PerceptualHash.ComputeColorHash()` -2. Update `CardRecognitionService.ColorHashBits` constant -3. Regenerate database: `just gen-db --force` -4. Run tests: `just test` +- In Expo Go: Uses `expo-camera`, check for "Dev mode" indicator +- In production: Uses Vision Camera, requires EAS build -## Gotchas +## Dependencies -1. **Hash size is 24 bytes (192 bits)** - 3 RGB channels × 8 bytes each -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 +### Core -## 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`): -- Builds for win-x64, linux-x64, osx-x64 -- Creates "standard" and "embedded" (with APK) variants -- Publishes to Forgejo releases +### Image Processing + +- `@shopify/react-native-skia` ^2.4.18 +- `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 - [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 diff --git a/Directory.Build.props b/Directory.Build.props deleted file mode 100644 index b9cdb07..0000000 --- a/Directory.Build.props +++ /dev/null @@ -1,10 +0,0 @@ - - - net10.0 - enable - enable - true - latest - true - - diff --git a/Directory.Packages.props b/Directory.Packages.props deleted file mode 100644 index baa520b..0000000 --- a/Directory.Packages.props +++ /dev/null @@ -1,29 +0,0 @@ - - - true - - - - - - - - - - - - - - - - - - - - diff --git a/Scry.slnx b/Scry.slnx deleted file mode 100644 index 8a2e845..0000000 --- a/Scry.slnx +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/app.json b/app.json new file mode 100644 index 0000000..f673b46 --- /dev/null +++ b/app.json @@ -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 + } + } +} diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..8454c7d --- /dev/null +++ b/app/(tabs)/_layout.tsx @@ -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["name"]; + color: string; +}) { + return ; +} + +export default function TabLayout() { + const colorScheme = useColorScheme(); + + return ( + + ( + + ), + }} + /> + , + }} + /> + , + }} + /> + + ); +} diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx new file mode 100644 index 0000000..3219241 --- /dev/null +++ b/app/(tabs)/index.tsx @@ -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(() => { + 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 }) => ( + + router.push({ + pathname: "/modal", + params: { + collectionEntryId: item.id, + cardId: item.cardId, + }, + }) + } + > + {item.imageUri ? ( + + ) : ( + + + + )} + {item.quantity > 1 && ( + + ×{item.quantity} + + )} + {item.isFoil && ( + + + + )} + + ), + [router] + ); + + const renderEmpty = useCallback( + () => ( + + + No Cards Yet + + {userId + ? "Start scanning cards to build your collection!" + : "Sign in to start building your collection!"} + + + ), + [userId] + ); + + // Loading state + if (rawCollection === undefined && userId) { + return ( + + + Loading collection... + + ); + } + + // Total cards count + const totalCount = collection.reduce((sum, item) => sum + item.quantity, 0); + + return ( + + {/* Search bar */} + + + + {searchQuery.length > 0 && ( + setSearchQuery("")}> + + + )} + + + {/* Stats and sort bar */} + + + {sortedCollection.length} unique + {collection.length > 0 && ` (${totalCount} total)`} + + + + setSortBy("recent")} + > + + Recent + + + setSortBy("name")} + > + + Name + + + setSortBy("set")} + > + + Set + + + + + + {/* Card grid */} + item.id} + numColumns={3} + contentContainerStyle={styles.gridContent} + ListEmptyComponent={renderEmpty} + refreshControl={ + + } + /> + + ); +} + +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, + }, +}); diff --git a/app/(tabs)/scan.tsx b/app/(tabs)/scan.tsx new file mode 100644 index 0000000..9ad3d12 --- /dev/null +++ b/app/(tabs)/scan.tsx @@ -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(null); + + // Local scan state + const [isProcessing, setIsProcessing] = useState(false); + const [flashEnabled, setFlashEnabled] = useState(false); + const [isAddingToCollection, setIsAddingToCollection] = useState(false); + const [lastScanResult, setLastScanResult] = useState(null); + const [scanError, setScanError] = useState(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 ( + + + Checking camera permission... + + ); + } + + // Permission denied + if (!hasPermission) { + return ( + + + Camera Access Required + + Scry needs camera access to scan your Magic cards. + + + Enable Camera + + + ); + } + + return ( + + {/* Adaptive camera - uses expo-camera in Expo Go, Vision Camera in production */} + + + {/* Overlay with scan box */} + + {/* Top dark area */} + + + {/* Middle row with scan box */} + + + + {/* Corner markers */} + + + + + + + + + {/* Bottom dark area */} + + + + {/* Instructions */} + + + {hashesLoaded + ? `Position card in frame • ${cardHashes.length} cards loaded` + : "Loading card database..."} + + {isExpoGo && ( + Dev mode (expo-camera) + )} + + + {/* Scan result overlay */} + {lastScanResult && ( + + + {lastScanResult.imageUri && ( + + )} + + {lastScanResult.cardName} + {lastScanResult.setCode.toUpperCase()} + + {Math.round(lastScanResult.confidence * 100)}% match + + + + {isAddingToCollection ? ( + + ) : ( + <> + + Add + + )} + + + + )} + + {/* Error message */} + {scanError && ( + + {scanError} + + Dismiss + + + )} + + {/* Controls */} + + {/* Flash toggle */} + setFlashEnabled(!flashEnabled)}> + + + + {/* Capture button */} + + {isProcessing ? ( + + ) : ( + + )} + + + {/* Placeholder for symmetry */} + + + + ); +} + +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", + }, +}); diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx new file mode 100644 index 0000000..5fa9c83 --- /dev/null +++ b/app/(tabs)/settings.tsx @@ -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["name"]; + title: string; + subtitle?: string; + onPress?: () => void; + rightElement?: React.ReactNode; + destructive?: boolean; +} + +function SettingRow({ + icon, + title, + subtitle, + onPress, + rightElement, + destructive, +}: SettingRowProps) { + return ( + [ + styles.settingRow, + pressed && onPress && styles.settingRowPressed, + ]} + onPress={onPress} + disabled={!onPress} + > + + + + + + {title} + + {subtitle && {subtitle}} + + {rightElement || (onPress && )} + + ); +} + +function SettingSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( + + {title} + {children} + + ); +} + +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 ( + + {/* Database section */} + + : undefined} + /> + : undefined} + /> + {syncError && ( + + )} + + + {/* Recognition section */} + + + } + /> + + } + /> + + + {/* Collection section */} + + + { + // TODO: Implement export + }} + /> + + + {/* About section */} + + + { + // TODO: Open source URL + }} + /> + + + {/* Danger zone */} + + { + 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 + /> + + + + {/* Footer */} + + Scry • Card scanner for Magic: The Gathering + Card data © Wizards of the Coast + + + ); +} + +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, + }, +}); diff --git a/app/+html.tsx b/app/+html.tsx new file mode 100644 index 0000000..cb31090 --- /dev/null +++ b/app/+html.tsx @@ -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 ( + + + + + + + {/* + 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. + */} + + + {/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */} + - - - - diff --git a/src/Scry.App/AppShell.xaml.cs b/src/Scry.App/AppShell.xaml.cs deleted file mode 100644 index c0d57ff..0000000 --- a/src/Scry.App/AppShell.xaml.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Scry.Views; - -namespace Scry; - -public partial class AppShell : Shell -{ - public AppShell() - { - InitializeComponent(); - - Routing.RegisterRoute(nameof(CardDetailPage), typeof(CardDetailPage)); - } -} diff --git a/src/Scry.App/Converters/BoolToScanTextConverter.cs b/src/Scry.App/Converters/BoolToScanTextConverter.cs deleted file mode 100644 index de0eec1..0000000 --- a/src/Scry.App/Converters/BoolToScanTextConverter.cs +++ /dev/null @@ -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(); - } -} diff --git a/src/Scry.App/Converters/InverseBoolConverter.cs b/src/Scry.App/Converters/InverseBoolConverter.cs deleted file mode 100644 index 9d32a3a..0000000 --- a/src/Scry.App/Converters/InverseBoolConverter.cs +++ /dev/null @@ -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; - } -} diff --git a/src/Scry.App/Converters/StringNotEmptyConverter.cs b/src/Scry.App/Converters/StringNotEmptyConverter.cs deleted file mode 100644 index d549f8f..0000000 --- a/src/Scry.App/Converters/StringNotEmptyConverter.cs +++ /dev/null @@ -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(); - } -} diff --git a/src/Scry.App/MauiProgram.cs b/src/Scry.App/MauiProgram.cs deleted file mode 100644 index ebeca2a..0000000 --- a/src/Scry.App/MauiProgram.cs +++ /dev/null @@ -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() - .UseMauiCommunityToolkit() - .UseMauiCommunityToolkitCamera() - .ConfigureFonts(fonts => - { - fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); - fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); - }); - - // Core Services (from Scry.Core) - builder.Services.AddSingleton(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(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(); - - // App Services - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - - // ViewModels - builder.Services.AddTransient(); - builder.Services.AddTransient(); - builder.Services.AddTransient(); - builder.Services.AddTransient(); - - // Views - builder.Services.AddTransient(); - builder.Services.AddTransient(); - builder.Services.AddTransient(); - builder.Services.AddTransient(); - -#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 - } - } -} diff --git a/src/Scry.App/Models/CollectionEntry.cs b/src/Scry.App/Models/CollectionEntry.cs deleted file mode 100644 index 4b523d2..0000000 --- a/src/Scry.App/Models/CollectionEntry.cs +++ /dev/null @@ -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; -} diff --git a/src/Scry.App/Platforms/Android/AndroidManifest.xml b/src/Scry.App/Platforms/Android/AndroidManifest.xml deleted file mode 100644 index a3ec4f5..0000000 --- a/src/Scry.App/Platforms/Android/AndroidManifest.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/src/Scry.App/Platforms/Android/MainActivity.cs b/src/Scry.App/Platforms/Android/MainActivity.cs deleted file mode 100644 index e569cf3..0000000 --- a/src/Scry.App/Platforms/Android/MainActivity.cs +++ /dev/null @@ -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 -{ -} diff --git a/src/Scry.App/Platforms/Android/MainApplication.cs b/src/Scry.App/Platforms/Android/MainApplication.cs deleted file mode 100644 index 0e985c5..0000000 --- a/src/Scry.App/Platforms/Android/MainApplication.cs +++ /dev/null @@ -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(); -} diff --git a/src/Scry.App/Platforms/Android/Resources/values/colors.xml b/src/Scry.App/Platforms/Android/Resources/values/colors.xml deleted file mode 100644 index 614ac41..0000000 --- a/src/Scry.App/Platforms/Android/Resources/values/colors.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - #512BD4 - #3B1F9E - #512BD4 - diff --git a/src/Scry.App/Resources/AppIcon/appicon.svg b/src/Scry.App/Resources/AppIcon/appicon.svg deleted file mode 100644 index 86e49b4..0000000 --- a/src/Scry.App/Resources/AppIcon/appicon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/Scry.App/Resources/AppIcon/appiconfg.svg b/src/Scry.App/Resources/AppIcon/appiconfg.svg deleted file mode 100644 index 76d01d6..0000000 --- a/src/Scry.App/Resources/AppIcon/appiconfg.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/Scry.App/Resources/Fonts/.gitkeep b/src/Scry.App/Resources/Fonts/.gitkeep deleted file mode 100644 index dcf2c80..0000000 --- a/src/Scry.App/Resources/Fonts/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# Placeholder diff --git a/src/Scry.App/Resources/Raw/.gitkeep b/src/Scry.App/Resources/Raw/.gitkeep deleted file mode 100644 index dcf2c80..0000000 --- a/src/Scry.App/Resources/Raw/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# Placeholder diff --git a/src/Scry.App/Resources/Splash/splash.svg b/src/Scry.App/Resources/Splash/splash.svg deleted file mode 100644 index be886b2..0000000 --- a/src/Scry.App/Resources/Splash/splash.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - SCRY - diff --git a/src/Scry.App/Resources/Styles/Colors.xaml b/src/Scry.App/Resources/Styles/Colors.xaml deleted file mode 100644 index 9f295a7..0000000 --- a/src/Scry.App/Resources/Styles/Colors.xaml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - #512BD4 - #3B1F9E - White - #DFD8F7 - #3B1F9E - #2B0B98 - - White - Black - #E1E1E1 - #C8C8C8 - #ACACAC - #919191 - #6E6E6E - #404040 - #2A2A2A - #1A1A1A - #141414 - - #F7B548 - #FFD590 - #FFE5B9 - #28C2D1 - #7BDDEF - #C3F2F4 - - - - - - - - - - - - - - - - diff --git a/src/Scry.App/Resources/Styles/Styles.xaml b/src/Scry.App/Resources/Styles/Styles.xaml deleted file mode 100644 index a1c5607..0000000 --- a/src/Scry.App/Resources/Styles/Styles.xaml +++ /dev/null @@ -1,184 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Scry.App/Scry.App.csproj b/src/Scry.App/Scry.App.csproj deleted file mode 100644 index 3a04d80..0000000 --- a/src/Scry.App/Scry.App.csproj +++ /dev/null @@ -1,103 +0,0 @@ - - - - - $(LOCALAPPDATA)\Android\Sdk - C:\Program Files\Microsoft\jdk-21.0.10.7-hotspot - - - - net10.0-android - android-arm64;android-x64 - true - - Exe - Scry - true - true - enable - enable - - - - - true - true - true - - - - - - - - - - Scry - - - land.charm.scry - - - 1.0 - 1 - - 21.0 - 15.0 - 15.0 - 10.0.17763.0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Scry.App/Services/ICardRecognitionService.cs b/src/Scry.App/Services/ICardRecognitionService.cs deleted file mode 100644 index c481e11..0000000 --- a/src/Scry.App/Services/ICardRecognitionService.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Scry.Core.Models; - -namespace Scry.Services; - -public interface ICardRecognitionService -{ - Task RecognizeCardAsync(Stream imageStream, CancellationToken cancellationToken = default); -} diff --git a/src/Scry.App/Services/ICardRepository.cs b/src/Scry.App/Services/ICardRepository.cs deleted file mode 100644 index 2eaa3fa..0000000 --- a/src/Scry.App/Services/ICardRepository.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Scry.Core.Models; -using Scry.Models; - -namespace Scry.Services; - -public interface ICardRepository -{ - IReadOnlyList 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; } -} diff --git a/src/Scry.App/Services/InMemoryCardRepository.cs b/src/Scry.App/Services/InMemoryCardRepository.cs deleted file mode 100644 index e56f8d2..0000000 --- a/src/Scry.App/Services/InMemoryCardRepository.cs +++ /dev/null @@ -1,103 +0,0 @@ -using Scry.Core.Models; -using Scry.Models; - -namespace Scry.Services; - -public class InMemoryCardRepository : ICardRepository -{ - private readonly List _entries = []; - private readonly object _lock = new(); - - public IReadOnlyList 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; - } - } - } -} diff --git a/src/Scry.App/Services/MockCardRecognitionService.cs b/src/Scry.App/Services/MockCardRecognitionService.cs deleted file mode 100644 index a2db310..0000000 --- a/src/Scry.App/Services/MockCardRecognitionService.cs +++ /dev/null @@ -1,129 +0,0 @@ -using Scry.Core.Models; - -namespace Scry.Services; - -/// -/// Mock implementation that returns random MTG cards for testing. -/// Replace with RealCardRecognitionService for production use. -/// -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 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)); - } -} diff --git a/src/Scry.App/Services/RealCardRecognitionService.cs b/src/Scry.App/Services/RealCardRecognitionService.cs deleted file mode 100644 index 1afb57f..0000000 --- a/src/Scry.App/Services/RealCardRecognitionService.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Scry.Core.Models; -using Scry.Core.Recognition; - -namespace Scry.Services; - -/// -/// Real implementation that uses Scry.Core's perceptual hash-based card recognition. -/// -public class RealCardRecognitionService : ICardRecognitionService -{ - private readonly CardRecognitionService _recognitionService; - - public RealCardRecognitionService(CardRecognitionService recognitionService) - { - _recognitionService = recognitionService; - } - - public async Task RecognizeCardAsync(Stream imageStream, CancellationToken cancellationToken = default) - { - return await _recognitionService.RecognizeAsync(imageStream, cancellationToken); - } -} diff --git a/src/Scry.App/ViewModels/CardDetailViewModel.cs b/src/Scry.App/ViewModels/CardDetailViewModel.cs deleted file mode 100644 index f50c2ff..0000000 --- a/src/Scry.App/ViewModels/CardDetailViewModel.cs +++ /dev/null @@ -1,86 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Scry.Core.Models; -using Scry.Models; -using Scry.Services; - -namespace Scry.ViewModels; - -[QueryProperty(nameof(CardId), "cardId")] -[QueryProperty(nameof(EntryId), "entryId")] -public partial class CardDetailViewModel : ObservableObject -{ - private readonly ICardRepository _cardRepository; - - [ObservableProperty] - private Card? _card; - - [ObservableProperty] - private CollectionEntry? _entry; - - [ObservableProperty] - private bool _isInCollection; - - [ObservableProperty] - private string? _cardId; - - [ObservableProperty] - private string? _entryId; - - public CardDetailViewModel(ICardRepository cardRepository) - { - _cardRepository = cardRepository; - } - - partial void OnEntryIdChanged(string? value) - { - if (string.IsNullOrEmpty(value)) return; - - Entry = _cardRepository.GetById(value); - if (Entry != null) - { - Card = Entry.Card; - IsInCollection = true; - } - } - - partial void OnCardIdChanged(string? value) - { - if (string.IsNullOrEmpty(value)) return; - - // For now, find by looking through collection - var entries = _cardRepository.GetAll(); - Entry = entries.FirstOrDefault(e => e.Card.Id == value); - if (Entry != null) - { - Card = Entry.Card; - IsInCollection = true; - } - } - - [RelayCommand] - private void IncrementQuantity() - { - if (Entry == null) return; - _cardRepository.UpdateQuantity(Entry.Id, Entry.Quantity + 1); - Entry = _cardRepository.GetById(Entry.Id); - } - - [RelayCommand] - private void DecrementQuantity() - { - if (Entry == null) return; - - if (Entry.Quantity > 1) - { - _cardRepository.UpdateQuantity(Entry.Id, Entry.Quantity - 1); - Entry = _cardRepository.GetById(Entry.Id); - } - } - - [RelayCommand] - private async Task GoBackAsync() - { - await Shell.Current.GoToAsync(".."); - } -} diff --git a/src/Scry.App/ViewModels/CollectionViewModel.cs b/src/Scry.App/ViewModels/CollectionViewModel.cs deleted file mode 100644 index 677e921..0000000 --- a/src/Scry.App/ViewModels/CollectionViewModel.cs +++ /dev/null @@ -1,84 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Scry.Core.Models; -using Scry.Models; -using Scry.Services; -using Scry.Views; -using System.Collections.ObjectModel; - -namespace Scry.ViewModels; - -public partial class CollectionViewModel : ObservableObject -{ - private readonly ICardRepository _cardRepository; - - [ObservableProperty] - private ObservableCollection _entries = []; - - [ObservableProperty] - private int _totalCards; - - [ObservableProperty] - private int _uniqueCards; - - [ObservableProperty] - private bool _isEmpty = true; - - public CollectionViewModel(ICardRepository cardRepository) - { - _cardRepository = cardRepository; - } - - [RelayCommand] - private void LoadCollection() - { - var allEntries = _cardRepository.GetAll(); - Entries = new ObservableCollection(allEntries.OrderByDescending(e => e.AddedAt)); - TotalCards = _cardRepository.TotalCards; - UniqueCards = _cardRepository.UniqueCards; - IsEmpty = Entries.Count == 0; - } - - [RelayCommand] - private async Task ViewCardAsync(CollectionEntry entry) - { - await Shell.Current.GoToAsync($"{nameof(CardDetailPage)}?entryId={entry.Id}"); - } - - [RelayCommand] - private void IncrementQuantity(CollectionEntry entry) - { - _cardRepository.UpdateQuantity(entry.Id, entry.Quantity + 1); - LoadCollection(); - } - - [RelayCommand] - private void DecrementQuantity(CollectionEntry entry) - { - if (entry.Quantity > 1) - { - _cardRepository.UpdateQuantity(entry.Id, entry.Quantity - 1); - } - else - { - _cardRepository.Remove(entry.Id); - } - LoadCollection(); - } - - [RelayCommand] - private async Task RemoveEntryAsync(CollectionEntry entry) - { - var result = await Shell.Current.DisplayAlertAsync( - "Remove Card", - $"Remove all {entry.Quantity}x {entry.Card.Name} from collection?", - "Remove", - "Cancel"); - - if (result) - { - _cardRepository.Remove(entry.Id); - LoadCollection(); - } - } -} diff --git a/src/Scry.App/ViewModels/ScanViewModel.cs b/src/Scry.App/ViewModels/ScanViewModel.cs deleted file mode 100644 index 8d1951a..0000000 --- a/src/Scry.App/ViewModels/ScanViewModel.cs +++ /dev/null @@ -1,130 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using CommunityToolkit.Maui.Views; -using Scry.Core.Models; -using Scry.Services; -using Scry.Views; - -namespace Scry.ViewModels; - -public partial class ScanViewModel : ObservableObject -{ - private readonly ICardRecognitionService _recognitionService; - private readonly ICardRepository _cardRepository; - - [ObservableProperty] - private bool _isScanning; - - [ObservableProperty] - private bool _isProcessing; - - [ObservableProperty] - private string? _statusMessage; - - [ObservableProperty] - private Card? _lastScannedCard; - - [ObservableProperty] - private float _lastConfidence; - - [ObservableProperty] - private bool _hasResult; - - [ObservableProperty] - private bool _isFoil; - - public ScanViewModel( - ICardRecognitionService recognitionService, - ICardRepository cardRepository) - { - _recognitionService = recognitionService; - _cardRepository = cardRepository; - StatusMessage = "Point camera at a card and tap Scan"; - } - - [RelayCommand] - private async Task CaptureAndRecognizeAsync(CameraView cameraView) - { - if (IsProcessing) return; - - try - { - IsProcessing = true; - StatusMessage = "Capturing..."; - - // Capture image from camera - using var imageStream = await cameraView.CaptureImage(CancellationToken.None); - if (imageStream == null) - { - StatusMessage = "Failed to capture image"; - return; - } - - StatusMessage = "Recognizing card..."; - - // Copy to memory stream since the camera stream might not be seekable - using var memoryStream = new MemoryStream(); - await imageStream.CopyToAsync(memoryStream); - memoryStream.Position = 0; - - var result = await _recognitionService.RecognizeCardAsync(memoryStream); - - if (result.Success && result.Card != null) - { - LastScannedCard = result.Card; - LastConfidence = result.Confidence; - HasResult = true; - StatusMessage = $"Found: {result.Card.Name} ({result.Confidence:P0})"; - } - else - { - HasResult = false; - StatusMessage = result.ErrorMessage ?? "Recognition failed"; - } - } - catch (Exception ex) - { - StatusMessage = $"Error: {ex.Message}"; - HasResult = false; - } - finally - { - IsProcessing = false; - } - } - - [RelayCommand] - private async Task AddToCollectionAsync() - { - if (LastScannedCard == null) return; - - _cardRepository.Add(LastScannedCard, 1, IsFoil); - - var foilText = IsFoil ? " (Foil)" : ""; - StatusMessage = $"Added {LastScannedCard.Name}{foilText} to collection!"; - - // Reset for next scan - await Task.Delay(1500); - HasResult = false; - LastScannedCard = null; - IsFoil = false; - StatusMessage = "Point camera at a card and tap Scan"; - } - - [RelayCommand] - private void CancelResult() - { - HasResult = false; - LastScannedCard = null; - IsFoil = false; - StatusMessage = "Point camera at a card and tap Scan"; - } - - [RelayCommand] - private async Task ViewCardDetailsAsync() - { - if (LastScannedCard == null) return; - - await Shell.Current.GoToAsync($"{nameof(CardDetailPage)}?cardId={LastScannedCard.Id}"); - } -} diff --git a/src/Scry.App/ViewModels/SettingsViewModel.cs b/src/Scry.App/ViewModels/SettingsViewModel.cs deleted file mode 100644 index 407a913..0000000 --- a/src/Scry.App/ViewModels/SettingsViewModel.cs +++ /dev/null @@ -1,36 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Scry.Core.Data; - -namespace Scry.ViewModels; - -public partial class SettingsViewModel : ObservableObject -{ - private readonly CardDatabase _database; - - [ObservableProperty] - private int _cardCount; - - [ObservableProperty] - private int _oracleCount; - - [ObservableProperty] - private int _setCount; - - [ObservableProperty] - private string? _statusMessage; - - public SettingsViewModel(CardDatabase database) - { - _database = database; - } - - [RelayCommand] - private async Task LoadAsync() - { - CardCount = await _database.GetCardCountAsync(); - OracleCount = await _database.GetOracleCountAsync(); - SetCount = await _database.GetSetCountAsync(); - StatusMessage = $"Database ready: {CardCount:N0} cards, {OracleCount:N0} oracles, {SetCount:N0} sets"; - } -} diff --git a/src/Scry.App/Views/CardDetailPage.xaml b/src/Scry.App/Views/CardDetailPage.xaml deleted file mode 100644 index 4432868..0000000 --- a/src/Scry.App/Views/CardDetailPage.xaml +++ /dev/null @@ -1,145 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - -