diff --git a/.gitignore b/.gitignore index 5d22002..1342ed4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .build/ +build/ .swiftpm/ DerivedData/ Package.resolved diff --git a/.swiftlint.yml b/.swiftlint.yml index e2f82d1..461ed54 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,6 +1,10 @@ disabled_rules: - todo - trailing_comma + # opening_brace kollidiert mit SwiftFormats `wrapMultilineStatementBraces`, + # das bei Multi-Line-Conditions das `{` auf eine eigene Zeile wirft. + # SwiftFormat gewinnt — ist im Pre-Commit-Hook. + - opening_brace opt_in_rules: - empty_count @@ -18,8 +22,31 @@ line_length: identifier_name: min_length: 2 excluded: + # Standard-Identifier - id - ok + # Mathematische/algorithmische Konventionen (loops, indizes, + # Koordinaten, distances) — kürzer ist hier klarer als verbose. + - i + - j + - n + - m + - x + - y + - w + - h + - r + - g + - b + - a + - c + - d + - s + - f + - p + - q + - t + - l included: - Sources diff --git a/CLAUDE.md b/CLAUDE.md index cf17405..7ea8a1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# CLAUDE.md — cards-native repo +# CLAUDE.md — wordeck-native repo Guidance für Claude Code in diesem Repository. @@ -10,27 +10,27 @@ Guidance für Claude Code in diesem Repository. ## Was dieses Repo ist **Cards Native** — native SwiftUI-Universal-App (iOS / iPadOS / macOS) -für **Cardecky**, die Spaced-Repetition-Karten-App des Vereins -**mana e.V.** Web-Parität zu `cardecky.mana.how`, plus native iOS- +für **Wordeck**, die Spaced-Repetition-Karten-App des Vereins +**mana e.V.** Web-Parität zu `wordeck.com`, plus native iOS- Affordances (Widgets, Notifications, Universal-Links, Pencil). ``` HTTPS/JWT ┌──────────────────┐ - cards-api ◄───────────── │ cards-native │ SwiftUI - cardecky-api.mana.how │ (this repo) │ SwiftData (Cache) - │ ev.mana.cardecky │ WidgetKit (β-6) + cards-api ◄───────────── │ wordeck-native │ SwiftUI + api.wordeck.com │ (this repo) │ SwiftData (Cache) + │ ev.mana.wordeck │ WidgetKit (β-6) └──────────────────┘ ``` ## Status **Phase β-0 — Setup (2026-05-12).** Repo-Skelett, ManaCore + ManaTokens -als Package-Dependency, Login + Cardecky-API-Reachability-Probe. +als Package-Dependency, Login + Wordeck-API-Reachability-Probe. Phasen β-1 bis β-7 in `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`. ## Leitprinzip: Web-Parität -Die Web-App auf `cardecky.mana.how` ist Funktions-Referenz. Bei +Die Web-App auf `wordeck.com` ist Funktions-Referenz. Bei Konflikt zwischen Native und Web → **Web gewinnt**. Native ist Re-Implementation, kein neues Produkt. @@ -53,12 +53,12 @@ Beschlossen. Nicht ohne explizite Diskussion antasten. Auth-Implementierung ist verboten. 4. **Pure SwiftUI.** Keine externen UI-Libraries. AppKit/UIKit nur als Bridge wenn zwingend (z.B. `PencilKit` für Image-Occlusion). -5. **Bundle-ID `ev.mana.cardecky`.** Reverse-Domain mana-ev.ch. - Universal-Link-Domain: `cardecky.mana.how`. +5. **Bundle-ID `ev.mana.wordeck`.** Reverse-Domain mana-ev.ch. + Universal-Link-Domain: `wordeck.com`. 6. **Cards-Domain-Logik bleibt am Server.** SubIndex-Berechnung für Cloze, Image-Occlusion-Mask-Validation, Content-Hash — alles Server. Native zeigt nur, was vom Server kommt. -7. **`forest`-Theme.** Heute lokal in `CardsTheme.swift` nachgebaut +7. **`forest`-Theme.** Heute lokal in `WordeckTheme.swift` nachgebaut (Werte gespiegelt aus `mana/packages/themes/src/variants/forest.css`). Migration auf ManaTokens-Theme-Switch ist Phase ε. 8. **Web gewinnt bei Konflikt.** Eleganteres Native-Verhalten geht @@ -74,16 +74,16 @@ Beschlossen. Nicht ohne explizite Diskussion antasten. sind **nicht** im Git - **SwiftFormat** mit `.swiftformat` (4-space, 120-col, sorted imports) - **SwiftLint** mit `.swiftlint.yml` -- **Logging:** App-Subsystem `ev.mana.cardecky` via +- **Logging:** App-Subsystem `ev.mana.wordeck` via `Sources/Core/Telemetry/Log.swift`. ManaCore loggt parallel unter `ev.mana.core` - **Persistenz:** SwiftData für Deck/Card-Cache (ab β-1), JWT im Keychain (über ManaCore) - **Lokalisierung:** DE primary, EN fallback via `Localizable.xcstrings` -## Cardecky-API-Wire-Format +## Wordeck-API-Wire-Format -Wire-Format gegen `https://cardecky-api.mana.how/api/v1/*`. Quelle der +Wire-Format gegen `https://api.wordeck.com/api/v1/*`. Quelle der Wahrheit: `../cards/apps/api/src/routes/*.ts`. Bei neuem DTO verifizieren: @@ -95,14 +95,14 @@ verifizieren: ## Repo-Layout ``` -cards-native/ +wordeck-native/ ├── project.yml XcodeGen-Manifest (SOT) ├── PLAN.md Phase-Tracking (gekürzt aus Greenfield-Plan) ├── CLAUDE.md dieses File ├── README.md ├── .swiftformat, .swiftlint.yml ├── Sources/ -│ ├── App/ CardsNativeApp (@main), RootView +│ ├── App/ WordeckNativeApp (@main), RootView │ ├── Features/ │ │ ├── Account/ LoginView, AccountView (ab β-1) │ │ ├── Decks/ DashboardView (Placeholder), DeckList (β-1) @@ -113,19 +113,19 @@ cards-native/ │ │ └── Imports/ (β-3) │ ├── Core/ │ │ ├── Auth/ AppConfig (ManaAppConfig-Provider) -│ │ ├── API/ CardsAPI (AuthenticatedTransport-Wrapper) +│ │ ├── API/ WordeckAPI (AuthenticatedTransport-Wrapper) │ │ ├── Domain/ (Card-Type-Enums, Rating-Enum — ab β-2) │ │ ├── Storage/ (SwiftData-Models — ab β-1) │ │ ├── Sync/ (ReviewQueue, MediaCache — ab β-2/β-4) -│ │ ├── Telemetry/ OSLog (Subsystem ev.mana.cardecky) -│ │ └── Theme/ CardsTheme (forest-Werte) +│ │ ├── Telemetry/ OSLog (Subsystem ev.mana.wordeck) +│ │ └── Theme/ WordeckTheme (forest-Werte) │ ├── Widgets/ (WidgetKit-Extension — ab β-6) │ ├── ShareExtension/ (Save-as-Card — ab β-6) │ └── Resources/ │ ├── Assets.xcassets │ ├── Localizable.xcstrings │ ├── Info.plist (generiert, gitignored) -│ └── CardsNative.entitlements (generiert, gitignored) +│ └── WordeckNative.entitlements (generiert, gitignored) ├── Tests/ │ ├── UnitTests/ │ └── UITests/ @@ -156,7 +156,7 @@ cards-native/ **Workflow:** ```bash xcodegen generate -open CardsNative.xcodeproj +open WordeckNative.xcodeproj ``` **Vor jedem Commit:** diff --git a/PLAN.md b/PLAN.md index b1dbd35..c6b595c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,13 +1,20 @@ -# Plan — cards-native (SwiftUI Universal) +# Plan — wordeck-native (SwiftUI Universal) -**Stand: 2026-05-13 — Phasen β-0 bis β-7 abgeschlossen.** -Feature-komplett für TestFlight. Alle 7 Card-Types + Marketplace -+ Keyboard/Daily-Reminder/Widget + Siri-Shortcut + Share-Extension -+ App-Icon-Platzhalter + Release-Checklist. 35 Unit-Tests + 1 UI-Test -grün, alle drei Targets (Haupt-App + Widget + Share) bauen. +**Stand: 2026-05-13 — TestFlight Build 11 (v0.9.4).** Alle Phasen +β-0 bis β-7 + Polish-Iterationen. 43 Unit-Tests + 1 UI-Test grün. + +**Wordeck-Web-Look übernommen** (v0.9.0 ff.): Fan-Stack-Tiles +(5:7 Aspect, 3 rotierte Background-Layer), CardSurface in +md/lg/hero, RatingBar mit Good-Emphasis. Tap auf Tile = Study- +Mode, Pencil-Icon unten rechts = Deck-Detail. Identische Tile- +Optik in Decks- und Entdecken-Tab. + +**App-Store-Connect:** Wordeck, App-ID 6769019526, Bundle +`ev.mana.wordeck`, Team `QP3GLU8PH3`. AASA + /privacy + /help +live unter `wordeck.com`. Pflicht-Check für β-2: Endurance-Test auf realem Gerät (200+ Karten -mit Flugmodus zwischendurch) steht aus — Aufgabe für Till. +mit Flugmodus zwischendurch) steht aus. > **SOT:** `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`. > Dieses File ist die App-lokale Status-Spur, das Greenfield-Doc @@ -16,18 +23,52 @@ mit Flugmodus zwischendurch) steht aus — Aufgabe für Till. ## Aktueller Stand ✅ **β-0 — Setup (2026-05-12, Tag `v0.1.0`)** -- Repo-Skelett unter `git.mana.how/till/cards-native` -- `project.yml` mit Bundle-ID `ev.mana.cardecky`, ManaSwiftCore via +- Repo-Skelett unter `git.mana.how/till/wordeck-native` +- `project.yml` mit Bundle-ID `ev.mana.wordeck`, ManaSwiftCore via `path: ../mana-swift-core` - `AppConfig` als `ManaAppConfig`-Provider: - Auth: `https://auth.mana.how` - - API: `https://cardecky-api.mana.how` - - Keychain-Service: `ev.mana.cardecky` -- `CardsTheme.swift` mit forest-Werten (lokal nachgebaut aus + - API: `https://api.wordeck.com` + - Keychain-Service: `ev.mana.wordeck` +- `WordeckTheme.swift` mit forest-Werten (lokal nachgebaut aus `mana/packages/themes/src/variants/forest.css`) - `LoginView` (Email/PW gegen mana-auth) - 3 Unit-Tests (AppConfig) +✅ **Post-β-7-Polish (2026-05-13, Tags v0.8.1 → v0.9.4)** + +Live-Fixes nach Apple-Submission und Real-Device-Tests: +- `v0.8.1` Wordeck-Rebrand (Bundle `ev.mana.cards` → `ev.mana.wordeck`, + AASA + Docker-Compose-Env nachgezogen) +- `v0.8.2` Archive-Polish — Versions-Sync zwischen Targets, iPad- + Orientations +- `v0.8.3` Sendable + AppIcon-Asset-Cleanup, /privacy + /help Stubs +- `v0.8.4` PhotosPicker Sendable-Warning via Sub-View-Struct +- `v0.8.5` ITMS-90129-Fix: DisplayName Cards → Wordeck, Build 2 +- `v0.8.6` Wordeck-Rebrand User-facing Strings durchgängig +- `v0.8.7` PublicDeckOwner.pseudonym Bool statt String? (Decoder-Crash + bei Marketplace-Deck-Open) +- `v0.8.8` Card-Liste in DeckDetailView + WordeckAPI.listCards +- `v0.8.9` URL-Query-Bug-Fix in ManaCore.AuthenticatedTransport + (URL.appending(path:) encoded `?` → 404; gefixt via String-Concat, + ManaCore v1.0.1). Behob alle "0-Karten"-Phänomene und das stille + Schlucken von Query-Endpoints. +- `v0.9.0` Wordeck-Web-Design: Fan-Stack-Tiles, CardSurface in + 3 Sizes, RatingBar mit Good-Emphasis, horizontale Scroll-Sections +- `v0.9.1` Multiple-Choice-Karten gerendert (Distractors via Server, + Tap-Selektion, Reveal mit Korrekt/Falsch-Highlight) +- `v0.9.2` Typing-Karten gerendert (Levenshtein-Match 1:1 aus + cards-domain portiert: correct/close/wrong, Aliases-Support, + Diakritika-Normalisierung) +- `v0.9.3` DEBUG-Auto-Login analog memoro-native (`ensureSignedIn()` + in #if DEBUG) — auch in manaspur-native nachgezogen +- `v0.9.4` Tile-Tap = Study-Mode direkt, Pencil-Edit-Icon unten + rechts → DeckDetail, ExploreView mit gleichem Tile-Layout + (5:7 Aspect, Kategorie-Icon oben rechts in primary) + +**Schema-Fix:** cards-Repo Commit `4d905bb` (0002_decks_archived_at) +gleicht Schema-Drift in der Production-DB aus. + ✅ **β-7 — App-Store-Vorbereitung (2026-05-13, Tag `v0.8.0`)** - App-Icon-Platzhalter: `scripts/make-appicon.swift` generiert 1024×1024 PNG aus CoreGraphics (forest-green + "C"-Letter). Asset-Catalog auf @@ -63,17 +104,17 @@ mit Flugmodus zwischendurch) steht aus — Aufgabe für Till. - `WidgetSnapshot` Codable mit `topDecks` (Top-3 nach dueCount) und `totalDueCount` - `WidgetSnapshotStore` schreibt in App-Group-Container - `group.ev.mana.cardecky` + `group.ev.mana.wordeck` - `DeckListStore.refresh` ruft `updateWidgetSnapshot()` und `WidgetCenter.shared.reloadAllTimelines()` nach jedem Pull -- `CardsWidgetExtension`-Target (eigenes app-extension-Bundle): - `CardsWidgetBundle` + `CardsDueWidget` mit `StaticConfiguration`, +- `WordeckWidgetExtension`-Target (eigenes app-extension-Bundle): + `WordeckWidgetBundle` + `CardsDueWidget` mit `StaticConfiguration`, Support für systemSmall, systemMedium, accessoryCircular, accessoryInline, accessoryRectangular - `DueProvider` als `TimelineProvider`: liest Snapshot, plant Refresh alle 30 min (plus instant-Refresh via Haupt-App) - `DueWidgetView` mit Family-Switch, alle 5 Family-Layouts -- `com.apple.security.application-groups: group.ev.mana.cardecky` +- `com.apple.security.application-groups: group.ev.mana.wordeck` im Haupt- und Widget-Entitlement - `WidgetSnapshot.swift` in beiden Targets via XcodeGen-source-array (single-source-of-truth) @@ -87,7 +128,7 @@ Widget decken 90% des Native-Polish ab. `PublicDeckDetail`, `ExploreResponse`, `BrowseResponse`, `SubscribeResponse` Codable-DTOs mit snake_case - `MarketplaceSort` Enum (recent/popular/trending) mit deutschen Labels -- `CardsAPI`: explore(), browseMarketplace(query:sort:language:), +- `WordeckAPI`: explore(), browseMarketplace(query:sort:language:), publicDeck(slug:), subscribe(slug:), unsubscribe(slug:) - `MarketplaceStore` @Observable mit Explore-State + Browse-State - `ExploreView` mit Featured + Trending Carousels, Browse-Link @@ -97,13 +138,13 @@ Widget decken 90% des Native-Polish ab. - `PublicDeckCard` + `BrowseRow` Komponenten mit forest-Theme - `RootView` → TabBar (Decks / Entdecken / Account) statt Single-View - Universal-Link-Handler in `RootView` (onOpenURL + onContinueUserActivity): - `https://cardecky.mana.how/d/` und `cards://d/` → Explore-Tab + `https://wordeck.com/d/` und `cards://d/` → Explore-Tab öffnet `PublicDeckView` -- `associated-domains: applinks:cardecky.mana.how` im entitlement +- `associated-domains: applinks:wordeck.com` im entitlement - 5 neue Marketplace-Decoding-Tests (35 Total grün) **Wichtig:** Universal-Links funktionieren erst, wenn AASA-Endpoint -unter `cardecky.mana.how/.well-known/apple-app-site-association` +unter `wordeck.com/.well-known/apple-app-site-association` ausgeliefert wird — heute 404. Web-seitige Aufgabe. ✅ **β-4 — Media + Advanced Card-Types (2026-05-13, Tag `v0.5.0`)** @@ -113,7 +154,7 @@ ausgeliefert wird — heute 404. Web-seitige Aufgabe. nach ID lexikographisch) - `CardFieldsBuilder.imageOcclusion`, `.audioFront` mit korrekter `mask_regions`-Serialisierung als stringified JSON-Array -- `CardsAPI.uploadMedia(data, filename, mimeType)` mit Multipart +- `WordeckAPI.uploadMedia(data, filename, mimeType)` mit Multipart (25 MiB max), `.fetchMedia(id)` für streamed bytes - `MediaCache` actor mit LRU 200 MB (sortiert nach `contentModificationDate`) - `mediaCache`-Environment-Key, im App-Entrypoint instantiiert @@ -135,7 +176,7 @@ ausgeliefert wird — heute 404. Web-seitige Aufgabe. Encodable-Structs (snake_case via `CodingKeys`, nil-Felder werden weggelassen) - `CardFieldsBuilder` mit Type-spezifischen Pflicht-Feld-Konstruktoren -- `CardsAPI`: createDeck/updateDeck/deleteDeck + createCard/updateCard/deleteCard +- `WordeckAPI`: createDeck/updateDeck/deleteDeck + createCard/updateCard/deleteCard - `DeckEditorView` für Create + Edit in einer View (mode-switch), Color-Picker mit 8-Preset-Palette aus forest-Theme, Category-Picker (11 Kategorien mit deutschen Labels), Visibility-Segmented-Control @@ -154,7 +195,7 @@ ausgeliefert wird — heute 404. Web-seitige Aufgabe. - `Cloze`-Helpers (extractClusterIds, subIndexCount, clusterId, renderPrompt, renderAnswer, hint) — 1:1-Port aus `cards/packages/cards-domain/src/cloze.ts` -- `CardsAPI.dueReviews(deckId:)`, `CardsAPI.gradeReview(...)` mit +- `WordeckAPI.dueReviews(deckId:)`, `WordeckAPI.gradeReview(...)` mit ISO8601-Encoder - `PendingGrade` SwiftData-Model + `GradeQueue` für Offline-Submit (FIFO-Drain, originaler reviewedAt-Timestamp bleibt erhalten) @@ -172,7 +213,7 @@ ausgeliefert wird — heute 404. Web-seitige Aufgabe. - `Deck`-Codable-DTO mit snake_case-CodingKeys, plus `DeckCategory`, `DeckVisibility`, `FsrsSettings` - ISO8601-Date-Decoder mit Fractional-Seconds-Toleranz -- `CardsAPI.listDecks()`, `cardCount(deckId:)`, `dueCount(deckId:)` +- `WordeckAPI.listDecks()`, `cardCount(deckId:)`, `dueCount(deckId:)` - `CachedDeck` als SwiftData-Model mit `lastFetchedAt` (Offline-Read) - `DeckListStore` orchestriert API + Cache, paralleles Counts-Fetching via TaskGroup @@ -193,6 +234,33 @@ ausgeliefert wird — heute 404. Web-seitige Aufgabe. | β-5 | ✅ 2026-05-13 | Marketplace (Explore/Browse/Subscribe) + TabBar + Universal-Link-Handler (AASA server-side pending) | | β-6 | ✅ 2026-05-13 | Keyboard-Shortcuts + Daily-Reminders + WidgetKit (Siri/Share deferred auf β-7) | | β-7 | ✅ 2026-05-13 | App-Icon-Platzhalter + Siri-Shortcut + Share-Extension + Release-Checklist (externe Apple-Schritte siehe docs/RELEASE_CHECKLIST.md) | +| **Wordeck-Cleanup** | ✅ 2026-05-18 | Image-Occlusion + Audio-Front-Code raus (Server seit Migration `0004_wordeck_text_only.sql` text-only). Gelöscht: MediaCache, MediaEnvironment, RemoteImage, AudioPlayerButton, MaskEditorView, CardEditorMediaFields, CardEditorPayload, Media.swift. CardType-Enum auf 5 Werte reduziert, `media_refs` aus Card+CardCreateBody+CardUpdateBody+CardCreate-Call-Sites raus, `WordeckAPI.uploadMedia/.fetchMedia/.deleteMedia` raus, `makeMultipartBody` (Single-File) raus. | +| **AI-Media-raus** | ✅ 2026-05-18 | `/decks/from-image`-Endpoint existiert serverseitig gar nicht — gesamten Native-Code rausgenommen: `aiMedia`-Case + Sub-Sections in `DeckEditorView`, `generateDeckFromMedia` + `makeFromImageMultipartBody`, `GenerationMediaFile`-Struct, `PhotosUI`-Import, `PlatformImage`-typealias, `NSPhotoLibraryUsageDescription` aus `project.yml`. ModePicker auf 3 Optionen (Leer/KI/CSV). Auch Test fix: `WordeckNativeTests` nutzt jetzt `ManaSharedKeychainGroup` statt String-Literal. 37/37 Tests grün. | +| **ζ-1 (Offline-Sync)** | ✅ 2026-05-18 | `CachedCard` + `CachedDueReview` SwiftData-Models, `DeckListStore.refresh()` zieht Cards+Due-Reviews pro Deck parallel (TaskGroup) und ersetzt den Snapshot atomar. `StudySession.start()` fällt bei Netz-Fehler auf den Cache zurück, setzt `isOfflineSession`-Flag für UX-Banner. `DueReview` bekommt programmatischen `init(review:card:)` für die Rekonstruktion. `ModelContainer` um die zwei Models erweitert (additive Migration, sollte automatisch durchlaufen). DSGVO-Logout-Wipe in `AccountView`: vor jedem `signOut()` und nach `deleteAccount` werden `CachedDeck`+`CachedCard`+`CachedDueReview`+`PendingGrade` aus dem Context gelöscht. iOS-Build grün, swiftlint --strict clean, 37/37 Tests passen. | + +## Geplant: ζ-2..4 + +Konzept in [`docs/OFFLINE_SYNC.md`](docs/OFFLINE_SYNC.md). + +| Phase | Inhalt | Aufwand | +|---|---|---| +| ζ-2 | Distractor-Pool für MC-Karten (pro MC-Karte 10 Distractors mit-cachen) | 0,5 Tag | +| ζ-3 | `SettingsView`-Cache-Footprint anzeigen + manueller Cache-Clear | 0,5 Tag | +| ζ-4 (optional) | `BGAppRefreshTask`, Wi-Fi-Only-Toggle | 0,5 Tag | + +Server-authoritative-FSRS bleibt — kein lokales FSRS, nur Snapshot. + +## Pflicht-Verifikation für ζ-1 (Endurance auf realem Gerät) + +- [ ] **SwiftData-Migration:** alte App von TestFlight installieren, dann + über Xcode mit ζ-1-Build überschreiben — Cache muss durchlaufen, kein + Crash. (Additive Schema-Change sollte automatisch gehen, aber unverifiziert.) +- [ ] **Offline-Study:** 50+ Karten lernen mit Flugmodus, App killen, + neu öffnen, weiter lernen — alle Grades landen am Server nach Reconnect. +- [ ] **Logout-Wipe:** Abmelden, anderer Account anmelden — keine Karten/Decks + des Vorgängers in der DeckListView sichtbar. +- [ ] **Cross-Check mit Web:** Karte offline gegradet → Web zeigt identischen + Review-State nach Reload. ## Nächste Schritte: TestFlight + App-Store @@ -203,7 +271,7 @@ Cards-Web-Repo (AASA). Strukturierte Liste in 1. Apple-Developer-Konfiguration (Team-ID, App-IDs, App-Group, Profiles) 2. App-Icon-Platzhalter durch Designer-Icon ersetzen -3. AASA-Endpoint auf `cardecky.mana.how` (Cards-Web-Repo) +3. AASA-Endpoint auf `wordeck.com` (Cards-Web-Repo) 4. Xcode-Archive + TestFlight-Upload 5. Endurance- und Cross-Device-Tests im TestFlight-Beta 6. App-Store-Connect-Listing (Description, Screenshots, Privacy) diff --git a/README.md b/README.md index 2967c18..4ab75c5 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,23 @@ -# cards-native +# wordeck-native -Native SwiftUI-Universal-App (iOS / iPadOS / macOS) für Cardecky — +Native SwiftUI-Universal-App (iOS / iPadOS / macOS) für Wordeck — die Spaced-Repetition-Karten-App des Vereins **mana e.V.** > **Web-App-Parität.** Die existierende Web-App auf -> `cardecky.mana.how` ist Funktions- und Verhaltens-Referenz. +> `wordeck.com` ist Funktions- und Verhaltens-Referenz. > Native bringt kein neues Produkt, sondern die App in einer Form, > die iOS-Hardware besser nutzt. ## Status -**Phase β-0 — Setup.** Leerer Build, Login funktioniert, Cardecky-API- +**Phase β-0 — Setup.** Leerer Build, Login funktioniert, Wordeck-API- Reachability-Check. Vollständiger Phasen-Plan in `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`. ``` HTTPS/JWT ┌──────────────────┐ - cards-api ◄───────────── │ cards-native │ SwiftUI - cardecky-api.mana.how │ ev.mana.cardecky │ WidgetKit (β-6) + cards-api ◄───────────── │ wordeck-native │ SwiftUI + api.wordeck.com │ ev.mana.wordeck │ WidgetKit (β-6) └──────────────────┘ │ ┌─────────────────────────────────────────┐ @@ -30,7 +30,7 @@ Reachability-Check. Vollständiger Phasen-Plan in ```bash xcodegen generate -open CardsNative.xcodeproj # iPhone-17-Simulator +open WordeckNative.xcodeproj # iPhone-17-Simulator ``` Konventionen, Invarianten, Phasen-Disziplin: [`CLAUDE.md`](CLAUDE.md). diff --git a/ShareExtension/Resources/WordeckShareExtension.entitlements b/ShareExtension/Resources/WordeckShareExtension.entitlements new file mode 100644 index 0000000..95d714b --- /dev/null +++ b/ShareExtension/Resources/WordeckShareExtension.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.ev.mana.wordeck + + keychain-access-groups + + $(AppIdentifierPrefix)ev.mana.session + + + diff --git a/ShareExtension/ShareEditorView.swift b/ShareExtension/ShareEditorView.swift index 842afa3..0c84e3a 100644 --- a/ShareExtension/ShareEditorView.swift +++ b/ShareExtension/ShareEditorView.swift @@ -39,7 +39,7 @@ struct ShareEditorView: View { } } Section { - Text("Wähle das Ziel-Deck in der Cards-App.") + Text("Wähle das Ziel-Deck in der Wordeck-App.") .font(.caption) .foregroundStyle(.secondary) } diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift index ee87506..73b98a4 100644 --- a/ShareExtension/ShareViewController.swift +++ b/ShareExtension/ShareViewController.swift @@ -31,16 +31,27 @@ final class ShareViewController: UIViewController { group.enter() provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in if let url = item as? URL { - self.sharedURL = url.absoluteString - if self.sharedText.isEmpty { self.sharedText = url.absoluteString } + let absolute = url.absoluteString + DispatchQueue.main.async { + self.sharedURL = absolute + if self.sharedText.isEmpty { self.sharedText = absolute } + group.leave() + } + } else { + group.leave() } - group.leave() } } else if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { group.enter() provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in - if let text = item as? String { self.sharedText = text } - group.leave() + if let text = item as? String { + DispatchQueue.main.async { + self.sharedText = text + group.leave() + } + } else { + group.leave() + } } } } @@ -72,7 +83,7 @@ final class ShareViewController: UIViewController { private func cancel() { dismiss(animated: true) { [weak self] in - self?.extensionContext?.cancelRequest(withError: NSError(domain: "ev.mana.cardecky.share", code: 0)) + self?.extensionContext?.cancelRequest(withError: NSError(domain: "ev.mana.wordeck.share", code: 0)) } } } diff --git a/Sources/App/CardsNativeApp.swift b/Sources/App/CardsNativeApp.swift deleted file mode 100644 index 7cdc542..0000000 --- a/Sources/App/CardsNativeApp.swift +++ /dev/null @@ -1,33 +0,0 @@ -import ManaCore -import SwiftData -import SwiftUI - -@main -struct CardsNativeApp: App { - let container: ModelContainer - @State private var auth: AuthClient - private let mediaCache: MediaCache - - init() { - do { - container = try ModelContainer(for: CachedDeck.self, PendingGrade.self) - } catch { - fatalError("Failed to init ModelContainer: \(error)") - } - let auth = AuthClient(config: AppConfig.manaAppConfig) - auth.bootstrap() - _auth = State(initialValue: auth) - mediaCache = MediaCache(api: CardsAPI(auth: auth)) - Log.app.info("Cards starting — auth status: \(String(describing: auth.status), privacy: .public)") - } - - var body: some Scene { - WindowGroup { - RootView() - .environment(auth) - .environment(\.mediaCache, mediaCache) - .tint(CardsTheme.primary) - } - .modelContainer(container) - } -} diff --git a/Sources/App/RootView.swift b/Sources/App/RootView.swift index 79ab44a..4e7fb35 100644 --- a/Sources/App/RootView.swift +++ b/Sources/App/RootView.swift @@ -1,30 +1,97 @@ +import ManaAuthUI import ManaCore import SwiftUI -/// Top-Level-Switch: Login vs Haupt-App. Haupt-App ist eine TabBar mit -/// drei Tabs (Decks / Entdecken / Account). +/// Top-Level-View: TabBar mit drei Tabs (Decks / Entdecken / Account). +/// Kein harter Login-Gate mehr — Wordeck läuft auch im Guest-Modus +/// (lokale Decks lernen, Marketplace browsen). Schreibende Server- +/// Aktionen werden über ``ManaAuthGate`` einzeln auf Login eskaliert. struct RootView: View { @Environment(AuthClient.self) private var auth + @Environment(ManaAuthGate.self) private var authGate @State private var selectedTab: AppTab = .decks @State private var pendingDeepLinkSlug: String? + @State private var showCreateDeck = false + @State private var showSignUpSheet = false + @State private var showForgotSheet = false + @State private var resetPasswordToken: String? + + private let sourceAppUrl = URL(string: "https://wordeck.com/auth/verify")! + private let resetUniversalLink = URL(string: "https://wordeck.com/auth/reset")! var body: some View { - switch auth.status { - case .signedIn: - mainTabs - .onOpenURL { url in handle(url: url) } - .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in - if let url = activity.webpageURL { handle(url: url) } + mainTabs + .onOpenURL { url in handle(url: url) } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + if let url = activity.webpageURL { handle(url: url) } + } + .manaBrand(WordeckBrand.manaBrand) + .manaAuthGate(authGate) { + gateSignInContent + } + .sheet(item: Binding( + get: { resetPasswordToken.map(IdentifiedString.init) }, + set: { resetPasswordToken = $0?.value } + )) { token in + ManaResetPasswordView( + token: token.value, + auth: auth, + onDone: { resetPasswordToken = nil } + ) + .manaBrand(WordeckBrand.manaBrand) + } + .task { + // DEBUG: Auto-Login mit DebugCredentials, falls signedOut. + // Release: no-op. Danach in Guest-Mode wechseln, wenn weder + // signedIn noch eingebuchtet — Wordeck soll *immer* nutzbar + // sein, auch ohne Account. + await auth.ensureSignedIn() + if case .signedOut = auth.status { + do { + _ = try auth.enterGuestMode() + } catch { + Log.auth.warning( + "Guest-Mode konnte nicht aktiviert werden: \(String(describing: error), privacy: .public)" + ) + } } - case .unknown, .signedOut, .signingIn, .error: - LoginView() + } + } + + /// Content für das ``ManaAuthGate``-Sheet — wenn ein gegateter Button + /// gedrückt wird, fliegt der User in den Sign-In-Flow. Sign-Up und + /// Forgot-Password werden als verschachtelte Sheets aufgeklappt, + /// damit aus dem Gate-Sheet alle Auth-Pfade erreichbar bleiben. + private var gateSignInContent: some View { + NavigationStack { + ManaLoginView( + auth: auth, + onSignUpTapped: { showSignUpSheet = true }, + onForgotTapped: { showForgotSheet = true } + ) + .manaBrand(WordeckBrand.manaBrand) + .sheet(isPresented: $showSignUpSheet) { + ManaSignUpView( + auth: auth, + sourceAppUrl: sourceAppUrl, + onDone: { showSignUpSheet = false } + ) + .manaBrand(WordeckBrand.manaBrand) + } + .sheet(isPresented: $showForgotSheet) { + ManaForgotPasswordView( + auth: auth, + resetUniversalLink: resetUniversalLink, + onDone: { showForgotSheet = false } + ) + .manaBrand(WordeckBrand.manaBrand) + } } } - @ViewBuilder private var mainTabs: some View { TabView(selection: $selectedTab) { - DeckListView() + DeckListView(showCreate: $showCreateDeck) .tabItem { Label("Decks", systemImage: "rectangle.stack") } .tag(AppTab.decks) @@ -38,20 +105,44 @@ struct RootView: View { .tabItem { Label("Account", systemImage: "person.crop.circle") } .tag(AppTab.account) } + .decksCreateAccessory(visible: selectedTab == .decks) { + authGate.require(reason: "deck-create-accessory") { + showCreateDeck = true + } + } } /// Universal-Link- und URL-Scheme-Handler: - /// - `https://cardecky.mana.how/d/` → Explore-Tab + PublicDeckView + /// - `https://wordeck.com/d/` → Explore-Tab + PublicDeckView + /// - `https://wordeck.com/auth/reset?token=…` → ManaResetPasswordView /// - `cards://study/` → später (β-6 Notifications) private func handle(url: URL) { Log.app.info("Open URL: \(url.absoluteString, privacy: .public)") - if url.host == "cardecky.mana.how" || url.scheme == "cards" { - let parts = url.pathComponents.filter { $0 != "/" } - if parts.count >= 2, parts[0] == "d" { - pendingDeepLinkSlug = parts[1] - selectedTab = .explore + guard url.host == "wordeck.com" || url.scheme == "cards" else { return } + + let parts = url.pathComponents.filter { $0 != "/" } + + // Auth-Reset-Link aus der Passwort-Vergessen-Email. + if parts == ["auth", "reset"] { + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + if let token = components?.queryItems?.first(where: { $0.name == "token" })?.value { + resetPasswordToken = token + return } } + + if parts.count >= 2, parts[0] == "d" { + pendingDeepLinkSlug = parts[1] + selectedTab = .explore + } + } +} + +/// Helper für `.sheet(item:)` mit einem String-Value (Reset-Token). +private struct IdentifiedString: Identifiable { + let value: String + var id: String { + value } } @@ -60,3 +151,38 @@ enum AppTab: Hashable { case explore case account } + +private extension View { + /// iOS 26: floating „Neues Deck"-Pille via `.tabViewBottomAccessory`, + /// nur sichtbar wenn der Decks-Tab aktiv ist. iOS 18 fällt auf den + /// bestehenden `.bottomBar`-„+"-Toolbar-Button in `DeckListView` zurück. + /// + /// Den Modifier nur konditional anwenden — sonst rendert das System + /// auch bei leerem Inhalt die leere Glass-Hülle (sichtbar als toter + /// Streifen über der TabBar auf Entdecken/Account). + @ViewBuilder + func decksCreateAccessory(visible: Bool, onTap: @escaping () -> Void) -> some View { + if #available(iOS 26.0, *), visible { + tabViewBottomAccessory { + DeckCreateAccessoryPill(action: onTap) + } + } else { + self + } + } +} + +@available(iOS 26.0, *) +private struct DeckCreateAccessoryPill: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Label("Neues Deck", systemImage: "plus") + .font(.subheadline.weight(.semibold)) + } + .buttonStyle(.glass) + .tint(WordeckTheme.primary) + .accessibilityLabel("Neues Deck erstellen") + } +} diff --git a/Sources/App/WordeckNativeApp.swift b/Sources/App/WordeckNativeApp.swift new file mode 100644 index 0000000..fe7e6bb --- /dev/null +++ b/Sources/App/WordeckNativeApp.swift @@ -0,0 +1,39 @@ +import ManaAuthUI +import ManaCore +import SwiftData +import SwiftUI + +@main +struct WordeckNativeApp: App { + let container: ModelContainer + @State private var auth: AuthClient + @State private var authGate: ManaAuthGate + + init() { + do { + container = try ModelContainer( + for: CachedDeck.self, + CachedCard.self, + CachedDueReview.self, + PendingGrade.self + ) + } catch { + fatalError("Failed to init ModelContainer: \(error)") + } + let auth = AuthClient(config: AppConfig.manaAppConfig) + auth.bootstrap() + _auth = State(initialValue: auth) + _authGate = State(initialValue: ManaAuthGate(auth: auth)) + Log.app.info("Wordeck starting — auth status: \(String(describing: auth.status), privacy: .public)") + } + + var body: some Scene { + WindowGroup { + RootView() + .environment(auth) + .environment(authGate) + .tint(WordeckTheme.primary) + } + .modelContainer(container) + } +} diff --git a/Sources/Core/API/WordeckAPI+Generation.swift b/Sources/Core/API/WordeckAPI+Generation.swift new file mode 100644 index 0000000..a803a49 --- /dev/null +++ b/Sources/Core/API/WordeckAPI+Generation.swift @@ -0,0 +1,20 @@ +import Foundation +import ManaCore + +/// AI-Deck-Generierung aus Text-Prompt — ausgelagert aus `WordeckAPI`, +/// damit der Haupt-Actor unter der Type-Body-Length-Grenze bleibt. +extension WordeckAPI { + /// `POST /api/v1/decks/generate` — KI generiert Deck aus Prompt. + /// Rate-Limit serverseitig 10/min. Antwort dauert typisch 10–60s + /// (synchron, kein Streaming). + func generateDeckFromText(_ body: DeckGenerateBody) async throws -> DeckGenerateResponse { + let data = try makeJSON(body) + let (responseData, http) = try await transport.request( + path: "/api/v1/decks/generate", + method: "POST", + body: data + ) + try ensureOK(http, data: responseData) + return try decoder.decode(DeckGenerateResponse.self, from: responseData) + } +} diff --git a/Sources/Core/API/WordeckAPI+Marketplace.swift b/Sources/Core/API/WordeckAPI+Marketplace.swift new file mode 100644 index 0000000..bac253f --- /dev/null +++ b/Sources/Core/API/WordeckAPI+Marketplace.swift @@ -0,0 +1,59 @@ +import Foundation +import ManaCore + +/// Marketplace-Moderation und Self-Endpoints — ausgelagert aus `WordeckAPI`, +/// damit der Haupt-Actor unter der Type-Body-Length-Grenze bleibt. +/// +/// `transport`, `decoder`, `makeJSON`, `ensureOK` sind im Actor +/// internal-zugänglich. +extension WordeckAPI { + /// `GET /api/v1/marketplace/me/decks` — eigene Marketplace-Decks + /// (mit aktueller Version) für den Re-Publish-Flow. + func myMarketplaceDecks() async throws -> [OwnedMarketplaceDeck] { + let (data, http) = try await transport.request(path: "/api/v1/marketplace/me/decks") + try ensureOK(http, data: data) + return try decoder.decode(OwnedMarketplaceDecksResponse.self, from: data).decks + } + + /// `POST /api/v1/marketplace/decks/:slug/report` — Meldung melden. + /// Idempotent: doppeltes Melden mit gleicher Kategorie liefert + /// `already_reported: true` ohne Fehler. + @discardableResult + func reportDeck(slug: String, body: ReportDeckBody) async throws -> ReportDeckResponse { + let payload = try makeJSON(body) + let (data, http) = try await transport.request( + path: "/api/v1/marketplace/decks/\(slug)/report", + method: "POST", + body: payload + ) + try ensureOK(http, data: data) + return try decoder.decode(ReportDeckResponse.self, from: data) + } + + /// `POST /api/v1/marketplace/authors/:slug/block` — Author blockieren. + /// Decks dieses Authors verschwinden für den aufrufenden User aus + /// allen Marketplace-Listings. + func blockAuthor(slug: String) async throws { + let (data, http) = try await transport.request( + path: "/api/v1/marketplace/authors/\(slug)/block", + method: "POST" + ) + try ensureOK(http, data: data) + } + + /// `DELETE /api/v1/marketplace/authors/:slug/block`. + func unblockAuthor(slug: String) async throws { + let (data, http) = try await transport.request( + path: "/api/v1/marketplace/authors/\(slug)/block", + method: "DELETE" + ) + try ensureOK(http, data: data) + } + + /// `GET /api/v1/marketplace/me/blocks` — eigene Block-Liste. + func myBlocks() async throws -> [BlockEntry] { + let (data, http) = try await transport.request(path: "/api/v1/marketplace/me/blocks") + try ensureOK(http, data: data) + return try decoder.decode(BlockListResponse.self, from: data).blocks + } +} diff --git a/Sources/Core/API/CardsAPI.swift b/Sources/Core/API/WordeckAPI.swift similarity index 67% rename from Sources/Core/API/CardsAPI.swift rename to Sources/Core/API/WordeckAPI.swift index f9472bc..126a5c1 100644 --- a/Sources/Core/API/CardsAPI.swift +++ b/Sources/Core/API/WordeckAPI.swift @@ -1,11 +1,15 @@ import Foundation import ManaCore -/// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport` -/// aus ManaCore, der die Cardecky-Endpoints kennt. -actor CardsAPI { - private let transport: AuthenticatedTransport - private let decoder: JSONDecoder +// swiftlint:disable type_body_length + +/// Wordeck-API-Client. Wrapper um `AuthenticatedTransport` +/// aus ManaCore, der die Wordeck-Endpoints kennt. Marketplace-Moderation +/// + Self-Endpoints + AI-Generation sind in `WordeckAPI+Marketplace.swift` +/// und `WordeckAPI+Generation.swift` ausgelagert. +actor WordeckAPI { + let transport: AuthenticatedTransport + let decoder: JSONDecoder init(auth: AuthClient) { transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth) @@ -13,7 +17,7 @@ actor CardsAPI { decoder.dateDecodingStrategy = .iso8601withFractional } - /// Health-Probe — verifiziert dass cardecky-api erreichbar ist + /// Health-Probe — verifiziert dass wordeck-api erreichbar ist /// und der eigene JWT akzeptiert wird. func healthCheck() async throws -> Bool { let (_, http) = try await transport.request(path: "/healthz") @@ -44,6 +48,29 @@ actor CardsAPI { return try decoder.decode(CardListResponse.self, from: data).total } + /// `GET /api/v1/cards?deck_id=...` — komplette Liste der Karten + /// für den Browse-Modus im DeckDetailView. + func listCards(deckId: String) async throws -> [Card] { + let (data, http) = try await transport.request(path: "/api/v1/cards?deck_id=\(deckId)") + try ensureOK(http, data: data) + return try decoder.decode(CardListResponse.self, from: data).cards + } + + /// `GET /api/v1/decks/:deckId/distractors` — N zufällige Feldwerte + /// aus anderen Karten desselben Decks. Server-Schema erlaubt nur + /// `front`, `back`, `answer`, `question` als field. + func distractors( + deckId: String, + cardId: String, + field: String = "answer", + count: Int = 3 + ) async throws -> [String] { + let path = "/api/v1/decks/\(deckId)/distractors?card_id=\(cardId)&field=\(field)&count=\(count)" + let (data, http) = try await transport.request(path: path) + try ensureOK(http, data: data) + return try decoder.decode(DistractorsResponse.self, from: data).distractors + } + /// `GET /api/v1/reviews/due?deck_id=...&limit=500` — Anzahl fälliger /// Reviews in einem Deck. func dueCount(deckId: String) async throws -> Int { @@ -74,7 +101,7 @@ actor CardsAPI { var items: [URLQueryItem] = [ .init(name: "sort", value: sort.rawValue), .init(name: "limit", value: "\(limit)"), - .init(name: "offset", value: "\(offset)"), + .init(name: "offset", value: "\(offset)") ] if let query, !query.trimmingCharacters(in: .whitespaces).isEmpty { items.append(.init(name: "q", value: query)) @@ -120,44 +147,6 @@ actor CardsAPI { try ensureOK(http, data: data) } - // MARK: - Media - - /// `POST /api/v1/media/upload` — Multipart-Upload. Max 25 MiB. - /// Erlaubte MIMEs: image/*, audio/*, video/*. - func uploadMedia(data: Data, filename: String, mimeType: String) async throws -> MediaUploadResponse { - let boundary = "cards-native-\(UUID().uuidString)" - let body = makeMultipartBody( - file: data, - filename: filename, - mimeType: mimeType, - boundary: boundary - ) - let (response, http) = try await transport.request( - path: "/api/v1/media/upload", - method: "POST", - body: body, - contentType: "multipart/form-data; boundary=\(boundary)" - ) - try ensureOK(http, data: response) - return try decoder.decode(MediaUploadResponse.self, from: response) - } - - /// `GET /api/v1/media/:id` — streamt das Media-File. Antwortet mit - /// raw bytes (kein JSON), Caller schreibt das auf Disk via MediaCache. - func fetchMedia(id: String) async throws -> Data { - let (data, http) = try await transport.request(path: "/api/v1/media/\(id)") - guard (200 ..< 300).contains(http.statusCode) else { - throw AuthError.serverError(status: http.statusCode, message: "media fetch failed") - } - return data - } - - /// `DELETE /api/v1/media/:id` — Soft-Forget. (Endpoint heute nicht - /// implementiert serverseitig; Stub bleibt für späteren Use.) - func deleteMedia(id _: String) async throws { - throw AuthError.serverError(status: 501, message: "media delete not implemented on server") - } - // MARK: - Deck-Mutations /// `POST /api/v1/decks` — Deck anlegen. @@ -195,6 +184,87 @@ actor CardsAPI { try ensureOK(http, data: data) } + /// `POST /api/v1/decks/:id/duplicate` — Server-seitige Kopie mit + /// "(Kopie)"-Suffix, ohne FSRS-Verlauf, ohne Marketplace-Pointer. + @discardableResult + func duplicateDeck(id: String) async throws -> Deck { + let (data, http) = try await transport.request( + path: "/api/v1/decks/\(id)/duplicate", + method: "POST" + ) + try ensureOK(http, data: data) + return try decoder.decode(Deck.self, from: data) + } + + /// `POST /api/v1/marketplace/private/:deckId/pull-update` — Smart-Merge- + /// Pull. Holt neue/geänderte Karten aus der jüngsten Marketplace-Version + /// in das geforkte private Deck. Removed-Karten bleiben lokal (User- + /// Choice gewinnt). 422 wenn das Deck kein Fork ist. + func pullUpdate(deckId: String) async throws -> PullUpdateResponse { + let (data, http) = try await transport.request( + path: "/api/v1/marketplace/private/\(deckId)/pull-update", + method: "POST" + ) + try ensureOK(http, data: data) + return try decoder.decode(PullUpdateResponse.self, from: data) + } + + // MARK: - Marketplace-Publish + + /// `POST /api/v1/marketplace/authors/me` — Author-Profil upserten. + /// Pflicht-Schritt vor dem ersten Deck-Init im Marketplace. + func upsertAuthor(_ body: AuthorUpsertBody) async throws { + let payload = try makeJSON(body) + let (data, http) = try await transport.request( + path: "/api/v1/marketplace/authors/me", + method: "POST", + body: payload + ) + try ensureOK(http, data: data) + } + + /// `GET /api/v1/marketplace/authors/me` — eigenes Author-Profil + /// lesen, gibt `nil` zurück wenn noch keins existiert. + func myAuthor() async throws -> Bool { + let (data, http) = try await transport.request(path: "/api/v1/marketplace/authors/me") + try ensureOK(http, data: data) + // Server liefert entweder Author-Objekt oder JSON-null. + if let raw = try? JSONSerialization.jsonObject(with: data), raw is NSNull { + return false + } + return true + } + + /// `POST /api/v1/marketplace/decks` — Marketplace-Deck-Init. + /// Erstellt nur Metadaten; Karten folgen via `publishMarketplaceVersion`. + @discardableResult + func initMarketplaceDeck(_ body: MarketplaceDeckInitBody) async throws -> PublicDeck { + let payload = try makeJSON(body) + let (data, http) = try await transport.request( + path: "/api/v1/marketplace/decks", + method: "POST", + body: payload + ) + try ensureOK(http, data: data) + return try decoder.decode(PublicDeck.self, from: data) + } + + /// `POST /api/v1/marketplace/decks/:slug/publish` — neue Version + /// publishen. Karten werden serverseitig durch AI-Moderation geschickt. + func publishMarketplaceVersion( + slug: String, + body: MarketplacePublishBody + ) async throws -> MarketplacePublishResponse { + let payload = try makeJSON(body) + let (data, http) = try await transport.request( + path: "/api/v1/marketplace/decks/\(slug)/publish", + method: "POST", + body: payload + ) + try ensureOK(http, data: data) + return try decoder.decode(MarketplacePublishResponse.self, from: data) + } + // MARK: - Card-Mutations /// `POST /api/v1/cards` — Karte anlegen. Server validiert `fields` @@ -270,44 +340,24 @@ actor CardsAPI { // MARK: - JSON-Encoding - private func makeJSON(_ value: T) throws -> Data { + func makeJSON(_ value: some Encodable) throws -> Data { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 return try encoder.encode(value) } - // MARK: - Multipart - - private func makeMultipartBody( - file: Data, - filename: String, - mimeType: String, - boundary: String - ) -> Data { - var body = Data() - let lineBreak = "\r\n" - let header = """ - --\(boundary)\(lineBreak)\ - Content-Disposition: form-data; name="file"; filename="\(filename)"\(lineBreak)\ - Content-Type: \(mimeType)\(lineBreak)\(lineBreak) - """ - body.append(header.data(using: .utf8) ?? Data()) - body.append(file) - body.append(lineBreak.data(using: .utf8) ?? Data()) - body.append("--\(boundary)--\(lineBreak)".data(using: .utf8) ?? Data()) - return body - } - // MARK: - Helpers - private func ensureOK(_ http: HTTPURLResponse, data: Data) throws { + func ensureOK(_ http: HTTPURLResponse, data: Data) throws { guard (200 ..< 300).contains(http.statusCode) else { let message = (try? JSONDecoder().decode(CardsServerError.self, from: data))?.error - throw AuthError.serverError(status: http.statusCode, message: message) + throw AuthError.serverError(status: http.statusCode, code: nil, message: message) } } } +// swiftlint:enable type_body_length + private struct CardsServerError: Decodable { let error: String? } diff --git a/Sources/Core/Auth/AppConfig.swift b/Sources/Core/Auth/AppConfig.swift index d07e8ab..00de32f 100644 --- a/Sources/Core/Auth/AppConfig.swift +++ b/Sources/Core/Auth/AppConfig.swift @@ -2,14 +2,19 @@ import Foundation import ManaCore /// App-spezifische Konfiguration für Cards. Implementiert `ManaAppConfig` -/// aus ManaCore und ergänzt die Cards-eigene `apiBaseURL` (cardecky-api, +/// aus ManaCore und ergänzt die Cards-eigene `apiBaseURL` (wordeck-api, /// getrennt von mana-auth). enum AppConfig { static let manaAppConfig: ManaAppConfig = DefaultManaAppConfig( authBaseURL: URL(string: "https://auth.mana.how")!, - keychainService: "ev.mana.cardecky", - keychainAccessGroup: nil + keychainService: ManaSharedKeychainGroup, + // Explizit auf TeamID.BundleID, statt nil. Vermeidet Logout + // bei TestFlight-Cert-Drift (siehe mana-swift-core v1.5.1). + keychainAccessGroup: ManaSharedKeychainGroup, + // Single-Source fuer den App-Group-String (App-Hülle + Widget + // + ShareExt-Entitlement spiegeln das hardcoded). + appGroup: "group.ev.mana.wordeck" ) - static let apiBaseURL = URL(string: "https://cardecky-api.mana.how")! + static let apiBaseURL = URL(string: "https://api.wordeck.com")! } diff --git a/Sources/Core/Auth/AuthClient+EnsureSignedIn.swift b/Sources/Core/Auth/AuthClient+EnsureSignedIn.swift new file mode 100644 index 0000000..b927737 --- /dev/null +++ b/Sources/Core/Auth/AuthClient+EnsureSignedIn.swift @@ -0,0 +1,18 @@ +import ManaCore + +extension AuthClient { + /// Stellt sicher, dass der User eingeloggt ist. Nur in DEBUG-Builds + /// aktiv: wenn die Session nach `bootstrap()` `.signedOut` ist, + /// wird automatisch mit den DEBUG-Credentials angemeldet. In + /// Release-Builds No-Op — User muss manuell einloggen. + /// + /// Vorbild: memoro-native — wird in `RootView.task` aufgerufen. + func ensureSignedIn() async { + #if DEBUG + if case .signedOut = status { + Log.auth.notice("DEBUG auto-login: signing in with DebugCredentials") + await signIn(email: DebugCredentials.email, password: DebugCredentials.password) + } + #endif + } +} diff --git a/Sources/Core/Auth/DebugCredentials.swift b/Sources/Core/Auth/DebugCredentials.swift new file mode 100644 index 0000000..9234057 --- /dev/null +++ b/Sources/Core/Auth/DebugCredentials.swift @@ -0,0 +1,17 @@ +#if DEBUG + /// Dev-only Auto-Login-Credentials. + /// + /// **Nur in DEBUG-Builds aktiv** — der ganze File ist hinter + /// `#if DEBUG` gewrapped und wird in Release-Builds (TestFlight/ + /// App-Store) nicht kompiliert. Wird beim App-Start von + /// `AuthClient.ensureSignedIn()` verwendet, falls die Session-Token + /// im Keychain abgelaufen oder leer sind — damit Till während der + /// Entwicklung nicht jedes Mal manuell einloggen muss. + /// + /// Identisches Pattern wie `memoro-native/Sources/Core/Auth/ + /// DebugCredentials.swift`. + enum DebugCredentials { + static let email = "tills95@gmail.com" + static let password = "Aa-123456789" + } +#endif diff --git a/Sources/Core/Domain/CSVParser.swift b/Sources/Core/Domain/CSVParser.swift new file mode 100644 index 0000000..0750dc0 --- /dev/null +++ b/Sources/Core/Domain/CSVParser.swift @@ -0,0 +1,164 @@ +import Foundation + +/// CSV-Zeile aus dem Import-Flow. `type` ist optional — fehlt es, +/// wird `.basic` angenommen. +struct CSVRow: Equatable { + let front: String + let back: String + let type: CardType + + init(front: String, back: String, type: CardType = .basic) { + self.front = front + self.back = back + self.type = type + } +} + +/// Pragmatischer CSV-Parser für den Cards-Import. Format pro Zeile: +/// +/// ,[,] +/// +/// - Quote-Escape via `""` (RFC-4180). +/// - Felder dürfen Kommas und Newlines enthalten, wenn sie in `"…"` +/// gekapselt sind. +/// - Header-Row wird automatisch übersprungen, wenn Front/Back beide +/// wie Header-Tokens aussehen (`front`, `back`, `question`, `answer`, +/// `vorderseite`, `rückseite` …). +/// - BOM (`\u{FEFF}`) am Anfang wird gestrippt. +/// - `type` darf jede Wordeck-Type-Bezeichnung sein; unbekannte Werte +/// landen als `.basic`. +enum CSVParser { + enum ParseError: LocalizedError { + case empty + case noValidRows + + var errorDescription: String? { + switch self { + case .empty: "Datei ist leer." + case .noValidRows: "Keine gültigen Zeilen gefunden — erwartet ‚vorne,hinten[,typ]'." + } + } + } + + static func parse(_ rawText: String) throws -> [CSVRow] { + var text = rawText + if text.hasPrefix("\u{FEFF}") { + text.removeFirst() + } + if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw ParseError.empty + } + + let allRows = parseFields(text) + guard !allRows.isEmpty else { throw ParseError.noValidRows } + + // Header-Detection: erste Zeile droppen wenn beide Felder Headerwords sind. + let headerTokens: Set = [ + "front", "back", "question", "answer", + "vorderseite", "rückseite", "rueckseite", "frage", "antwort" + ] + var rows = allRows + if let first = rows.first, + first.count >= 2, + headerTokens.contains(first[0].lowercased()), + headerTokens.contains(first[1].lowercased()) + { + rows.removeFirst() + } + + let parsed: [CSVRow] = rows.compactMap { fields in + guard fields.count >= 2 else { return nil } + let front = fields[0].trimmingCharacters(in: .whitespacesAndNewlines) + let back = fields[1].trimmingCharacters(in: .whitespacesAndNewlines) + if front.isEmpty, back.isEmpty { return nil } + let type: CardType = fields.count >= 3 + ? CardType(rawValue: fields[2].trimmingCharacters(in: .whitespacesAndNewlines)) ?? .basic + : .basic + return CSVRow(front: front, back: back, type: type) + } + + if parsed.isEmpty { + throw ParseError.noValidRows + } + return parsed + } + + /// Parser-State-Machine: liest Zeichen-für-Zeichen, beachtet Quote- + /// Modus für Kommas/Newlines innerhalb von `"…"`-Feldern. `""` wird + /// als wörtliches `"` im Feld behandelt. + private static func parseFields(_ text: String) -> [[String]] { + var state = ParseState() + var iterator = text.makeIterator() + + while let char = iterator.next() { + if state.inQuotes { + handleQuotedChar(char, iterator: &iterator, state: &state) + } else if char == "\"", state.currentField.isEmpty { + state.inQuotes = true + } else { + handleUnquotedChar(char, state: &state) + } + } + + // Tail-Flush — letzte Zeile ohne abschließendes Newline. + if !state.currentField.isEmpty || !state.currentRow.isEmpty { + state.currentRow.append(state.currentField) + state.rows.append(state.currentRow) + } + + return state.rows + } + + /// Mutable State der Parse-Machine — als `inout`-Struct in die + /// Char-Handler durchgereicht, damit die Parameter-Listen kompakt + /// bleiben. + fileprivate struct ParseState { + var rows: [[String]] = [] + var currentRow: [String] = [] + var currentField = "" + var inQuotes = false + } + + /// Im Quote-Modus: `"` schließt das Feld oder escaped sich selbst, + /// alles andere ist Inhalt. + private static func handleQuotedChar( + _ char: Character, + iterator: inout String.Iterator, + state: inout ParseState + ) { + guard char == "\"" else { + state.currentField.append(char) + return + } + if let next = iterator.next(), next == "\"" { + state.currentField.append("\"") + return + } + state.inQuotes = false + // Das Zeichen nach dem End-Quote ist ein Separator (Komma/Newline + // /EOF) — über den Unquoted-Handler routen. + if let next = iterator.next() { + handleUnquotedChar(next, state: &state) + } + } + + private static func handleUnquotedChar(_ char: Character, state: inout ParseState) { + switch char { + case ",": + state.currentRow.append(state.currentField) + state.currentField = "" + case "\n": + state.currentRow.append(state.currentField) + state.rows.append(state.currentRow) + state.currentField = "" + state.currentRow = [] + case "\r": + // CRLF: `\r` schluken, `\n` macht den Row-Break. + break + case "\"" where state.currentField.isEmpty: + state.inQuotes = true + default: + state.currentField.append(char) + } + } +} diff --git a/Sources/Core/Domain/Card.swift b/Sources/Core/Domain/Card.swift index 24bb6e4..aad68b0 100644 --- a/Sources/Core/Domain/Card.swift +++ b/Sources/Core/Domain/Card.swift @@ -1,14 +1,16 @@ import Foundation -/// Card-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toCardDto` -/// und `cards/packages/cards-domain/src/schemas/card.ts`. -struct Card: Codable, Identifiable, Hashable, Sendable { +/// Card-DTO. Wire-Format aus `wordeck/apps/api/src/lib/dto.ts:toCardDto` +/// und `wordeck/packages/wordeck-domain/src/schemas/card.ts`. +/// +/// Seit Wordeck-Rebrand (2026-05-17) text-only — kein `media_refs` +/// mehr im Schema. +struct Card: Codable, Identifiable, Hashable { let id: String let deckId: String let userId: String let type: CardType let fields: [String: String] - let mediaRefs: [String] let contentHash: String? let createdAt: Date let updatedAt: Date @@ -19,22 +21,18 @@ struct Card: Codable, Identifiable, Hashable, Sendable { case userId = "user_id" case type case fields - case mediaRefs = "media_refs" case contentHash = "content_hash" case createdAt = "created_at" case updatedAt = "updated_at" } } -/// Card-Type-Enum. Vollständig aus `CardTypeSchema`. In β-2 rendern -/// wir nur `basic`, `basic-reverse`, `cloze`. Die anderen Types -/// kommen in β-3 und β-4 dazu, sind aber jetzt schon decodierbar. -enum CardType: String, Codable, Sendable, CaseIterable { +/// Card-Type-Enum. Wordeck ist text-only (Rebrand 2026-05-17) — +/// `image-occlusion` und `audio-front` sind aus dem Schema raus. +enum CardType: String, Codable, CaseIterable { case basic case basicReverse = "basic-reverse" case cloze - case imageOcclusion = "image-occlusion" - case audioFront = "audio-front" case typing case multipleChoice = "multiple-choice" } @@ -43,7 +41,7 @@ enum CardType: String, Codable, Sendable, CaseIterable { /// Server liefert nur 4 Felder (id, deckId, type, fields) als Drizzle- /// Joined-Subset — Achtung: `deckId` hier in **camelCase**, nicht /// snake_case wie sonst. -struct ReviewCard: Codable, Hashable, Sendable { +struct ReviewCard: Codable, Hashable { let id: String let deckId: String let type: CardType diff --git a/Sources/Core/Domain/CardMutations.swift b/Sources/Core/Domain/CardMutations.swift index bec4a71..4003e09 100644 --- a/Sources/Core/Domain/CardMutations.swift +++ b/Sources/Core/Domain/CardMutations.swift @@ -8,31 +8,25 @@ import Foundation /// - cloze: `text` (mit `{{cN::...}}`-Clustern) /// - typing: `front`, `answer` /// - multiple-choice: `front`, `answer` -/// - image-occlusion: `image_ref`, `mask_regions` (β-4) -/// - audio-front: `audio_ref`, `back` (β-4) -struct CardCreateBody: Encodable, Sendable { +struct CardCreateBody: Encodable { let deckId: String let type: CardType let fields: [String: String] - let mediaRefs: [String]? enum CodingKeys: String, CodingKey { case deckId = "deck_id" case type case fields - case mediaRefs = "media_refs" } } -/// Body für `PATCH /api/v1/cards/:id`. Nur `fields` und `media_refs` — +/// Body für `PATCH /api/v1/cards/:id`. Nur `fields` ist änderbar — /// Type und deck_id sind immutable (Server-Schema). -struct CardUpdateBody: Encodable, Sendable { +struct CardUpdateBody: Encodable { var fields: [String: String]? - var mediaRefs: [String]? enum CodingKeys: String, CodingKey { case fields - case mediaRefs = "media_refs" } } diff --git a/Sources/Core/Domain/Cloze.swift b/Sources/Core/Domain/Cloze.swift index 214403c..7fa71f4 100644 --- a/Sources/Core/Domain/Cloze.swift +++ b/Sources/Core/Domain/Cloze.swift @@ -12,12 +12,18 @@ import Foundation /// 1-basierte Cluster-ID. Mehrere Cluster pro Karte → mehrere /// Sub-Index-Reviews. enum Cloze { + // swiftlint:disable large_tuple + /// Pattern für `{{cN::answer(::hint)?}}`. Pro Call konstruiert, /// weil `Regex` unter Strict-Concurrency nicht Sendable ist. + /// Tuple-Output (whole-match, id, answer, hint?) ist Regex-Builder- + /// bedingt — Lint-Regel `large_tuple` greift hier nicht. private static var clusterPattern: Regex<(Substring, Substring, Substring, Substring?)> { #/\{\{c(\d+)::([^}]*?)(?:::([^}]*?))?\}\}/# } + // swiftlint:enable large_tuple + /// Distinct Cluster-IDs, sortiert. static func extractClusterIds(_ text: String) -> [Int] { var ids = Set() diff --git a/Sources/Core/Domain/Deck.swift b/Sources/Core/Domain/Deck.swift index e4f907f..c7f2a3c 100644 --- a/Sources/Core/Domain/Deck.swift +++ b/Sources/Core/Domain/Deck.swift @@ -2,7 +2,7 @@ import Foundation /// Deck-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toDeckDto`. /// snake_case-Felder via `CodingKeys`, Optionals explizit nullable. -struct Deck: Codable, Identifiable, Hashable, Sendable { +struct Deck: Codable, Identifiable, Hashable { let id: String let userId: String let name: String @@ -35,20 +35,20 @@ struct Deck: Codable, Identifiable, Hashable, Sendable { case updatedAt = "updated_at" } - /// Geforkt aus dem Cardecky-Marketplace? + /// Geforkt aus dem Wordeck-Marketplace? var isFromMarketplace: Bool { forkedFromMarketplaceDeckId != nil } } -enum DeckVisibility: String, Codable, Sendable { +enum DeckVisibility: String, Codable { case `private` case space case `public` } /// Aus `cards/packages/cards-domain/src/schemas/deck.ts:DECK_CATEGORY_IDS`. -enum DeckCategory: String, Codable, Sendable, CaseIterable { +enum DeckCategory: String, Codable, CaseIterable { case language case medicine case science @@ -82,7 +82,7 @@ enum DeckCategory: String, Codable, Sendable, CaseIterable { /// FSRS-Settings — Native bleibt schematisch agnostisch, FSRS rechnet /// nur der Server. Wir behalten die Felder als roh-JSON, damit eine /// neue Setting auf dem Server uns nicht bricht. -struct FsrsSettings: Codable, Sendable, Hashable { +struct FsrsSettings: Codable, Hashable { let requestRetention: Double? let maximumInterval: Int? let enableFuzz: Bool? @@ -114,17 +114,23 @@ struct FsrsSettings: Codable, Sendable, Hashable { } /// Server-Response von `GET /api/v1/decks`. -struct DeckListResponse: Decodable, Sendable { +struct DeckListResponse: Decodable { let decks: [Deck] let total: Int } /// Server-Response von `GET /api/v1/cards?deck_id=...`. -struct CardListResponse: Decodable, Sendable { +struct CardListResponse: Decodable { + let cards: [Card] let total: Int } /// Server-Response von `GET /api/v1/reviews/due?deck_id=...`. -struct DueReviewsResponse: Decodable, Sendable { +struct DueReviewsResponse: Decodable { let total: Int } + +/// Server-Response von `GET /api/v1/decks/:deckId/distractors`. +struct DistractorsResponse: Decodable { + let distractors: [String] +} diff --git a/Sources/Core/Domain/DeckGeneration.swift b/Sources/Core/Domain/DeckGeneration.swift new file mode 100644 index 0000000..7b38648 --- /dev/null +++ b/Sources/Core/Domain/DeckGeneration.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Body für `POST /api/v1/decks/generate` — AI-Text-Generierung. +/// Aus `wordeck/apps/api/src/routes/decks-generate.ts:GenerateInputSchema`. +struct DeckGenerateBody: Encodable { + let prompt: String + let language: GenerationLanguage + let count: Int + let url: String? +} + +/// Sprache für AI-Deck-Generierung. Server akzeptiert `de` oder `en`. +enum GenerationLanguage: String, Codable, CaseIterable { + case de + case en + + var label: String { + switch self { + case .de: "Deutsch" + case .en: "English" + } + } +} + +/// Response von `/decks/generate`. +struct DeckGenerateResponse: Decodable { + let deck: Deck + let cardsCreated: Int + + enum CodingKeys: String, CodingKey { + case deck + case cardsCreated = "cards_created" + } +} diff --git a/Sources/Core/Domain/DeckMutations.swift b/Sources/Core/Domain/DeckMutations.swift index c40d62f..6faccd7 100644 --- a/Sources/Core/Domain/DeckMutations.swift +++ b/Sources/Core/Domain/DeckMutations.swift @@ -2,7 +2,7 @@ import Foundation /// Body für `POST /api/v1/decks`. Aus `DeckCreateSchema` in /// `cards/packages/cards-domain/src/schemas/deck.ts`. -struct DeckCreateBody: Encodable, Sendable { +struct DeckCreateBody: Encodable { let name: String let description: String? let color: String? @@ -19,7 +19,7 @@ struct DeckCreateBody: Encodable, Sendable { } /// Body für `PATCH /api/v1/decks/:id`. Alle Felder optional plus `archived`. -struct DeckUpdateBody: Encodable, Sendable { +struct DeckUpdateBody: Encodable { var name: String? var description: String? var color: String? @@ -36,3 +36,37 @@ struct DeckUpdateBody: Encodable, Sendable { case archived } } + +/// Kurze Marketplace-Version-Info: Semver + Version-ID. +struct PullUpdateVersion: Decodable { + let semver: String + let versionId: String + + enum CodingKeys: String, CodingKey { + case semver + case versionId = "version_id" + } +} + +/// Response von `POST /api/v1/marketplace/private/:deckId/pull-update`. +/// `up_to_date == true` heißt: keine neue Marketplace-Version verfügbar, +/// die anderen Counts sind dann 0. +struct PullUpdateResponse: Decodable { + let upToDate: Bool + let from: PullUpdateVersion? + let to: PullUpdateVersion? + let added: Int + let changed: Int + let removed: Int + let cardsInserted: Int? + + enum CodingKeys: String, CodingKey { + case upToDate = "up_to_date" + case from + case to + case added + case changed + case removed + case cardsInserted = "cards_inserted" + } +} diff --git a/Sources/Core/Domain/Marketplace.swift b/Sources/Core/Domain/Marketplace.swift index b4f929e..f3ecd56 100644 --- a/Sources/Core/Domain/Marketplace.swift +++ b/Sources/Core/Domain/Marketplace.swift @@ -1,7 +1,7 @@ import Foundation /// Browse-Eintrag aus `/api/v1/marketplace/decks` und `.../explore`. -struct PublicDeckEntry: Codable, Hashable, Sendable, Identifiable { +struct PublicDeckEntry: Codable, Hashable, Identifiable { let slug: String let title: String let description: String? @@ -16,7 +16,9 @@ struct PublicDeckEntry: Codable, Hashable, Sendable, Identifiable { let createdAt: Date let owner: PublicDeckOwner - var id: String { slug } + var id: String { + slug + } enum CodingKeys: String, CodingKey { case slug, title, description, language, category, license @@ -29,15 +31,21 @@ struct PublicDeckEntry: Codable, Hashable, Sendable, Identifiable { case owner } - var isPaid: Bool { priceCredits > 0 } + var isPaid: Bool { + priceCredits > 0 + } } -struct PublicDeckOwner: Codable, Hashable, Sendable { +struct PublicDeckOwner: Codable, Hashable { let slug: String let displayName: String let verifiedMana: Bool let verifiedCommunity: Bool - let pseudonym: String? + /// Flag: Autor tritt pseudonym auf (Anzeigename verbergen). + /// Server-Schema ist `boolean NOT NULL DEFAULT false`. War in v0.8.x + /// fälschlich als `String?` (Anzeige-Pseudonym) interpretiert — + /// führte zu Decoder-typeMismatch beim Öffnen eines Marketplace-Decks. + let pseudonym: Bool enum CodingKeys: String, CodingKey { case slug @@ -53,24 +61,24 @@ struct PublicDeckOwner: Codable, Hashable, Sendable { displayName = try c.decode(String.self, forKey: .displayName) verifiedMana = try c.decode(Bool.self, forKey: .verifiedMana) verifiedCommunity = try c.decode(Bool.self, forKey: .verifiedCommunity) - pseudonym = try c.decodeIfPresent(String.self, forKey: .pseudonym) + pseudonym = (try? c.decode(Bool.self, forKey: .pseudonym)) ?? false } } /// Response von `GET /api/v1/marketplace/explore`. -struct ExploreResponse: Decodable, Sendable { +struct ExploreResponse: Decodable { let featured: [PublicDeckEntry] let trending: [PublicDeckEntry] } /// Response von `GET /api/v1/marketplace/decks`. -struct BrowseResponse: Decodable, Sendable { +struct BrowseResponse: Decodable { let items: [PublicDeckEntry] let total: Int } /// Vollständiges Public-Deck aus `GET /api/v1/marketplace/decks/:slug`. -struct PublicDeck: Codable, Hashable, Sendable, Identifiable { +struct PublicDeck: Codable, Hashable, Identifiable { let id: String let slug: String let title: String @@ -96,7 +104,7 @@ struct PublicDeck: Codable, Hashable, Sendable, Identifiable { } } -struct PublicDeckVersion: Codable, Hashable, Sendable, Identifiable { +struct PublicDeckVersion: Codable, Hashable, Identifiable { let id: String let deckId: String let semver: String @@ -119,7 +127,7 @@ struct PublicDeckVersion: Codable, Hashable, Sendable, Identifiable { } /// Response von `GET /api/v1/marketplace/decks/:slug`. -struct PublicDeckDetail: Decodable, Sendable { +struct PublicDeckDetail: Decodable { let deck: PublicDeck let latestVersion: PublicDeckVersion? let owner: PublicDeckOwner? @@ -132,7 +140,7 @@ struct PublicDeckDetail: Decodable, Sendable { } /// Response von `POST /api/v1/marketplace/decks/:slug/subscribe`. -struct SubscribeResponse: Decodable, Sendable { +struct SubscribeResponse: Decodable { let subscribed: Bool let deckSlug: String let currentVersionId: String? @@ -147,7 +155,7 @@ struct SubscribeResponse: Decodable, Sendable { } /// Browse-Sort-Optionen aus `BrowseQuerySchema`. -enum MarketplaceSort: String, Sendable, CaseIterable { +enum MarketplaceSort: String, CaseIterable { case recent case popular case trending diff --git a/Sources/Core/Domain/MarketplaceModeration.swift b/Sources/Core/Domain/MarketplaceModeration.swift new file mode 100644 index 0000000..e4972ec --- /dev/null +++ b/Sources/Core/Domain/MarketplaceModeration.swift @@ -0,0 +1,122 @@ +import Foundation + +/// Kategorien für Deck-Reports — entspricht serverseitig +/// `report_category` Enum in `marketplace.deck_reports`. +enum ReportCategory: String, Codable, CaseIterable { + case spam + case copyright + case nsfw + case misinformation + case hate + case other + + var label: String { + switch self { + case .spam: "Spam" + case .copyright: "Urheberrecht" + case .nsfw: "Anstößige Inhalte" + case .misinformation: "Falschinformation" + case .hate: "Hass / Diskriminierung" + case .other: "Sonstiges" + } + } +} + +/// Body für `POST /api/v1/marketplace/decks/:slug/report`. +struct ReportDeckBody: Encodable { + let category: ReportCategory + let body: String? + let versionId: String? + let cardContentHash: String? + + enum CodingKeys: String, CodingKey { + case category + case body + case versionId + case cardContentHash + } +} + +/// Antwort vom Report-Endpoint. +struct ReportDeckResponse: Decodable { + let ok: Bool + let alreadyReported: Bool + let reportId: String? + + enum CodingKeys: String, CodingKey { + case ok + case alreadyReported = "already_reported" + case reportId = "report_id" + } +} + +/// Eintrag aus `GET /api/v1/marketplace/me/blocks`. +struct BlockEntry: Decodable, Identifiable { + let authorSlug: String + let displayName: String + let blockedAt: Date + + var id: String { + authorSlug + } + + enum CodingKeys: String, CodingKey { + case authorSlug = "author_slug" + case displayName = "display_name" + case blockedAt = "blocked_at" + } +} + +struct BlockListResponse: Decodable { + let blocks: [BlockEntry] +} + +/// Aktuelle Version eines `OwnedMarketplaceDeck` — semver + Karten-Count. +struct OwnedMarketplaceVersion: Decodable { + let versionId: String + let semver: String + let cardCount: Int + let publishedAt: Date? + + enum CodingKeys: String, CodingKey { + case versionId = "version_id" + case semver + case cardCount = "card_count" + case publishedAt = "published_at" + } +} + +/// Eintrag aus `GET /api/v1/marketplace/me/decks` — Re-Publish-Flow. +struct OwnedMarketplaceDeck: Decodable, Identifiable { + let slug: String + let title: String + let description: String? + let language: String? + let category: String? + let license: String + let priceCredits: Int + let isTakedown: Bool + let createdAt: Date + let latestVersion: OwnedMarketplaceVersion? + + var id: String { + slug + } + + enum CodingKeys: String, CodingKey { + case slug + case title + case description + case language + case category + case license + case priceCredits = "price_credits" + case isTakedown = "is_takedown" + case createdAt = "created_at" + case latestVersion = "latest_version" + } +} + +struct OwnedMarketplaceDecksResponse: Decodable { + let decks: [OwnedMarketplaceDeck] +} diff --git a/Sources/Core/Domain/MarketplacePublish.swift b/Sources/Core/Domain/MarketplacePublish.swift new file mode 100644 index 0000000..8f2094b --- /dev/null +++ b/Sources/Core/Domain/MarketplacePublish.swift @@ -0,0 +1,107 @@ +import Foundation + +/// Body für `POST /api/v1/marketplace/authors/me` — Upsert des +/// Author-Profils. Pflicht-Schritt vor dem ersten Deck-Init im +/// Marketplace. +struct AuthorUpsertBody: Encodable { + let slug: String + let displayName: String + let bio: String? + let avatarUrl: String? + let pseudonym: Bool? + + enum CodingKeys: String, CodingKey { + case slug + case displayName + case bio + case avatarUrl + case pseudonym + } +} + +/// Body für `POST /api/v1/marketplace/decks` — Deck-Init. +/// Erstellt nur die Metadaten; Karten kommen mit der ersten `publish`. +struct MarketplaceDeckInitBody: Encodable { + let slug: String + let title: String + let description: String? + let language: String? + let license: String? + let priceCredits: Int? + let category: DeckCategory? + + enum CodingKeys: String, CodingKey { + case slug + case title + case description + case language + case license + case priceCredits + case category + } +} + +/// Eine Card-Payload-Zeile für `POST /:slug/publish`. Andere Type- +/// Namen als bei privaten Karten — der Server nutzt `'type-in'` statt +/// `'typing'`. +struct MarketplacePublishCard: Encodable { + let type: String + let fields: [String: String] +} + +/// Body für `POST /api/v1/marketplace/decks/:slug/publish`. +struct MarketplacePublishBody: Encodable { + let semver: String + let changelog: String? + let cards: [MarketplacePublishCard] +} + +/// Antwort von `POST /:slug/publish`. Enthält das aktualisierte Deck, +/// die neue Version und das AI-Moderation-Verdict. +struct MarketplacePublishResponse: Decodable { + let deck: PublicDeck + let version: PublicDeckVersion + let moderation: ModerationResult + + struct ModerationResult: Decodable { + let verdict: String + let categories: [String]? + let model: String? + } +} + +/// Liste von Wordeck-Marketplace-Lizenzen. Server akzeptiert beliebige +/// Strings ≤ 60 Zeichen — wir bieten die kanonischen vier. +enum MarketplaceLicense: String, CaseIterable { + case personalUse = "Wordeck-Personal-Use-1.0" + case shareAlike = "Wordeck-Share-Alike-1.0" + case attribution = "Wordeck-Attribution-1.0" + case proOnly = "Wordeck-Pro-Only-1.0" + + var label: String { + switch self { + case .personalUse: "Persönlicher Gebrauch" + case .shareAlike: "Share-Alike (CC-BY-SA-Stil)" + case .attribution: "Namensnennung (CC-BY-Stil)" + case .proOnly: "Nur für Wordeck-Pro (Bezahl-Decks)" + } + } +} + +/// Konvertiert eine private `Card` in eine `MarketplacePublishCard` +/// mit dem korrekten Marketplace-Type und Feld-Mapping. +enum MarketplaceCardConverter { + static func convert(_ card: Card) -> MarketplacePublishCard? { + switch card.type { + case .basic, .basicReverse, .cloze, .multipleChoice: + return MarketplacePublishCard(type: card.type.rawValue, fields: card.fields) + case .typing: + let front = card.fields["front"] ?? "" + let answer = card.fields["answer"] ?? "" + return MarketplacePublishCard( + type: "type-in", + fields: ["question": front, "expected": answer] + ) + } + } +} diff --git a/Sources/Core/Domain/Media.swift b/Sources/Core/Domain/Media.swift deleted file mode 100644 index a20e498..0000000 --- a/Sources/Core/Domain/Media.swift +++ /dev/null @@ -1,103 +0,0 @@ -import Foundation - -/// Response von `POST /api/v1/media/upload`. -struct MediaUploadResponse: Decodable, Sendable { - let id: String - let url: String - let mimeType: String - let kind: MediaKind - let sizeBytes: Int - let originalFilename: String? - - enum CodingKeys: String, CodingKey { - case id - case url - case mimeType = "mime_type" - case kind - case sizeBytes = "size_bytes" - case originalFilename = "original_filename" - } -} - -enum MediaKind: String, Codable, Sendable { - case image - case audio - case video - case other -} - -/// Image-Occlusion-Mask-Region. -/// `mask_regions`-Feld ist ein JSON-Array-**String** in `fields`, -/// nicht ein Object — Server-Schema-Constraint (`fields: Record`). -struct MaskRegion: Codable, Hashable, Sendable, Identifiable { - let id: String - let x: Double // 0..1 relativ - let y: Double - let w: Double - let h: Double - let label: String? - - init(id: String, x: Double, y: Double, w: Double, h: Double, label: String? = nil) { - self.id = id - self.x = x - self.y = y - self.w = w - self.h = h - self.label = label - } -} - -/// Helpers zum Parsen/Serialisieren von `mask_regions` als JSON-String. -enum MaskRegions { - /// 1:1-Port aus `cards-domain/image-occlusion.ts:parseMaskRegions`. - /// Bei Parse- oder Schema-Fehler: leere Liste. Sortiert nach ID - /// (lexikographisch, gleich wie Server-Sortierung). - static func parse(_ json: String) -> [MaskRegion] { - guard let data = json.data(using: .utf8) else { return [] } - guard let regions = try? JSONDecoder().decode([MaskRegion].self, from: data) else { return [] } - return regions.sorted { $0.id < $1.id } - } - - /// Sub-Index → Region (Sortier-Reihenfolge). - static func region(for json: String, subIndex: Int) -> MaskRegion? { - let all = parse(json) - return all.indices.contains(subIndex) ? all[subIndex] : nil - } - - /// Anzahl Regionen → Anzahl Sub-Index-Reviews. - static func count(_ json: String) -> Int { - parse(json).count - } - - /// Serialisiert eine Liste zu einem JSON-Array-String fürs `fields`-Feld. - static func encode(_ regions: [MaskRegion]) -> String { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - guard let data = try? encoder.encode(regions) else { return "[]" } - return String(decoding: data, as: UTF8.self) - } -} - -extension CardFieldsBuilder { - /// `image-occlusion`-Fields: `image_ref` (media_id) + - /// `mask_regions` (stringified JSON-Array) + optional `note`. - static func imageOcclusion( - imageRef: String, - regions: [MaskRegion], - note: String? = nil - ) -> [String: String] { - var fields: [String: String] = [ - "image_ref": imageRef, - "mask_regions": MaskRegions.encode(regions), - ] - if let note, !note.isEmpty { - fields["note"] = note - } - return fields - } - - /// `audio-front`-Fields: `audio_ref` (media_id) + `back` (Antwort-Text). - static func audioFront(audioRef: String, back: String) -> [String: String] { - ["audio_ref": audioRef, "back": back] - } -} diff --git a/Sources/Core/Domain/Review.swift b/Sources/Core/Domain/Review.swift index 5fc9ae4..e3a0774 100644 --- a/Sources/Core/Domain/Review.swift +++ b/Sources/Core/Domain/Review.swift @@ -2,7 +2,7 @@ import Foundation /// Rating-Werte für `POST /reviews/:cardId/:subIndex/grade`. /// Aus `cards/packages/cards-domain/src/schemas/review.ts:RatingSchema`. -enum Rating: String, Codable, Sendable, CaseIterable { +enum Rating: String, Codable, CaseIterable { case again case hard case good @@ -30,7 +30,7 @@ enum Rating: String, Codable, Sendable, CaseIterable { } /// FSRS-Review-State. Aus `ReviewStateSchema`. -enum ReviewState: String, Codable, Sendable { +enum ReviewState: String, Codable { case new case learning case review @@ -38,7 +38,7 @@ enum ReviewState: String, Codable, Sendable { } /// Review-DTO. Wire-Format aus `cards/apps/api/src/routes/reviews.ts:toReviewDto`. -struct Review: Codable, Hashable, Sendable { +struct Review: Codable, Hashable { let cardId: String let subIndex: Int let userId: String @@ -71,11 +71,21 @@ struct Review: Codable, Hashable, Sendable { } /// Eintrag aus `/reviews/due?deck_id=X` — Review + zugehörige Card. -struct DueReview: Codable, Hashable, Sendable, Identifiable { +struct DueReview: Codable, Hashable, Identifiable { let review: Review let card: ReviewCard - var id: String { "\(review.cardId)-\(review.subIndex)" } + var id: String { + "\(review.cardId)-\(review.subIndex)" + } + + /// Programmatischer Memberwise-Init — fürs Rekonstruieren aus + /// `CachedDueReview` (offline-Fallback). Wird von Swift nicht + /// auto-synthesiert, weil der custom `init(from decoder:)` da ist. + init(review: Review, card: ReviewCard) { + self.review = review + self.card = card + } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -96,13 +106,13 @@ struct DueReview: Codable, Hashable, Sendable, Identifiable { } /// Wrapper-Response von `GET /api/v1/reviews/due?deck_id=X`. -struct DueReviewsListResponse: Decodable, Sendable { +struct DueReviewsListResponse: Decodable { let reviews: [DueReview] let total: Int } /// Body für `POST /reviews/:cardId/:subIndex/grade`. -struct GradeReviewBody: Encodable, Sendable { +struct GradeReviewBody: Encodable { let rating: Rating let reviewedAt: Date diff --git a/Sources/Core/Domain/Typing.swift b/Sources/Core/Domain/Typing.swift new file mode 100644 index 0000000..3d02240 --- /dev/null +++ b/Sources/Core/Domain/Typing.swift @@ -0,0 +1,77 @@ +import Foundation + +/// Vergleich einer getippten User-Antwort gegen die erwartete Antwort. +/// 1:1-Port aus `cards/packages/cards-domain/src/typing.ts`: +/// Normalisierung (lowercase, trim, NFD-Diakritika-Stripping), +/// dann exact-match → `correct`. Sonst Levenshtein-Distanz mit +/// Threshold `max(1, floor(answer.length * 0.2))` → `close`. +enum TypingMatch: Equatable { + case correct + case close + case wrong +} + +enum Typing { + /// `aliases` ist ein Komma-getrennter String aus dem `aliases`-Feld + /// der Karte (optional). Jeder Alias zählt als gültige Antwort. + static func check(input: String, answer: String, aliases: String? = nil) -> TypingMatch { + let normInput = normalize(input) + guard !normInput.isEmpty else { return .wrong } + + var candidates = [answer] + if let aliases { + candidates.append(contentsOf: aliases.split(separator: ",").map(String.init)) + } + let normalizedCandidates = candidates + .map(normalize) + .filter { !$0.isEmpty } + guard !normalizedCandidates.isEmpty else { return .wrong } + + if normalizedCandidates.contains(normInput) { + return .correct + } + + let shortestLen = normalizedCandidates.map(\.count).min() ?? normInput.count + let threshold = max(1, Int(Double(shortestLen) * 0.2)) + for candidate in normalizedCandidates where levenshtein(normInput, candidate) <= threshold { + return .close + } + return .wrong + } + + private static func normalize(_ string: String) -> String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + let lowered = trimmed.lowercased() + // NFD-Dekomposition + Combining-Marks entfernen (z.B. ä → a) + let decomposed = lowered.decomposedStringWithCanonicalMapping + let stripped = decomposed.unicodeScalars.filter { scalar in + !(0x0300 ... 0x036F).contains(scalar.value) + } + return String(String.UnicodeScalarView(stripped)) + } + + static func levenshtein(_ a: String, _ b: String) -> Int { + let aChars = Array(a) + let bChars = Array(b) + let m = aChars.count + let n = bChars.count + if m == 0 { return n } + if n == 0 { return m } + + var row = Array(0 ... n) + for i in 1 ... m { + var prev = row[0] + row[0] = i + for j in 1 ... n { + let tmp = row[j] + if aChars[i - 1] == bChars[j - 1] { + row[j] = prev + } else { + row[j] = 1 + Swift.min(prev, row[j], row[j - 1]) + } + prev = tmp + } + } + return row[n] + } +} diff --git a/Sources/Core/Intents/StudyAppIntents.swift b/Sources/Core/Intents/StudyAppIntents.swift index a98efc2..b2587c9 100644 --- a/Sources/Core/Intents/StudyAppIntents.swift +++ b/Sources/Core/Intents/StudyAppIntents.swift @@ -7,7 +7,7 @@ import SwiftUI struct StudyCardsIntent: AppIntent { static let title: LocalizedStringResource = "Karten lernen" static let description = IntentDescription( - "Öffnet Cards und zeigt deine Decks mit fälligen Karten.", + "Öffnet Wordeck und zeigt deine Decks mit fälligen Karten.", categoryName: "Lernen" ) static let openAppWhenRun: Bool = true @@ -29,7 +29,7 @@ struct CardsAppShortcuts: AppShortcutsProvider { phrases: [ "Karten lernen mit \(.applicationName)", "Mit \(.applicationName) lernen", - "\(.applicationName) öffnen", + "\(.applicationName) öffnen" ], shortTitle: "Karten lernen", systemImageName: "rectangle.stack" diff --git a/Sources/Core/Notifications/NotificationManager.swift b/Sources/Core/Notifications/NotificationManager.swift index a48aec9..f96471e 100644 --- a/Sources/Core/Notifications/NotificationManager.swift +++ b/Sources/Core/Notifications/NotificationManager.swift @@ -8,14 +8,14 @@ import UserNotifications @MainActor @Observable final class NotificationManager { - enum AuthorizationStatus: Sendable { + enum AuthorizationStatus { case unknown case authorized case denied } private(set) var authorization: AuthorizationStatus = .unknown - private let identifier = "ev.mana.cardecky.dailyReminder" + private let identifier = "ev.mana.wordeck.dailyReminder" private let store = UserDefaults.standard /// Persistiert User-Pref. Format: ISO-Stunde:Minute (default 18:00). @@ -67,7 +67,7 @@ final class NotificationManager { guard remindersEnabled, authorization == .authorized else { return } let content = UNMutableNotificationContent() - content.title = "Cards" + content.title = "Wordeck" content.body = "Ein paar Karten warten auf dich." content.sound = .default diff --git a/Sources/Core/Storage/CachedCard.swift b/Sources/Core/Storage/CachedCard.swift new file mode 100644 index 0000000..34d1394 --- /dev/null +++ b/Sources/Core/Storage/CachedCard.swift @@ -0,0 +1,43 @@ +import Foundation +import SwiftData + +/// Lokales Cache-Model für eine einzelne Karte. Wird beim +/// `DeckListStore.refresh` mitgezogen, damit die Liste der Karten +/// auch offline verfügbar ist. Server bleibt Wahrheit — alle Edits +/// laufen über die API, der Cache wird nur beim Re-Fetch aktualisiert. +@Model +final class CachedCard { + @Attribute(.unique) var id: String + var deckId: String + var userId: String + var typeRaw: String + var fields: [String: String] + var contentHash: String? + var createdAt: Date + var updatedAt: Date + var lastFetchedAt: Date + + init(card: Card) { + id = card.id + deckId = card.deckId + userId = card.userId + typeRaw = card.type.rawValue + fields = card.fields + contentHash = card.contentHash + createdAt = card.createdAt + updatedAt = card.updatedAt + lastFetchedAt = .now + } + + func update(from card: Card) { + typeRaw = card.type.rawValue + fields = card.fields + contentHash = card.contentHash + updatedAt = card.updatedAt + lastFetchedAt = .now + } + + var type: CardType? { + CardType(rawValue: typeRaw) + } +} diff --git a/Sources/Core/Storage/CachedDueReview.swift b/Sources/Core/Storage/CachedDueReview.swift new file mode 100644 index 0000000..b0143f4 --- /dev/null +++ b/Sources/Core/Storage/CachedDueReview.swift @@ -0,0 +1,88 @@ +import Foundation +import SwiftData + +/// Snapshot eines `DueReview` (Review + Card-Subset) zum Zeitpunkt des +/// letzten Sync. Wird verwendet, wenn `StudySession` keine Verbindung +/// zum Server bekommt — der User lernt dann die Karten, die zum Sync- +/// Zeitpunkt fällig waren. +/// +/// Server-authoritative-FSRS bleibt: die `due`/`stability`/… Werte +/// kommen vom Server, lokal wird nie gerechnet. Beim nächsten Sync +/// liefert der Server eine neue Due-Liste. +@Model +final class CachedDueReview { + /// Eindeutiger Schlüssel: `"-"`. SwiftData braucht + /// einen primären Identifier pro Model, das natürliche compound- + /// Schlüssel auf cardId+subIndex. + @Attribute(.unique) var compoundId: String + + var cardId: String + var subIndex: Int + var deckId: String + var userId: String + + // Review-State (server-authoritative, hier nur Snapshot) + var due: Date + var stability: Double + var difficulty: Double + var elapsedDays: Double + var scheduledDays: Double + var learningSteps: Int + var reps: Int + var lapses: Int + var stateRaw: String + var lastReview: Date? + + // Card-Snapshot (für offline-Rendering — was die Study-View braucht) + var cardType: String + var cardFields: [String: String] + + var snapshottedAt: Date + + init(dueReview: DueReview, deckId: String, userId: String) { + compoundId = "\(dueReview.review.cardId)-\(dueReview.review.subIndex)" + cardId = dueReview.review.cardId + subIndex = dueReview.review.subIndex + self.deckId = deckId + self.userId = userId + due = dueReview.review.due + stability = dueReview.review.stability + difficulty = dueReview.review.difficulty + elapsedDays = dueReview.review.elapsedDays + scheduledDays = dueReview.review.scheduledDays + learningSteps = dueReview.review.learningSteps + reps = dueReview.review.reps + lapses = dueReview.review.lapses + stateRaw = dueReview.review.state.rawValue + lastReview = dueReview.review.lastReview + cardType = dueReview.card.type.rawValue + cardFields = dueReview.card.fields + snapshottedAt = .now + } + + /// Rekonstruiert einen `DueReview` für die `StudySession`-Queue. + /// Gibt `nil` zurück, wenn der Type/State im Enum nicht mehr existiert + /// (z.B. nach Schema-Migration). + func toDueReview() -> DueReview? { + guard let state = ReviewState(rawValue: stateRaw), + let type = CardType(rawValue: cardType) + else { return nil } + let review = Review( + cardId: cardId, + subIndex: subIndex, + userId: userId, + due: due, + stability: stability, + difficulty: difficulty, + elapsedDays: elapsedDays, + scheduledDays: scheduledDays, + learningSteps: learningSteps, + reps: reps, + lapses: lapses, + state: state, + lastReview: lastReview + ) + let card = ReviewCard(id: cardId, deckId: deckId, type: type, fields: cardFields) + return DueReview(review: review, card: card) + } +} diff --git a/Sources/Core/Sync/DeckListStore.swift b/Sources/Core/Sync/DeckListStore.swift index 09f6a09..f9e37c5 100644 --- a/Sources/Core/Sync/DeckListStore.swift +++ b/Sources/Core/Sync/DeckListStore.swift @@ -6,10 +6,14 @@ import WidgetKit /// Orchestriert API + SwiftData-Cache für die Deck-Liste. /// View bindet sich an `state` und `errorMessage`. +/// +/// Seit ζ-1 (2026-05-18) zieht der Store auch Karten + Due-Reviews +/// pro Deck mit (offline-Read für die Study-View). Siehe +/// `docs/OFFLINE_SYNC.md`. @MainActor @Observable final class DeckListStore { - enum State: Sendable { + enum State { case idle case loading case loaded @@ -19,23 +23,33 @@ final class DeckListStore { private(set) var state: State = .idle private(set) var errorMessage: String? - private let api: CardsAPI + private let api: WordeckAPI private let context: ModelContext + private let auth: AuthClient init(auth: AuthClient, context: ModelContext) { - api = CardsAPI(auth: auth) + api = WordeckAPI(auth: auth) self.context = context + self.auth = auth } - /// Holt Decks vom Server, aktualisiert Cache. Bei Netzfehler bleibt - /// der Cache (offline-readable). + /// Holt Decks + Karten + Due-Reviews vom Server, aktualisiert Cache. + /// Bei Netzfehler bleibt der Cache (offline-readable). Im Guest-Mode + /// wird kein Server-Call versucht. func refresh() async { + guard case .signedIn = auth.status else { + state = .idle + errorMessage = nil + return + } + state = .loading errorMessage = nil do { let decks = try await api.listDecks() - try await applyToCache(decks: decks) + let perDeck = try await fetchPerDeckPayloads(decks: decks) + try await applyToCache(decks: decks, perDeck: perDeck) updateWidgetSnapshot() state = .loaded Log.sync.info("Loaded \(decks.count, privacy: .public) decks from server") @@ -50,58 +64,103 @@ final class DeckListStore { } } - private func applyToCache(decks remoteDecks: [Deck]) async throws { - let remoteIDs = Set(remoteDecks.map(\.id)) + /// Snapshot pro Deck, geholt in einer parallelen TaskGroup. + private struct PerDeckPayload { + let cards: [Card] + let dueReviews: [DueReview] + } - // 1. Bestehende Cache-Entries finden - let descriptor = FetchDescriptor() - let cached = (try? context.fetch(descriptor)) ?? [] - let cachedByID = Dictionary(uniqueKeysWithValues: cached.map { ($0.id, $0) }) - - // 2. Gelöschte Decks aus Cache entfernen - for cachedDeck in cached where !remoteIDs.contains(cachedDeck.id) { - context.delete(cachedDeck) - } - - // 3. Counts parallel holen - let counts = await withTaskGroup(of: (String, Int, Int).self) { group in - for deck in remoteDecks { + private func fetchPerDeckPayloads(decks: [Deck]) async throws -> [String: PerDeckPayload] { + try await withThrowingTaskGroup(of: (String, PerDeckPayload).self) { group in + for deck in decks { group.addTask { [api] in - async let cards = api.cardCount(deckId: deck.id) - async let due = api.dueCount(deckId: deck.id) - let cardCount = (try? await cards) ?? 0 - let dueCount = (try? await due) ?? 0 - return (deck.id, cardCount, dueCount) + async let cards = api.listCards(deckId: deck.id) + async let due = api.dueReviews(deckId: deck.id, limit: 500) + return try await (deck.id, PerDeckPayload(cards: cards, dueReviews: due)) } } - var result: [String: (cardCount: Int, dueCount: Int)] = [:] - for await (id, c, d) in group { - result[id] = (c, d) + var result: [String: PerDeckPayload] = [:] + for try await (id, payload) in group { + result[id] = payload } return result } + } - // 4. Neue/aktualisierte Decks einarbeiten - for deck in remoteDecks { - let counts = counts[deck.id] ?? (0, 0) - if let existing = cachedByID[deck.id] { - existing.update(from: deck, cardCount: counts.cardCount, dueCount: counts.dueCount) - } else { - let cachedDeck = CachedDeck( - deck: deck, - cardCount: counts.cardCount, - dueCount: counts.dueCount - ) - context.insert(cachedDeck) - } - } - + private func applyToCache( + decks remoteDecks: [Deck], + perDeck: [String: PerDeckPayload] + ) async throws { + applyDecks(remoteDecks, perDeck: perDeck) + applyCards(remoteDecks, perDeck: perDeck) + applyDueReviews(remoteDecks, perDeck: perDeck) try context.save() } + private func applyDecks(_ remoteDecks: [Deck], perDeck: [String: PerDeckPayload]) { + let remoteIDs = Set(remoteDecks.map(\.id)) + let cachedDecks = (try? context.fetch(FetchDescriptor())) ?? [] + let cachedDeckByID = Dictionary(uniqueKeysWithValues: cachedDecks.map { ($0.id, $0) }) + + for cachedDeck in cachedDecks where !remoteIDs.contains(cachedDeck.id) { + context.delete(cachedDeck) + } + + for deck in remoteDecks { + let cardCount = perDeck[deck.id]?.cards.count ?? 0 + let dueCount = perDeck[deck.id]?.dueReviews.count ?? 0 + if let existing = cachedDeckByID[deck.id] { + existing.update(from: deck, cardCount: cardCount, dueCount: dueCount) + } else { + context.insert(CachedDeck(deck: deck, cardCount: cardCount, dueCount: dueCount)) + } + } + } + + /// Karten: Upsert pro remoteDeck, Orphans (Karten von gelöschten + /// Decks oder serverseits gelöschte Karten) löschen. + private func applyCards(_ remoteDecks: [Deck], perDeck: [String: PerDeckPayload]) { + let allCachedCards = (try? context.fetch(FetchDescriptor())) ?? [] + let cachedCardByID = Dictionary(uniqueKeysWithValues: allCachedCards.map { ($0.id, $0) }) + var remoteCardIDs: Set = [] + + for deck in remoteDecks { + guard let cards = perDeck[deck.id]?.cards else { continue } + for card in cards { + remoteCardIDs.insert(card.id) + if let existing = cachedCardByID[card.id] { + existing.update(from: card) + } else { + context.insert(CachedCard(card: card)) + } + } + } + for cachedCard in allCachedCards where !remoteCardIDs.contains(cachedCard.id) { + context.delete(cachedCard) + } + } + + /// Due-Reviews: Snapshot überschreibt komplett. Server-`due`-Zeiten + /// können sich ändern, also kein Merge — voll ersetzen. + private func applyDueReviews(_ remoteDecks: [Deck], perDeck: [String: PerDeckPayload]) { + let allCachedDues = (try? context.fetch(FetchDescriptor())) ?? [] + for cached in allCachedDues { + context.delete(cached) + } + for deck in remoteDecks { + guard let dues = perDeck[deck.id]?.dueReviews else { continue } + for due in dues { + context.insert(CachedDueReview( + dueReview: due, + deckId: deck.id, + userId: due.review.userId + )) + } + } + } + /// Schreibt einen WidgetSnapshot in den shared App-Group-Container - /// und fordert WidgetKit auf, alle Widgets neu zu rendern. Wird nach - /// jedem erfolgreichen Refresh aufgerufen. + /// und fordert WidgetKit auf, alle Widgets neu zu rendern. private func updateWidgetSnapshot() { let descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.dueCount, order: .reverse)] diff --git a/Sources/Core/Sync/GradeQueue.swift b/Sources/Core/Sync/GradeQueue.swift index 0b4ff8f..b88c370 100644 --- a/Sources/Core/Sync/GradeQueue.swift +++ b/Sources/Core/Sync/GradeQueue.swift @@ -11,10 +11,10 @@ final class GradeQueue { private(set) var isDraining = false private(set) var lastDrainError: String? - private let api: CardsAPI + private let api: WordeckAPI private let context: ModelContext - init(api: CardsAPI, context: ModelContext) { + init(api: WordeckAPI, context: ModelContext) { self.api = api self.context = context } @@ -30,8 +30,9 @@ final class GradeQueue { ) context.insert(grade) try? context.save() + let rawRating = rating.rawValue Log.study.info( - "Queued grade for \(cardId, privacy: .public)/\(subIndex, privacy: .public): \(rating.rawValue, privacy: .public)" + "Queued grade \(cardId, privacy: .public)/\(subIndex, privacy: .public): \(rawRating, privacy: .public)" ) await drain() } @@ -73,8 +74,10 @@ final class GradeQueue { grade.lastError = msg try? context.save() lastDrainError = msg + let cid = grade.cardId + let sub = grade.subIndex Log.study.notice( - "Drain stopped for \(grade.cardId, privacy: .public)/\(grade.subIndex, privacy: .public): \(msg, privacy: .public)" + "Drain stopped \(cid, privacy: .public)/\(sub, privacy: .public): \(msg, privacy: .public)" ) return } diff --git a/Sources/Core/Sync/MediaCache.swift b/Sources/Core/Sync/MediaCache.swift deleted file mode 100644 index b636072..0000000 --- a/Sources/Core/Sync/MediaCache.swift +++ /dev/null @@ -1,73 +0,0 @@ -import Foundation -import ManaCore - -/// Persistenter Disk-Cache für Cards-Media-Files. Bilder/Audio werden -/// einmal vom Server geladen und danach lokal serviert — der Server -/// setzt `Cache-Control: private, immutable`, das honorieren wir hier. -/// -/// LRU-Verdrängung mit Soft-Limit (Default 200 MB). -actor MediaCache { - private let root: URL - private let api: CardsAPI - private let maxBytes: Int - - init(api: CardsAPI, maxBytes: Int = 200 * 1024 * 1024) { - self.api = api - self.maxBytes = maxBytes - let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] - root = caches.appendingPathComponent("cards-media", isDirectory: true) - try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - } - - /// Liefert die lokale URL eines Media-Files. Lädt vom Server, falls - /// nicht im Cache. Wirft `AuthError`, wenn der Download scheitert. - func localURL(for mediaId: String) async throws -> URL { - let target = root.appendingPathComponent(mediaId) - if FileManager.default.fileExists(atPath: target.path) { - try? FileManager.default.setAttributes([.modificationDate: Date.now], ofItemAtPath: target.path) - return target - } - let data = try await api.fetchMedia(id: mediaId) - try data.write(to: target, options: .atomic) - try? await pruneIfNeeded() - return target - } - - /// Direktes Lesen — für UI-Komponenten, die `Data` brauchen (z.B. AVAudioPlayer). - func data(for mediaId: String) async throws -> Data { - try Data(contentsOf: try await localURL(for: mediaId)) - } - - /// LRU-Eviction: bei Überschreitung des Limits ältesten zuerst löschen. - private func pruneIfNeeded() async throws { - let resourceKeys: Set = [.fileSizeKey, .contentModificationDateKey] - guard let items = try? FileManager.default.contentsOfDirectory( - at: root, - includingPropertiesForKeys: Array(resourceKeys) - ) else { return } - - let withMeta = items.compactMap { url -> (url: URL, size: Int, date: Date)? in - let values = try? url.resourceValues(forKeys: resourceKeys) - guard let size = values?.fileSize, let date = values?.contentModificationDate else { return nil } - return (url, size, date) - } - - let totalBytes = withMeta.reduce(0) { $0 + $1.size } - guard totalBytes > maxBytes else { return } - - let sortedOldestFirst = withMeta.sorted { $0.date < $1.date } - var remaining = totalBytes - for item in sortedOldestFirst { - if remaining <= maxBytes { break } - try? FileManager.default.removeItem(at: item.url) - remaining -= item.size - Log.sync.info("MediaCache evicted \(item.url.lastPathComponent, privacy: .public) (\(item.size, privacy: .public)B)") - } - } - - /// Wipe — für Sign-out o.ä. - func clear() { - try? FileManager.default.removeItem(at: root) - try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - } -} diff --git a/Sources/Core/Sync/MediaEnvironment.swift b/Sources/Core/Sync/MediaEnvironment.swift deleted file mode 100644 index bd158c0..0000000 --- a/Sources/Core/Sync/MediaEnvironment.swift +++ /dev/null @@ -1,15 +0,0 @@ -import SwiftUI - -/// Environment-Key, der den shared `MediaCache` durch die View-Hierarchie -/// reicht. App-Entrypoint setzt den Wert; Views lesen via -/// `@Environment(\.mediaCache)`. -private struct MediaCacheKey: EnvironmentKey { - static let defaultValue: MediaCache? = nil -} - -extension EnvironmentValues { - var mediaCache: MediaCache? { - get { self[MediaCacheKey.self] } - set { self[MediaCacheKey.self] = newValue } - } -} diff --git a/Sources/Core/Sync/PendingShareStore.swift b/Sources/Core/Sync/PendingShareStore.swift index cd14d83..8e753d7 100644 --- a/Sources/Core/Sync/PendingShareStore.swift +++ b/Sources/Core/Sync/PendingShareStore.swift @@ -3,7 +3,7 @@ import Foundation /// Inbox für Share-Extension. Die Extension persistiert hier, die /// Haupt-App liest beim Start und zeigt einen Banner mit /// "→ Als Karte speichern". Shared App-Group-Container. -struct PendingShare: Codable, Identifiable, Hashable, Sendable { +struct PendingShare: Codable, Identifiable, Hashable { let id: String let text: String let sourceURL: String? @@ -18,7 +18,7 @@ struct PendingShare: Codable, Identifiable, Hashable, Sendable { } enum PendingShareStore { - static let appGroupID = "group.ev.mana.cardecky" + static let appGroupID = "group.ev.mana.wordeck" static let filename = "pending-shares.json" static var url: URL? { @@ -39,7 +39,7 @@ enum PendingShareStore { /// kann ein Eintrag verloren gehen — akzeptabel, weil Extension nur /// schreibt wenn User aktiv "Teilen" tippt. static func append(_ share: PendingShare) { - guard let url else { return } + guard url != nil else { return } var all = readAll() all.append(share) write(all) diff --git a/Sources/Core/Sync/WidgetSnapshot.swift b/Sources/Core/Sync/WidgetSnapshot.swift index 559694c..9f4f0cc 100644 --- a/Sources/Core/Sync/WidgetSnapshot.swift +++ b/Sources/Core/Sync/WidgetSnapshot.swift @@ -6,13 +6,13 @@ import Foundation /// /// Wire ist bewusst stabil + schmal — nur was das Widget rendert. /// Neue Felder dürfen additiv dazukommen, alte Felder bleiben. -struct WidgetSnapshot: Codable, Sendable { +struct WidgetSnapshot: Codable { let updatedAt: Date let totalDueCount: Int let topDecks: [Entry] - struct Entry: Codable, Sendable, Identifiable { - let id: String // deck-id + struct Entry: Codable, Identifiable { + let id: String // deck-id let name: String let dueCount: Int let colorHex: String? @@ -22,7 +22,7 @@ struct WidgetSnapshot: Codable, Sendable { /// Liest und schreibt WidgetSnapshot in den shared App-Group-Container. enum WidgetSnapshotStore { /// App-Group-ID — muss exakt mit dem Entitlement-Eintrag matchen. - static let appGroupID = "group.ev.mana.cardecky" + static let appGroupID = "group.ev.mana.wordeck" static let snapshotFilename = "widget-snapshot.json" static var snapshotURL: URL? { diff --git a/Sources/Core/Telemetry/Log.swift b/Sources/Core/Telemetry/Log.swift index 7a01f3c..b5ea049 100644 --- a/Sources/Core/Telemetry/Log.swift +++ b/Sources/Core/Telemetry/Log.swift @@ -1,13 +1,19 @@ -import Foundation +import ManaCore import OSLog -/// App-eigene OSLog-Logger unter Subsystem `ev.mana.cardecky`. -/// ManaCore loggt unter `ev.mana.core` parallel — siehe -/// `mana-swift-core/Sources/ManaCore/Telemetry/CoreLog.swift`. +/// App-eigene OSLog-Logger gegen ``AppConfig/manaAppConfig``. Standard- +/// Kategorien (`app`/`auth`/`api`) kommen aus ``ManaAppLog`` (mana-swift- +/// core v1.7.0+), app-spezifische über `mana.category(…)`. +/// +/// Subsystem leitet sich aus `AppConfig.manaAppConfig.logSubsystem` ab +/// — heute `ev.mana.wordeck`. ManaCore loggt parallel unter +/// `ev.mana.core`. enum Log { - static let app = Logger(subsystem: "ev.mana.cardecky", category: "app") - static let auth = Logger(subsystem: "ev.mana.cardecky", category: "auth") - static let api = Logger(subsystem: "ev.mana.cardecky", category: "api") - static let study = Logger(subsystem: "ev.mana.cardecky", category: "study") - static let sync = Logger(subsystem: "ev.mana.cardecky", category: "sync") + private static let mana = ManaAppLog(AppConfig.manaAppConfig) + + static let app = mana.app + static let auth = mana.auth + static let api = mana.api + static let study = mana.category("study") + static let sync = mana.category("sync") } diff --git a/Sources/Core/Theme/CardSurface.swift b/Sources/Core/Theme/CardSurface.swift new file mode 100644 index 0000000..b60d520 --- /dev/null +++ b/Sources/Core/Theme/CardSurface.swift @@ -0,0 +1,108 @@ +import SwiftUI + +/// Wiederverwendbare Karten-Hülle in drei Größen — entspricht den +/// Web-`CardSurface.svelte`-Varianten. Konsistenter Border-Radius (14pt), +/// gleicher Border-Stil, gleiche Shadow-Behandlung über alle Größen, +/// optional ein linker Color-Accent-Streifen. +/// +/// Spec aus `cards/apps/web/src/lib/components/CardSurface.svelte`: +/// - Alle Größen Border-Radius 0.875rem (14pt) +/// - Border 1px hsl(--color-border) +/// - Background hsl(--color-surface) +/// - Aspect-Ratio 5/7 für `.md` und `.hero`, fix für `.lg` +struct CardSurface: View { + enum Size { + case md // Deck-Tile in der Liste (max-width 18rem) + case lg // Fan-Detail (12rem x 16.8rem) + case hero // Study-Lernkarte (max-width 24rem) + } + + enum Elevation { + case flat // Subtle shadow + case standard // Default Karten-Shadow + case raised // Study-Hero + } + + let size: Size + let elevation: Elevation + let colorAccentHex: String? + let content: () -> Content + + init( + size: Size = .md, + elevation: Elevation = .standard, + colorAccentHex: String? = nil, + @ViewBuilder content: @escaping () -> Content + ) { + self.size = size + self.elevation = elevation + self.colorAccentHex = colorAccentHex + self.content = content + } + + var body: some View { + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(WordeckTheme.surface) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(WordeckTheme.border, lineWidth: 1) + ) + + if let colorAccentHex { + Color.swatchFromHex(colorAccentHex) + .frame(width: 6) + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: 14, + bottomLeadingRadius: 14, + bottomTrailingRadius: 0, + topTrailingRadius: 0, + style: .continuous + ) + ) + } + + content() + .padding(EdgeInsets(top: 16, leading: 22, bottom: 18, trailing: 16)) + } + .frame(maxWidth: maxWidth) + .aspectRatio(aspectRatio, contentMode: .fit) + .shadow(color: shadowColor, radius: shadowRadius, x: 0, y: shadowY) + } + + private var maxWidth: CGFloat? { + switch size { + case .md: 288 // 18rem + case .lg: 192 // 12rem + case .hero: 384 // 24rem + } + } + + private var aspectRatio: CGFloat? { + switch size { + case .md, .hero: 5.0 / 7.0 + case .lg: 12.0 / 16.8 + } + } + + private var shadowColor: Color { + WordeckTheme.foreground.opacity(elevation == .raised ? 0.18 : 0.08) + } + + private var shadowRadius: CGFloat { + switch elevation { + case .flat: 3 + case .standard: 8 + case .raised: 18 + } + } + + private var shadowY: CGFloat { + switch elevation { + case .flat: 1 + case .standard: 4 + case .raised: 12 + } + } +} diff --git a/Sources/Core/Theme/CardsTheme.swift b/Sources/Core/Theme/CardsTheme.swift deleted file mode 100644 index 064ae6e..0000000 --- a/Sources/Core/Theme/CardsTheme.swift +++ /dev/null @@ -1,99 +0,0 @@ -import SwiftUI - -#if canImport(UIKit) -import UIKit -private typealias PlatformColorType = UIColor -#elseif canImport(AppKit) -import AppKit -private typealias PlatformColorType = NSColor -#endif - -/// Forest-Theme aus `mana/packages/themes/src/variants/forest.css`. -/// Lokal in cards-native nachgebaut, weil ManaTokens v1.0.0 nur den -/// Default-Theme (mana-Variant) liefert. -/// -/// Migration auf einen Theme-Switch in ManaTokens ist Phase ε aus -/// `mana/docs/MANA_SWIFT.md` — bis dahin lebt forest hier. -enum CardsTheme { - /// Page-Hintergrund - static let background = dynamic(light: (0, 0, 100), dark: (142, 30, 8)) - - /// Standard-Text - static let foreground = dynamic(light: (142, 30, 12), dark: (142, 15, 95)) - - /// Card, Panel, Modal - static let surface = dynamic(light: (142, 25, 98), dark: (142, 25, 12)) - - /// Hover-State auf Surface - static let surfaceHover = dynamic(light: (142, 20, 95), dark: (142, 20, 16)) - - /// Disabled-Felder, Skeleton - static let muted = dynamic(light: (142, 15, 93), dark: (142, 18, 18)) - - /// Sekundär-Text, Placeholder - static let mutedForeground = dynamic(light: (142, 10, 42), dark: (142, 12, 65)) - - /// Rahmen, Trennlinien - static let border = dynamic(light: (142, 15, 88), dark: (142, 18, 22)) - - /// Cards-Brand-Grün — Tiefgrün im Light, leuchtender im Dark - static let primary = dynamic(light: (142, 76, 28), dark: (142, 71, 45)) - - /// Text auf Primary - static let primaryForeground = dynamic(light: (0, 0, 100), dark: (142, 30, 8)) - - static let error = dynamic(light: (0, 84, 60), dark: (0, 63, 55)) - static let success = dynamic(light: (142, 71, 45), dark: (142, 71, 45)) - static let warning = dynamic(light: (38, 92, 50), dark: (48, 96, 53)) - - // MARK: - HSL Helper - - private static func dynamic( - light: (Double, Double, Double), - dark: (Double, Double, Double) - ) -> Color { - let lightColor = fromHSL(light.0, light.1, light.2) - let darkColor = fromHSL(dark.0, dark.1, dark.2) - - #if canImport(UIKit) - return Color(uiColor: UIColor { trait in - trait.userInterfaceStyle == .dark ? darkColor : lightColor - }) - #elseif canImport(AppKit) - return Color(nsColor: NSColor(name: nil) { appearance in - let isDark = appearance.bestMatch(from: [.darkAqua, .vibrantDark]) != nil - return isDark ? darkColor : lightColor - }) - #else - return Color(red: 0, green: 0, blue: 0) - #endif - } - - private static func fromHSL(_ hue: Double, _ saturation: Double, _ lightness: Double) -> PlatformColorType { - let h = hue / 360 - let s = saturation / 100 - let l = lightness / 100 - - if s == 0 { - return PlatformColorType(red: l, green: l, blue: l, alpha: 1) - } - - let q = l < 0.5 ? l * (1 + s) : l + s - l * s - let p = 2 * l - q - let r = hueToRGB(p, q, h + 1.0 / 3.0) - let g = hueToRGB(p, q, h) - let b = hueToRGB(p, q, h - 1.0 / 3.0) - - return PlatformColorType(red: r, green: g, blue: b, alpha: 1) - } - - private static func hueToRGB(_ p: Double, _ q: Double, _ rawT: Double) -> Double { - var t = rawT - if t < 0 { t += 1 } - if t > 1 { t -= 1 } - if t < 1.0 / 6.0 { return p + (q - p) * 6 * t } - if t < 1.0 / 2.0 { return q } - if t < 2.0 / 3.0 { return p + (q - p) * (2.0 / 3.0 - t) * 6 } - return p - } -} diff --git a/Sources/Core/Theme/WordeckBrand.swift b/Sources/Core/Theme/WordeckBrand.swift new file mode 100644 index 0000000..5327a2b --- /dev/null +++ b/Sources/Core/Theme/WordeckBrand.swift @@ -0,0 +1,25 @@ +import ManaAuthUI + +/// Brücke zwischen Wordecks `WordeckTheme` (HSL-Forest) und der +/// `ManaBrandConfig` des `ManaAuthUI`-Paketes. Wird im RootView +/// einmal als Environment-Wert gesetzt. +/// +/// Wenn ManaTokens (mana-swift-core) später Theme-Variants liefert, +/// kann diese Datei durch `ManaBrandConfig.forest(appName: "Wordeck", …)` +/// ersetzt werden — siehe MANA_SWIFT.md Phase ε. +enum WordeckBrand { + static let manaBrand = ManaBrandConfig( + appName: "Wordeck", + tagline: "Karteikarten des Vereins mana e.V.", + logoSymbol: "rectangle.stack.fill", + background: WordeckTheme.background, + foreground: WordeckTheme.foreground, + surface: WordeckTheme.surface, + mutedForeground: WordeckTheme.mutedForeground, + border: WordeckTheme.border, + primary: WordeckTheme.primary, + primaryForeground: WordeckTheme.primaryForeground, + error: WordeckTheme.error, + success: WordeckTheme.success + ) +} diff --git a/Sources/Core/Theme/WordeckTheme.swift b/Sources/Core/Theme/WordeckTheme.swift new file mode 100644 index 0000000..bb99147 --- /dev/null +++ b/Sources/Core/Theme/WordeckTheme.swift @@ -0,0 +1,29 @@ +import ManaTokens +import SwiftUI + +/// Cards-Theme — forwarded auf ``ManaTheme/forest`` aus +/// `mana-swift-core` v1.6.0. +/// +/// Bis v1.5.x lebte hier ein 120-LOC-HSL-Apparat als lokaler Nachbau +/// der `forest.css`-Variant. Mit v1.6.0 liefert ManaTokens alle acht +/// Web-Theme-Variants nativ — `forest` ist eine davon. +/// +/// `WordeckTheme` bleibt als dünner Alias bestehen, damit die ~290 +/// Call-Sites in dieser App nicht in einem einzigen Sprint umziehen +/// müssen. Neue Call-Sites bevorzugen direkt `ManaTheme.forest.` +/// (oder `@Environment(\.manaTheme)` falls die App irgendwann +/// Theme-Switching bekommt). +enum WordeckTheme { + static let background = ManaTheme.forest.background + static let foreground = ManaTheme.forest.foreground + static let surface = ManaTheme.forest.surface + static let surfaceHover = ManaTheme.forest.surfaceHover + static let muted = ManaTheme.forest.muted + static let mutedForeground = ManaTheme.forest.mutedForeground + static let border = ManaTheme.forest.border + static let primary = ManaTheme.forest.primary + static let primaryForeground = ManaTheme.forest.primaryForeground + static let error = ManaTheme.forest.error + static let success = ManaTheme.forest.success + static let warning = ManaTheme.forest.warning +} diff --git a/Sources/Features/Account/AccountView.swift b/Sources/Features/Account/AccountView.swift index 0ab63af..f947348 100644 --- a/Sources/Features/Account/AccountView.swift +++ b/Sources/Features/Account/AccountView.swift @@ -1,59 +1,219 @@ +import ManaAuthUI import ManaCore +import SwiftData import SwiftUI struct AccountView: View { @Environment(AuthClient.self) private var auth + @Environment(ManaAuthGate.self) private var authGate + @Environment(\.modelContext) private var context + @State private var showChangeEmail = false + @State private var showChangePassword = false + @State private var showDeleteAccount = false var body: some View { ZStack { - CardsTheme.background.ignoresSafeArea() - VStack(spacing: 24) { - Image(systemName: "person.crop.circle.fill") - .resizable() - .frame(width: 80, height: 80) - .foregroundStyle(CardsTheme.primary) - - if let email = auth.currentEmail { - Text(email) - .font(.headline) - .foregroundStyle(CardsTheme.foreground) + WordeckTheme.background.ignoresSafeArea() + Group { + switch auth.status { + case .signedIn: + signedInContent + case .guest, .signedOut, .error, .unknown: + guestContent + case .signingIn, .twoFactorRequired: + ProgressView().tint(WordeckTheme.primary) } - - NavigationLink { - SettingsView() - } label: { - Label("Einstellungen", systemImage: "gear") - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 8)) - .foregroundStyle(CardsTheme.foreground) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(CardsTheme.border, lineWidth: 1) - ) - } - .buttonStyle(.plain) - .padding(.horizontal, 32) - - Spacer() - - Button(role: .destructive) { - Task { await auth.signOut() } - } label: { - Text("Abmelden") - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) - .foregroundStyle(CardsTheme.error) - } - .padding(.horizontal, 32) } - .padding(.top, 48) } .navigationTitle("Account") #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif + .manaBrand(WordeckBrand.manaBrand) + .sheet(isPresented: $showChangeEmail) { + ManaChangeEmailView( + auth: auth, + callbackUniversalLink: URL(string: "https://wordeck.com/auth/email-changed"), + onDone: { showChangeEmail = false } + ) + .manaBrand(WordeckBrand.manaBrand) + } + .sheet(isPresented: $showChangePassword) { + ManaChangePasswordView( + auth: auth, + onDone: { showChangePassword = false } + ) + .manaBrand(WordeckBrand.manaBrand) + } + .sheet(isPresented: $showDeleteAccount) { + ManaDeleteAccountView( + auth: auth, + onDone: { + Task { await wipeLocalCache() } + showDeleteAccount = false + } + ) + .manaBrand(WordeckBrand.manaBrand) + } + } + + private var signedInContent: some View { + VStack(spacing: 20) { + Image(systemName: "person.crop.circle.fill") + .resizable() + .frame(width: 80, height: 80) + .foregroundStyle(WordeckTheme.primary) + + if let email = auth.currentEmail { + Text(email) + .font(.headline) + .foregroundStyle(WordeckTheme.foreground) + } + + VStack(spacing: 12) { + NavigationLink { + SettingsView() + } label: { + rowLabel("Einstellungen", systemImage: "gear") + } + .buttonStyle(.plain) + + Button { showChangeEmail = true } label: { + rowLabel("Email ändern", systemImage: "envelope") + } + .buttonStyle(.plain) + + Button { showChangePassword = true } label: { + rowLabel("Passwort ändern", systemImage: "key") + } + .buttonStyle(.plain) + + ManaTwoFactorAccountRow(auth: auth) + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(WordeckTheme.border, lineWidth: 1) + ) + } + .padding(.horizontal, 32) + + Spacer() + + Button(role: .destructive) { + // Logout behält die Guest-Identity → App bleibt im + // anonymen Modus nutzbar (lokale Decks, Marketplace + // browsen). Wer „alles vergessen" will, nutzt + // „Account löschen". + // + // DSGVO: Cache (Karten + Due-Reviews + Decks + + // pending Grades) wird vor dem signOut gewipet, damit + // ein anderer User auf demselben Gerät keine Daten + // des Vorgängers sieht. + Task { + await wipeLocalCache() + await auth.signOut(keepGuestMode: true) + } + } label: { + Text("Abmelden") + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(WordeckTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) + .foregroundStyle(WordeckTheme.error) + } + .padding(.horizontal, 32) + + // App-Store-Guideline 5.1.1(v): jede App mit Sign-Up MUSS + // eine Account-Löschung anbieten. + Button(role: .destructive) { + showDeleteAccount = true + } label: { + Text("Account löschen…") + .font(.footnote) + .foregroundStyle(WordeckTheme.mutedForeground) + } + .padding(.bottom, 16) + } + .padding(.top, 48) + } + + private var guestContent: some View { + VStack(spacing: 20) { + Image(systemName: "person.crop.circle.dashed") + .resizable() + .frame(width: 80, height: 80) + .foregroundStyle(WordeckTheme.mutedForeground) + + VStack(spacing: 8) { + Text("Du nutzt Wordeck anonym") + .font(.headline) + .foregroundStyle(WordeckTheme.foreground) + Text( + """ + Marketplace und lokale Decks funktionieren ohne Konto. \ + Für KI-Karten, eigene Decks im Cloud-Sync und Marketplace-\ + Veröffentlichung brauchst du ein Konto. + """ + ) + .font(.subheadline) + .foregroundStyle(WordeckTheme.mutedForeground) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 32) + + VStack(spacing: 12) { + Button { + // Trigger ohne pending-Action — wir wollen einfach + // das Sign-In-Sheet öffnen. `require` mit no-op + // schaltet die Sheet-Logik des Gates ein. + authGate.require(reason: "account-tab") {} + } label: { + Text("Anmelden / Konto erstellen") + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + + NavigationLink { + SettingsView() + } label: { + rowLabel("Einstellungen", systemImage: "gear") + } + .buttonStyle(.plain) + } + .padding(.horizontal, 32) + + Spacer() + } + .padding(.top, 48) + } + + /// Löscht alle lokal gecachten User-Daten: Decks, Karten, fällige + /// Reviews und die offline Grade-Queue. Wird vor jedem signOut und + /// vor Account-Löschung aufgerufen. + private func wipeLocalCache() async { + try? context.delete(model: CachedDeck.self) + try? context.delete(model: CachedCard.self) + try? context.delete(model: CachedDueReview.self) + try? context.delete(model: PendingGrade.self) + try? context.save() + Log.app.info("Local cache wiped (signOut / delete-account)") + } + + private func rowLabel(_ title: String, systemImage: String) -> some View { + Label(title, systemImage: systemImage) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 8)) + .foregroundStyle(WordeckTheme.foreground) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(WordeckTheme.border, lineWidth: 1) + ) } } diff --git a/Sources/Features/Account/LoginView.swift b/Sources/Features/Account/LoginView.swift deleted file mode 100644 index 7823745..0000000 --- a/Sources/Features/Account/LoginView.swift +++ /dev/null @@ -1,78 +0,0 @@ -import ManaCore -import SwiftUI - -struct LoginView: View { - @Environment(AuthClient.self) private var auth - @State private var email = "" - @State private var password = "" - - var body: some View { - ZStack { - CardsTheme.background.ignoresSafeArea() - VStack(spacing: 24) { - Text("Cards") - .font(.system(size: 48, weight: .bold)) - .foregroundStyle(CardsTheme.primary) - Text("Karteikarten des Vereins mana e.V.") - .font(.subheadline) - .foregroundStyle(CardsTheme.mutedForeground) - - VStack(spacing: 12) { - TextField("Email", text: $email) - .textContentType(.emailAddress) - .keyboardType(.emailAddress) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .padding(.vertical, 12) - .padding(.horizontal, 16) - .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 8)) - - SecureField("Passwort", text: $password) - .textContentType(.password) - .padding(.vertical, 12) - .padding(.horizontal, 16) - .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 8)) - } - .padding(.horizontal, 32) - - Button { - Task { await auth.signIn(email: email, password: password) } - } label: { - HStack { - if case .signingIn = auth.status { - ProgressView() - .controlSize(.small) - .tint(CardsTheme.primaryForeground) - } - Text("Anmelden") - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 8)) - .foregroundStyle(CardsTheme.primaryForeground) - } - .padding(.horizontal, 32) - .disabled(isSigningIn || email.isEmpty || password.isEmpty) - - if case let .error(message) = auth.status { - Text(message) - .font(.footnote) - .foregroundStyle(CardsTheme.error) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - } - } - } - } - - private var isSigningIn: Bool { - if case .signingIn = auth.status { return true } - return false - } -} - -#Preview { - LoginView() - .environment(AuthClient(config: AppConfig.manaAppConfig)) -} diff --git a/Sources/Features/Decks/DeckCoverTile.swift b/Sources/Features/Decks/DeckCoverTile.swift new file mode 100644 index 0000000..5f76b25 --- /dev/null +++ b/Sources/Features/Decks/DeckCoverTile.swift @@ -0,0 +1,122 @@ +import SwiftUI + +/// Gemeinsame Karten-Tile mit Fan-Stack-Hintergrund-Layern. +/// Basis für `DeckStackTile` (eigene Decks) und `PublicDeckCard` +/// (Marketplace-Decks). Web-Vorbild: +/// `cards/apps/web/src/lib/components/DeckStack.svelte` und +/// `MarketplaceDeckStack.svelte` — selbe Größe, selbes Stack-Visual, +/// nur der Footer variiert. +struct DeckCoverTile: View { + let title: String + let description: String? + let category: DeckCategory? + let seed: String + let colorAccentHex: String? + let isFeatured: Bool + @ViewBuilder let footer: () -> Footer + + init( + title: String, + description: String? = nil, + category: DeckCategory? = nil, + seed: String, + colorAccentHex: String? = nil, + isFeatured: Bool = false, + @ViewBuilder footer: @escaping () -> Footer + ) { + self.title = title + self.description = description + self.category = category + self.seed = seed + self.colorAccentHex = colorAccentHex + self.isFeatured = isFeatured + self.footer = footer + } + + var body: some View { + ZStack { + ForEach(Array(layers.enumerated()), id: \.offset) { _, layer in + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(WordeckTheme.surface) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(WordeckTheme.border, lineWidth: 1) + ) + .opacity(layer.opacity) + .rotationEffect(.degrees(layer.tilt)) + .offset(x: layer.dx, y: layer.dy) + .shadow(color: WordeckTheme.foreground.opacity(0.05), radius: 2, y: 1) + } + + CardSurface(size: .md, elevation: .standard, colorAccentHex: colorAccentHex) { + cardContent + } + } + .aspectRatio(5.0 / 7.0, contentMode: .fit) + .frame(maxWidth: 280) + } + + private var cardContent: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top) { + if isFeatured { + Image(systemName: "star.fill") + .font(.caption) + .foregroundStyle(WordeckTheme.warning) + } + Spacer() + Image(systemName: category?.systemImageName ?? "rectangle.stack") + .font(.title2) + .foregroundStyle(WordeckTheme.primary.opacity(0.85)) + } + + Spacer(minLength: 0) + + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(WordeckTheme.foreground) + .lineLimit(3) + + if let description, !description.isEmpty { + Text(description) + .font(.caption) + .foregroundStyle(WordeckTheme.mutedForeground) + .lineLimit(2) + } + } + + Spacer(minLength: 0) + + footer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var layers: [DeckCoverStackLayer] { + var hash = UInt64(0) + for byte in seed.utf8 { + hash = hash &* 31 &+ UInt64(byte) + } + return (0 ..< 3).map { index in + let seedHash = hash &+ UInt64(index) &* 17 + let tiltRaw = Double((seedHash >> 8) & 0xFF) / 255.0 - 0.5 + let xRaw = Double((seedHash >> 16) & 0xFF) / 255.0 - 0.5 + let yRaw = Double((seedHash >> 24) & 0xFF) / 255.0 - 0.5 + let depth = Double(index + 1) + return DeckCoverStackLayer( + tilt: tiltRaw * 4.0, + dx: xRaw * 6.0, + dy: depth * 3.0 + yRaw * 2.0, + opacity: 0.7 - depth * 0.18 + ) + } + } +} + +private struct DeckCoverStackLayer { + let tilt: Double + let dx: Double + let dy: Double + let opacity: Double +} diff --git a/Sources/Features/Decks/DeckDetailView.swift b/Sources/Features/Decks/DeckDetailView.swift index 045f78c..6fec1d5 100644 --- a/Sources/Features/Decks/DeckDetailView.swift +++ b/Sources/Features/Decks/DeckDetailView.swift @@ -2,8 +2,16 @@ import ManaCore import SwiftData import SwiftUI -/// Deck-Detail mit Aktionen: Lernen, Karte hinzufügen, Bearbeiten, Löschen. -/// Wird per Tap auf eine Deck-Row aus der DeckListView geöffnet. +// swiftlint:disable file_length +// swiftlint:disable type_body_length + +/// Deck-Detail mit Aktionen + Card-Liste. Wird per Tap auf eine Deck-Row +/// aus der DeckListView geöffnet. +/// +/// `type_body_length` ist bewusst übersprungen — Detail-View hostet +/// 5 verschiedene Sheets (Edit, CardCreate, CardEdit, Publish, Print), +/// Confirmation-Dialog + Alerts; aufspalten ginge nur über Multi-State- +/// Plumbing zwischen Parent und Children. struct DeckDetailView: View { let deckId: String @@ -17,6 +25,18 @@ struct DeckDetailView: View { @State private var showDeleteConfirm = false @State private var navigateToStudy = false @State private var deleteError: String? + @State private var editingCard: Card? + + @State private var cards: [Card] = [] + @State private var isLoadingCards = false + @State private var cardsError: String? + + @State private var isPullingUpdate = false + @State private var isDuplicating = false + @State private var pullAlert: AlertMessage? + @State private var actionError: String? + @State private var showPublishSheet = false + @State private var showPrintSheet = false init(deckId: String) { self.deckId = deckId @@ -25,12 +45,12 @@ struct DeckDetailView: View { var body: some View { ZStack { - CardsTheme.background.ignoresSafeArea() + WordeckTheme.background.ignoresSafeArea() if let deck = decks.first { content(deck: deck) } else { ContentUnavailableView("Deck nicht gefunden", systemImage: "questionmark.folder") - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) } } .navigationTitle(decks.first?.name ?? "") @@ -49,11 +69,44 @@ struct DeckDetailView: View { } .sheet(isPresented: $showCardEditor) { NavigationStack { - CardEditorView(deckId: deckId) { _ in - Task { await refreshAfterEdit() } + CardEditorView(mode: .create(deckId: deckId)) { _ in + Task { + await refreshAfterEdit() + await loadCards() + } } } } + .sheet(item: $editingCard) { card in + NavigationStack { + CardEditorView(mode: .edit(card: card)) { _ in + Task { + await refreshAfterEdit() + await loadCards() + editingCard = nil + } + } + } + } + .sheet(isPresented: $showPublishSheet) { + if let deck = decks.first { + NavigationStack { + MarketplacePublishView(privateDeck: deck) { _ in + showPublishSheet = false + } + } + } + } + .sheet(isPresented: $showPrintSheet) { + NavigationStack { + DeckPrintView(deckId: deckId) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Fertig") { showPrintSheet = false } + } + } + } + } .confirmationDialog( "Deck löschen?", isPresented: $showDeleteConfirm, @@ -64,28 +117,56 @@ struct DeckDetailView: View { } Button("Abbrechen", role: .cancel) {} } message: { - Text("Alle Karten und Reviews dieses Decks werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.") + Text( + """ + Alle Karten und Reviews dieses Decks werden ebenfalls \ + gelöscht. Diese Aktion kann nicht rückgängig gemacht werden. + """ + ) } .navigationDestination(isPresented: $navigateToStudy) { if let deck = decks.first { StudySessionView(deckId: deck.id, deckName: deck.name) } } + .task(id: deckId) { + await loadCards() + } + .refreshable { + await loadCards() + } + .alert(item: $pullAlert) { alert in + Alert(title: Text(alert.title), message: Text(alert.message), dismissButton: .default(Text("OK"))) + } + .alert( + "Aktion fehlgeschlagen", + isPresented: Binding( + get: { actionError != nil }, + set: { if !$0 { actionError = nil } } + ), + presenting: actionError + ) { _ in + Button("OK") { actionError = nil } + } message: { message in + Text(message) + } } private func content(deck: CachedDeck) -> some View { - VStack(alignment: .leading, spacing: 16) { - header(deck: deck) - actions(deck: deck) - if let deleteError { - Text(deleteError) - .font(.footnote) - .foregroundStyle(CardsTheme.error) - .padding(.horizontal, 16) + ScrollView { + VStack(alignment: .leading, spacing: 16) { + header(deck: deck) + actions(deck: deck) + if let deleteError { + Text(deleteError) + .font(.footnote) + .foregroundStyle(WordeckTheme.error) + .padding(.horizontal, 16) + } + cardListSection } - Spacer() + .padding(.vertical, 16) } - .padding(.vertical, 16) } private func header(deck: CachedDeck) -> some View { @@ -93,25 +174,25 @@ struct DeckDetailView: View { HStack { Text(deck.name) .font(.title.bold()) - .foregroundStyle(CardsTheme.foreground) + .foregroundStyle(WordeckTheme.foreground) if deck.isFromMarketplace { Image(systemName: "globe") - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) } } if let description = deck.deckDescription, !description.isEmpty { Text(description) - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) } HStack(spacing: 16) { Label("\(deck.cardCount) Karten", systemImage: "rectangle.stack") if deck.dueCount > 0 { Label("\(deck.dueCount) fällig", systemImage: "clock.badge.exclamationmark") - .foregroundStyle(CardsTheme.primary) + .foregroundStyle(WordeckTheme.primary) } if let category = deck.category { Text(category.label) - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) } } .font(.footnote) @@ -121,75 +202,180 @@ struct DeckDetailView: View { private func actions(deck: CachedDeck) -> some View { VStack(spacing: 12) { - Button { - navigateToStudy = true - } label: { - Label("Karten lernen", systemImage: "play.fill") - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10)) - .foregroundStyle(CardsTheme.primaryForeground) - } - .buttonStyle(.plain) - .disabled(deck.dueCount == 0) - - Button { - showCardEditor = true - } label: { - Label("Karte hinzufügen", systemImage: "plus.rectangle.on.rectangle") - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) - .foregroundStyle(CardsTheme.foreground) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(CardsTheme.border, lineWidth: 1) - ) - } - .buttonStyle(.plain) - - HStack(spacing: 12) { - Button { - showEditor = true - } label: { - Label("Bearbeiten", systemImage: "pencil") - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) - .foregroundStyle(CardsTheme.foreground) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(CardsTheme.border, lineWidth: 1) - ) - } - .buttonStyle(.plain) - - Button { - showDeleteConfirm = true - } label: { - Label("Löschen", systemImage: "trash") - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 10)) - .foregroundStyle(CardsTheme.error) - } - .buttonStyle(.plain) - } + primaryActions + secondaryActions(deck: deck) } .padding(.horizontal, 16) } + @ViewBuilder + private var primaryActions: some View { + Button { + navigateToStudy = true + } label: { + Label("Karten lernen", systemImage: "play.fill") + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(WordeckTheme.primaryForeground) + } + .buttonStyle(.plain) + .disabled((decks.first?.dueCount ?? 0) == 0) + + Button { + showCardEditor = true + } label: { + Label("Karte hinzufügen", systemImage: "plus.rectangle.on.rectangle") + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(WordeckTheme.foreground) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(WordeckTheme.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + private func secondaryActions(deck: CachedDeck) -> some View { + DeckSecondaryActions( + isForkedFromMarketplace: deck.isFromMarketplace, + isPullingUpdate: isPullingUpdate, + isDuplicating: isDuplicating, + onPullUpdate: { Task { await pullUpdate() } }, + onDuplicate: { Task { await duplicate() } }, + onPublish: { showPublishSheet = true }, + onPrint: { showPrintSheet = true }, + onEdit: { showEditor = true }, + onDelete: { showDeleteConfirm = true } + ) + } + + private var cardListSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Karten") + .font(.headline) + .foregroundStyle(WordeckTheme.foreground) + Spacer() + if !cards.isEmpty { + Text("\(cards.count)") + .font(.footnote) + .foregroundStyle(WordeckTheme.mutedForeground) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + + if isLoadingCards, cards.isEmpty { + HStack { + Spacer() + ProgressView() + .tint(WordeckTheme.primary) + Spacer() + } + .padding(.vertical, 24) + } else if let cardsError { + Text(cardsError) + .font(.caption) + .foregroundStyle(WordeckTheme.error) + .padding(.horizontal, 16) + } else if cards.isEmpty { + Text("Noch keine Karten. Tippe auf »Karte hinzufügen«.") + .font(.footnote) + .foregroundStyle(WordeckTheme.mutedForeground) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } else { + LazyVStack(spacing: 8) { + ForEach(cards) { card in + Button { + editingCard = card + } label: { + CardPreviewRow(card: card) + .padding(.horizontal, 16) + } + .buttonStyle(.plain) + .accessibilityHint("Tippen zum Bearbeiten") + } + } + } + } + } + private func refreshAfterEdit() async { let store = DeckListStore(auth: auth, context: context) await store.refresh() } + private func pullUpdate() async { + isPullingUpdate = true + defer { isPullingUpdate = false } + let api = WordeckAPI(auth: auth) + do { + let result = try await api.pullUpdate(deckId: deckId) + pullAlert = formatPullResult(result) + await refreshAfterEdit() + await loadCards() + } catch let error as AuthError { + actionError = error.errorDescription ?? "Update fehlgeschlagen" + } catch { + actionError = error.localizedDescription + } + } + + private func formatPullResult(_ result: PullUpdateResponse) -> AlertMessage { + if result.upToDate { + return AlertMessage( + title: "Schon aktuell", + message: "Es gibt keine neue Marketplace-Version dieses Decks." + ) + } + let inserted = result.cardsInserted ?? 0 + let parts = [ + inserted > 0 ? "\(inserted) Karten hinzugefügt" : nil, + result.changed > 0 ? "\(result.changed) Karten geändert" : nil, + result.removed > 0 ? "\(result.removed) im Marketplace entfernt (lokal behalten)" : nil + ].compactMap(\.self) + let body = parts.isEmpty ? "Update angewendet." : parts.joined(separator: ", ") + let versionText = result.to.map { "Version \($0.semver)" } ?? "Update angewendet" + return AlertMessage(title: versionText, message: body) + } + + private func duplicate() async { + isDuplicating = true + defer { isDuplicating = false } + let api = WordeckAPI(auth: auth) + do { + _ = try await api.duplicateDeck(id: deckId) + await refreshAfterEdit() + dismiss() + } catch let error as AuthError { + actionError = error.errorDescription ?? "Duplizieren fehlgeschlagen" + } catch { + actionError = error.localizedDescription + } + } + + private func loadCards() async { + isLoadingCards = true + cardsError = nil + defer { isLoadingCards = false } + let api = WordeckAPI(auth: auth) + do { + cards = try await api.listCards(deckId: deckId) + .sorted { $0.createdAt > $1.createdAt } + } catch { + cardsError = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } + } + private func delete() async { deleteError = nil - let api = CardsAPI(auth: auth) + let api = WordeckAPI(auth: auth) do { try await api.deleteDeck(id: deckId) - // Cache nachziehen if let deck = decks.first { context.delete(deck) try? context.save() @@ -200,3 +386,71 @@ struct DeckDetailView: View { } } } + +// swiftlint:enable type_body_length + +/// Einfacher Alert-Body — Title + Message für `.alert(item:)`-Trigger. +struct AlertMessage: Identifiable { + let id = UUID() + let title: String + let message: String +} + +/// Kompakte Card-Row mit Front-Vorschau und Type-Badge. +private struct CardPreviewRow: View { + let card: Card + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon(for: card.type)) + .foregroundStyle(WordeckTheme.primary) + .frame(width: 24) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 4) { + Text(preview(card: card)) + .font(.subheadline) + .foregroundStyle(WordeckTheme.foreground) + .lineLimit(2) + Text(typeLabel(card.type)) + .font(.caption2) + .foregroundStyle(WordeckTheme.mutedForeground) + } + Spacer() + } + .padding(12) + .background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(WordeckTheme.border, lineWidth: 1) + ) + } + + private func preview(card: Card) -> String { + switch card.type { + case .basic, .basicReverse, .typing, .multipleChoice: + card.fields["front"] ?? "—" + case .cloze: + card.fields["text"] ?? "—" + } + } + + private func icon(for type: CardType) -> String { + switch type { + case .basic: "rectangle.split.2x1" + case .basicReverse: "rectangle.2.swap" + case .cloze: "text.append" + case .typing: "keyboard" + case .multipleChoice: "list.bullet" + } + } + + private func typeLabel(_ type: CardType) -> String { + switch type { + case .basic: "Einfach" + case .basicReverse: "Beidseitig" + case .cloze: "Lückentext" + case .typing: "Eintippen" + case .multipleChoice: "Multiple Choice" + } + } +} diff --git a/Sources/Features/Decks/DeckListView.swift b/Sources/Features/Decks/DeckListView.swift index fff8437..76edad0 100644 --- a/Sources/Features/Decks/DeckListView.swift +++ b/Sources/Features/Decks/DeckListView.swift @@ -1,66 +1,82 @@ +import ManaAuthUI import ManaCore import SwiftData import SwiftUI -/// β-1 Hauptbildschirm: Liste aller Decks mit Card- und Due-Counts. -/// Web-Vorbild: `cards/apps/web/src/routes/decks/+page.svelte`. +/// Navigations-Routen für die DeckListView. Tap auf eine Tile geht +/// direkt in Study-Mode, Tap auf den Edit-Button in den Deck-Detail- +/// View für Browse + Edit. +enum DeckRoute: Hashable { + case study(deckId: String, deckName: String) + case detail(deckId: String) +} + +// swiftlint:disable type_body_length + +/// Decks-Hauptbildschirm im Wordeck-Look: horizontale Scroll-Reihen +/// mit Fan-Stack-Karten-Tiles. Web-Vorbild: +/// `cards/apps/web/src/routes/decks/+page.svelte`. struct DeckListView: View { @Environment(AuthClient.self) private var auth + @Environment(ManaAuthGate.self) private var authGate @Environment(\.modelContext) private var context @Query(sort: \CachedDeck.updatedAt, order: .reverse) private var decks: [CachedDeck] + @Binding var showCreate: Bool + + private var isGuest: Bool { + if case .signedIn = auth.status { false } else { true } + } + @State private var store: DeckListStore? - @State private var showAccount = false - @State private var showCreate = false @State private var pendingShares: [PendingShare] = [] + @State private var path = NavigationPath() var body: some View { - NavigationStack { + NavigationStack(path: $path) { ZStack { - CardsTheme.background.ignoresSafeArea() + WordeckTheme.background.ignoresSafeArea() content } .navigationTitle("Decks") - .navigationDestination(for: String.self) { deckId in - DeckDetailView(deckId: deckId) - } - .navigationDestination(for: PendingShareRoute.self) { route in - PendingShareConsumeView(share: route.share, onDone: { - PendingShareStore.remove(id: route.share.id) - pendingShares = PendingShareStore.readAll() - }) - } - .toolbar { toolbar } - .refreshable { - await store?.refresh() - } - .sheet(isPresented: $showCreate) { - NavigationStack { - DeckEditorView(mode: .create) { _ in - Task { await store?.refresh() } + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .navigationDestination(for: DeckRoute.self) { route in + switch route { + case let .study(deckId, deckName): + StudySessionView(deckId: deckId, deckName: deckName) + case let .detail(deckId): + DeckDetailView(deckId: deckId) } } - } - .task { - if store == nil { - store = DeckListStore(auth: auth, context: context) + .navigationDestination(for: PendingShareRoute.self) { route in + PendingShareConsumeView(share: route.share, onDone: { + PendingShareStore.remove(id: route.share.id) + pendingShares = PendingShareStore.readAll() + }) } - await store?.refresh() - pendingShares = PendingShareStore.readAll() - } - .onAppear { - pendingShares = PendingShareStore.readAll() - } - .sheet(isPresented: $showAccount) { - NavigationStack { - AccountView() - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Fertig") { showAccount = false } - } + .toolbar { toolbar } + .refreshable { + await store?.refresh() + } + .sheet(isPresented: $showCreate) { + NavigationStack { + DeckEditorView(mode: .create) { _ in + Task { await store?.refresh() } } + } + } + .task { + if store == nil { + store = DeckListStore(auth: auth, context: context) + } + await store?.refresh() + pendingShares = PendingShareStore.readAll() + } + .onAppear { + pendingShares = PendingShareStore.readAll() } - } } } @@ -69,45 +85,135 @@ struct DeckListView: View { if decks.isEmpty { emptyState } else { - List { - pendingShareSection - inboxBannerSection - ownDecksSection + ScrollView { + VStack(alignment: .leading, spacing: 24) { + pendingShareSection + inboxBanner + deckSection(title: "Eigene Decks", icon: "rectangle.stack", decks: ownDecks) + if !subscribedDecks.isEmpty { + deckSection(title: "Abonniert", icon: "globe", decks: subscribedDecks) + } + } + .padding(.vertical, 12) } - .listStyle(.plain) - .scrollContentBackground(.hidden) + } + } + + private var ownDecks: [CachedDeck] { + decks.filter { !$0.isFromMarketplace } + } + + private var subscribedDecks: [CachedDeck] { + decks.filter(\.isFromMarketplace) + } + + @ViewBuilder + private func deckSection(title: String, icon: String, decks: [CachedDeck]) -> some View { + if !decks.isEmpty { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + Image(systemName: icon) + .foregroundStyle(WordeckTheme.primary) + Text(title) + .font(.title3.weight(.semibold)) + .foregroundStyle(WordeckTheme.foreground) + Text("\(decks.count)") + .font(.subheadline) + .foregroundStyle(WordeckTheme.mutedForeground) + } + .padding(.horizontal, 20) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 16) { + ForEach(decks) { deck in + DeckStackTile( + deck: deck, + onTap: { path.append(DeckRoute.study(deckId: deck.id, deckName: deck.name)) }, + onEdit: { path.append(DeckRoute.detail(deckId: deck.id)) } + ) + .frame(width: 240) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 12) + .scrollTargetLayout() + } + .scrollTargetBehavior(.viewAligned) + } + } + } + + @ViewBuilder + private var inboxBanner: some View { + if let inbox = decks.first(where: { $0.isFromMarketplace && $0.dueCount > 0 }) { + Button { + path.append(DeckRoute.study(deckId: inbox.id, deckName: inbox.name)) + } label: { + HStack(spacing: 12) { + Image(systemName: "tray.full.fill") + .font(.title3) + .foregroundStyle(WordeckTheme.primary) + VStack(alignment: .leading, spacing: 2) { + Text("Inbox") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(WordeckTheme.foreground) + Text("\(inbox.dueCount) fällige Karten aus abonnierten Decks") + .font(.caption) + .foregroundStyle(WordeckTheme.mutedForeground) + } + Spacer() + Image(systemName: "chevron.right") + .font(.footnote) + .foregroundStyle(WordeckTheme.mutedForeground) + } + .padding(14) + .background( + WordeckTheme.primary.opacity(0.08), + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(WordeckTheme.primary.opacity(0.18), lineWidth: 1) + ) + .padding(.horizontal, 20) + } + .buttonStyle(.plain) } } @ViewBuilder private var pendingShareSection: some View { if !pendingShares.isEmpty { - Section { + VStack(alignment: .leading, spacing: 8) { ForEach(pendingShares) { share in NavigationLink(value: PendingShareRoute(share: share)) { HStack(spacing: 12) { Image(systemName: "square.and.arrow.down") - .foregroundStyle(CardsTheme.primary) + .foregroundStyle(WordeckTheme.warning) VStack(alignment: .leading, spacing: 2) { Text("Aus Teilen-Menü") .font(.subheadline.weight(.semibold)) - .foregroundStyle(CardsTheme.foreground) + .foregroundStyle(WordeckTheme.foreground) Text(share.text) .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) .lineLimit(2) } Spacer() + Image(systemName: "chevron.right") + .font(.footnote) + .foregroundStyle(WordeckTheme.mutedForeground) } - .padding() - .background(CardsTheme.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) + .padding(14) + .background( + WordeckTheme.warning.opacity(0.12), + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) } .buttonStyle(.plain) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) } } + .padding(.horizontal, 20) } } @@ -115,168 +221,75 @@ struct DeckListView: View { VStack(spacing: 16) { if store?.state == .loading { ProgressView() - .tint(CardsTheme.primary) + .tint(WordeckTheme.primary) Text("Lade Decks …") - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) } else if let message = store?.errorMessage { ContentUnavailableView { Label("Decks konnten nicht geladen werden", systemImage: "wifi.exclamationmark") - .foregroundStyle(CardsTheme.foreground) + .foregroundStyle(WordeckTheme.foreground) } description: { Text(message) - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) + } + } else if isGuest { + ContentUnavailableView { + Label("Wordeck ohne Konto", systemImage: "person.crop.circle.dashed") + .foregroundStyle(WordeckTheme.foreground) + } description: { + Text( + """ + Browse den Marketplace im Entdecken-Tab — kein Konto \ + nötig. Für eigene Decks und Cloud-Sync logge dich ein. + """ + ) + .foregroundStyle(WordeckTheme.mutedForeground) + } actions: { + Button("Anmelden / Konto erstellen") { + authGate.require(reason: "deck-list-empty") {} + } + .buttonStyle(.borderedProminent) + .tint(WordeckTheme.primary) } } else { ContentUnavailableView { Label("Noch keine Decks", systemImage: "rectangle.stack") - .foregroundStyle(CardsTheme.foreground) + .foregroundStyle(WordeckTheme.foreground) } description: { - Text("Erstelle dein erstes Deck auf cardecky.mana.how oder ziehe nach unten zum Aktualisieren.") - .foregroundStyle(CardsTheme.mutedForeground) + Text( + """ + Tippe unten auf »+«, um dein erstes Deck zu erstellen, \ + oder browse den Marketplace im Entdecken-Tab. + """ + ) + .foregroundStyle(WordeckTheme.mutedForeground) } } } .frame(maxWidth: .infinity, maxHeight: .infinity) } - @ViewBuilder - private var inboxBannerSection: some View { - if let inbox = decks.first(where: { $0.isFromMarketplace && $0.dueCount > 0 }) { - Section { - HStack(spacing: 12) { - Image(systemName: "tray.full.fill") - .foregroundStyle(CardsTheme.primary) - VStack(alignment: .leading, spacing: 2) { - Text("Inbox") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(CardsTheme.foreground) - Text("\(inbox.dueCount) fällige Karten aus abonnierten Decks") - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - } - Spacer() - } - .padding() - .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } - } - } - - private var ownDecksSection: some View { - Section { - ForEach(decks) { deck in - NavigationLink(value: deck.id) { - DeckRow(deck: deck) - } - .buttonStyle(.plain) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) - } - } - } - @ToolbarContentBuilder private var toolbar: some ToolbarContent { - ToolbarItem(placement: .topBarLeading) { - Button { - showCreate = true - } label: { - Image(systemName: "plus.circle") - .foregroundStyle(CardsTheme.primary) - } - .accessibilityLabel("Deck hinzufügen") - } - ToolbarItem(placement: .topBarTrailing) { - Button { - showAccount = true - } label: { - Image(systemName: accountIcon) - .foregroundStyle(CardsTheme.primary) - } - .accessibilityLabel("Account") - } - } - - private var accountIcon: String { - if case .signedIn = auth.status { return "person.crop.circle.fill" } - return "person.crop.circle.badge.exclamationmark" - } -} - -/// Einzelne Deck-Zeile in der Liste. -struct DeckRow: View { - let deck: CachedDeck - - var body: some View { - HStack(spacing: 12) { - // Farbiger Streifen aus deck.color (Hex), default forest-primary - RoundedRectangle(cornerRadius: 3) - .fill(deckColor) - .frame(width: 4) - - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(deck.name) - .font(.headline) - .foregroundStyle(CardsTheme.foreground) - if deck.isFromMarketplace { - Image(systemName: "globe") - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - } - } - - if let category = deck.category { - Text(category.label) - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - } - - HStack(spacing: 12) { - Label("\(deck.cardCount)", systemImage: "rectangle.stack") - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - if deck.dueCount > 0 { - Label("\(deck.dueCount) fällig", systemImage: "clock.badge.exclamationmark") - .font(.caption.weight(.semibold)) - .foregroundStyle(CardsTheme.primary) + // Auf iOS 26 übernimmt das `.tabViewBottomAccessory` aus RootView die + // „Neues Deck"-Pille. Doppelten „+"-Button im Liquid-Glass-Layout + // vermeiden — bottomBar-Button nur auf iOS < 26 zeigen. + if #unavailable(iOS 26.0) { + ToolbarItemGroup(placement: .bottomBar) { + Button { + authGate.require(reason: "deck-create-toolbar") { + showCreate = true } + } label: { + Label("Deck hinzufügen", systemImage: "plus") + .labelStyle(.iconOnly) + .foregroundStyle(WordeckTheme.primary) } + .accessibilityLabel("Deck hinzufügen") + Spacer() } - Spacer() - Image(systemName: "chevron.right") - .font(.footnote) - .foregroundStyle(CardsTheme.mutedForeground) } - .padding(.vertical, 12) - .padding(.horizontal, 12) - .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) - } - - private var deckColor: Color { - guard let hex = deck.color, let rgb = parseHex(hex) else { - return CardsTheme.primary - } - return Color.manaHexLocal(rgb) - } - - private func parseHex(_ hex: String) -> UInt32? { - var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.hasPrefix("#") { trimmed = String(trimmed.dropFirst()) } - return UInt32(trimmed, radix: 16) } } -private extension Color { - /// Lokales Hex-Helper analog zu `ManaTokens.Color.manaHex`. Hier - /// dupliziert, weil DeckRow nicht von ManaTokens abhängen muss. - static func manaHexLocal(_ rgb: UInt32) -> Color { - let r = Double((rgb >> 16) & 0xFF) / 255.0 - let g = Double((rgb >> 8) & 0xFF) / 255.0 - let b = Double(rgb & 0xFF) / 255.0 - return Color(red: r, green: g, blue: b) - } -} +// swiftlint:enable type_body_length diff --git a/Sources/Features/Decks/DeckPrintView.swift b/Sources/Features/Decks/DeckPrintView.swift new file mode 100644 index 0000000..5c6eadc --- /dev/null +++ b/Sources/Features/Decks/DeckPrintView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +#if canImport(SafariServices) && canImport(UIKit) + import SafariServices + import UIKit +#endif + +/// In-App-Browser für die Druck-Ansicht des Decks. Nutzt +/// `SFSafariViewController`, weil iOS dort die Print-Sheet und +/// „In Dateien speichern" → PDF von Haus aus mitbringt — kein eigener +/// PDF-Renderer in der App nötig. +/// +/// Auth-Cookies für `wordeck.com` werden geteilt mit Safari auf +/// dem Gerät; der User muss dort eingeloggt sein, damit die Print- +/// Seite den Deck-Inhalt rendert. +struct DeckPrintView: View { + let deckId: String + + @Environment(\.dismiss) private var dismiss + + private var printURL: URL { + URL(string: "https://wordeck.com/decks/\(deckId)/print")! + } + + var body: some View { + #if canImport(SafariServices) && canImport(UIKit) + SafariViewRepresentable(url: printURL) + .ignoresSafeArea() + #else + VStack(spacing: 16) { + Text("Druck-Ansicht ist nur auf iOS verfügbar.") + .font(.subheadline) + Link("Im Web öffnen", destination: printURL) + Button("Schließen") { dismiss() } + } + .padding(32) + #endif + } +} + +#if canImport(SafariServices) && canImport(UIKit) + private struct SafariViewRepresentable: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context _: Context) -> SFSafariViewController { + let config = SFSafariViewController.Configuration() + config.entersReaderIfAvailable = false + let controller = SFSafariViewController(url: url, configuration: config) + controller.preferredControlTintColor = .systemGreen + controller.dismissButtonStyle = .close + return controller + } + + func updateUIViewController(_: SFSafariViewController, context _: Context) {} + } +#endif diff --git a/Sources/Features/Decks/DeckSecondaryActions.swift b/Sources/Features/Decks/DeckSecondaryActions.swift new file mode 100644 index 0000000..d9325ff --- /dev/null +++ b/Sources/Features/Decks/DeckSecondaryActions.swift @@ -0,0 +1,138 @@ +import SwiftUI + +/// Sekundär-Action-Buttons unterhalb der Lern-/Karten-hinzufügen-Buttons +/// in `DeckDetailView`. Eigenständige View, damit `DeckDetailView` selbst +/// nicht über die Type-Body-Length-Grenze rutscht und die einzelnen +/// Aktionen einzeln (z.B. via Snapshot-Tests) prüfbar bleiben. +/// +/// Reines Layout — alle Side-Effects laufen über die Callbacks im Parent. +struct DeckSecondaryActions: View { + let isForkedFromMarketplace: Bool + let isPullingUpdate: Bool + let isDuplicating: Bool + let onPullUpdate: () -> Void + let onDuplicate: () -> Void + let onPublish: () -> Void + let onPrint: () -> Void + let onEdit: () -> Void + let onDelete: () -> Void + + var body: some View { + if isForkedFromMarketplace { + updateButton + } else { + publishButton + } + duplicateButton + printButton + editDeleteRow + } + + private var printButton: some View { + Button(action: onPrint) { + HStack { + Image(systemName: "printer") + Text("Druck-Ansicht / PDF") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(WordeckTheme.foreground) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(WordeckTheme.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + private var publishButton: some View { + Button(action: onPublish) { + HStack { + Image(systemName: "globe.badge.chevron.backward") + Text("Im Marketplace veröffentlichen") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(WordeckTheme.primary) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(WordeckTheme.primary.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + private var updateButton: some View { + Button(action: onPullUpdate) { + HStack { + if isPullingUpdate { + ProgressView().tint(WordeckTheme.primary) + } else { + Image(systemName: "arrow.triangle.2.circlepath") + } + Text(isPullingUpdate ? "Wird geprüft …" : "Updates aus Marketplace prüfen") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(WordeckTheme.primary) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(WordeckTheme.primary.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(isPullingUpdate) + } + + private var duplicateButton: some View { + Button(action: onDuplicate) { + HStack { + if isDuplicating { + ProgressView().tint(WordeckTheme.foreground) + } else { + Image(systemName: "doc.on.doc") + } + Text(isDuplicating ? "Wird dupliziert …" : "Deck duplizieren") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(WordeckTheme.foreground) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(WordeckTheme.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(isDuplicating) + } + + private var editDeleteRow: some View { + HStack(spacing: 12) { + Button(action: onEdit) { + Label("Bearbeiten", systemImage: "pencil") + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(WordeckTheme.foreground) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(WordeckTheme.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + + Button(action: onDelete) { + Label("Löschen", systemImage: "trash") + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(WordeckTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(WordeckTheme.error) + } + .buttonStyle(.plain) + } + } +} diff --git a/Sources/Features/Decks/DeckStackTile.swift b/Sources/Features/Decks/DeckStackTile.swift new file mode 100644 index 0000000..ed20760 --- /dev/null +++ b/Sources/Features/Decks/DeckStackTile.swift @@ -0,0 +1,85 @@ +import SwiftUI + +/// Tile für eigene Decks in der Decks-Liste. Nutzt `DeckCoverTile` als +/// Basis (Fan-Stack-Visual + Card-Content). Footer: Karten-Count, +/// Due-Capsule, Marketplace-Globe, Edit-Button. +/// Tap auf die Tile triggert `onTap` (Study-Mode), Tap auf den Edit- +/// Button triggert `onEdit` (Deck-Detail). +struct DeckStackTile: View { + let deck: CachedDeck + let onTap: () -> Void + let onEdit: () -> Void + + var body: some View { + DeckCoverTile( + title: deck.name, + description: deck.deckDescription, + category: deck.category, + seed: deck.id, + colorAccentHex: deck.color, + isFeatured: false + ) { + footerContent + } + .contentShape(Rectangle()) + .onTapGesture { onTap() } + } + + private var footerContent: some View { + HStack(spacing: 8) { + Label("\(deck.cardCount)", systemImage: "rectangle.stack") + .font(.caption2) + .foregroundStyle(WordeckTheme.mutedForeground) + if deck.dueCount > 0 { + Text("\(deck.dueCount) fällig") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(WordeckTheme.primary.opacity(0.15), in: Capsule()) + .foregroundStyle(WordeckTheme.primary) + } + if deck.isFromMarketplace { + Image(systemName: "globe") + .font(.caption2) + .foregroundStyle(WordeckTheme.mutedForeground) + } + Spacer() + editButton + } + } + + private var editButton: some View { + Button { + onEdit() + } label: { + Image(systemName: "pencil") + .font(.footnote.weight(.semibold)) + .foregroundStyle(WordeckTheme.mutedForeground) + .frame(width: 30, height: 30) + .background(WordeckTheme.muted.opacity(0.7), in: Circle()) + .overlay( + Circle().stroke(WordeckTheme.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("Deck bearbeiten") + } +} + +extension DeckCategory { + var systemImageName: String { + switch self { + case .language: "character.book.closed" + case .medicine: "cross.case" + case .science: "atom" + case .math: "function" + case .history: "scroll" + case .law: "scale.3d" + case .technology: "cpu" + case .arts: "paintbrush" + case .music: "music.note" + case .sport: "figure.run" + case .other: "rectangle.stack" + } + } +} diff --git a/Sources/Features/Decks/PendingShareConsumeView.swift b/Sources/Features/Decks/PendingShareConsumeView.swift index 00266bb..1b5359b 100644 --- a/Sources/Features/Decks/PendingShareConsumeView.swift +++ b/Sources/Features/Decks/PendingShareConsumeView.swift @@ -33,7 +33,7 @@ struct PendingShareConsumeView: View { Section("Ziel-Deck") { if decks.isEmpty { Text("Erst ein Deck erstellen.") - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) } else { Picker("Deck", selection: $selectedDeckId) { Text("Wählen …").tag(String?.none) @@ -55,14 +55,14 @@ struct PendingShareConsumeView: View { Section("Quelle") { Text(sourceURL) .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) } } if let errorMessage { Section { Text(errorMessage) .font(.footnote) - .foregroundStyle(CardsTheme.error) + .foregroundStyle(WordeckTheme.error) } } } @@ -92,12 +92,11 @@ struct PendingShareConsumeView: View { defer { isSubmitting = false } let backText = back.trimmed.isEmpty ? (share.sourceURL ?? "—") : back.trimmed - let api = CardsAPI(auth: auth) + let api = WordeckAPI(auth: auth) let body = CardCreateBody( deckId: deckId, type: .basic, - fields: CardFieldsBuilder.basic(front: front.trimmed, back: backText), - mediaRefs: nil + fields: CardFieldsBuilder.basic(front: front.trimmed, back: backText) ) do { _ = try await api.createCard(body) diff --git a/Sources/Features/Editor/CSVImportFormSections.swift b/Sources/Features/Editor/CSVImportFormSections.swift new file mode 100644 index 0000000..d33a909 --- /dev/null +++ b/Sources/Features/Editor/CSVImportFormSections.swift @@ -0,0 +1,78 @@ +import SwiftUI + +/// CSV-Import-Form für den `.csv`-Sub-Modus in `DeckEditorView`. Zeigt +/// File-Picker-Button, Deck-Namens-Feld und eine Preview-Liste der +/// erkannten Karten. +/// +/// State (Datei-Picker-Bool, geparste Rows, Deck-Name) lebt im Parent — +/// dieser View arbeitet nur über `@Binding`. +struct CSVImportFormSections: View { + @Binding var rows: [CSVRow] + @Binding var deckName: String + @Binding var showImporter: Bool + + var body: some View { + Section { + Button { + showImporter = true + } label: { + Label(rows.isEmpty ? "CSV-Datei wählen" : "Andere Datei wählen", systemImage: "doc.text") + } + } header: { + Text("Datei") + } footer: { + Text("Format pro Zeile: vorne,hinten,typ. Typ-Spalte optional (Default basic).") + } + + if !rows.isEmpty { + Section("Deck-Name") { + TextField("Deck-Name", text: $deckName) + .textInputAutocapitalization(.sentences) + } + + Section { + preview + } header: { + Text("Vorschau (\(rows.count) Karten)") + } + } + } + + @ViewBuilder + private var preview: some View { + let visible = rows.prefix(8) + ForEach(Array(visible.enumerated()), id: \.offset) { _, row in + VStack(alignment: .leading, spacing: 4) { + Text(row.front) + .font(.subheadline) + .lineLimit(2) + .foregroundStyle(WordeckTheme.foreground) + Text(row.back) + .font(.caption) + .lineLimit(2) + .foregroundStyle(WordeckTheme.mutedForeground) + if row.type != .basic { + Text(typeLabel(row.type)) + .font(.caption2) + .foregroundStyle(WordeckTheme.primary) + } + } + .padding(.vertical, 2) + } + if rows.count > visible.count { + Text("… und \(rows.count - visible.count) weitere") + .font(.caption) + .foregroundStyle(WordeckTheme.mutedForeground) + } + } + + private func typeLabel(_ type: CardType) -> String { + switch type { + case .basic: "Einfach" + case .basicReverse: "Beidseitig" + case .cloze: "Lückentext" + case .typing: "Eintippen" + case .multipleChoice: "Multiple Choice" + } + } +} diff --git a/Sources/Features/Editor/CardEditorView.swift b/Sources/Features/Editor/CardEditorView.swift index d5bc324..4fcbdbe 100644 --- a/Sources/Features/Editor/CardEditorView.swift +++ b/Sources/Features/Editor/CardEditorView.swift @@ -1,55 +1,86 @@ import ManaCore -import PhotosUI import SwiftUI -#if canImport(UIKit) -import UIKit -#endif - -/// Card-Create-View. Type-Picker oben, type-spezifische Felder unten. -/// Deckt alle 7 Card-Types ab. +/// Card-Create und Card-Edit in einer View. +/// +/// - `.create(deckId:)` zeigt Type-Picker + leere Felder. +/// - `.edit(card:)` blendet Type-Picker aus (Server-seitig immutable), +/// pre-fillt alle Felder, und PATCHt auf Submit. struct CardEditorView: View { - let deckId: String - let onCreated: (Card) -> Void + enum Mode { + case create(deckId: String) + case edit(card: Card) + } + + let mode: Mode + let onSaved: (Card) -> Void @Environment(AuthClient.self) private var auth @Environment(\.dismiss) private var dismiss - @State private var type: CardType = .basic - @State private var front: String = "" - @State private var back: String = "" - @State private var clozeText: String = "" - @State private var typingAnswer: String = "" - @State private var multipleChoiceAnswer: String = "" + @State private var type: CardType + @State private var front: String + @State private var back: String + @State private var clozeText: String + @State private var typingAnswer: String + @State private var multipleChoiceAnswer: String @State private var isSubmitting = false @State private var errorMessage: String? - // Image-Occlusion-State - @State private var imagePickerItem: PhotosPickerItem? - @State private var occlusionImage: PlatformImage? - @State private var occlusionImageData: Data? - @State private var occlusionMimeType: String = "image/jpeg" - @State private var occlusionRegions: [MaskRegion] = [] - @State private var occlusionNote: String = "" - - // Audio-Front-State - @State private var audioFileURL: URL? - @State private var showAudioPicker = false - private static let supportedTypes: [CardType] = [ - .basic, .basicReverse, .cloze, .typing, .multipleChoice, - .imageOcclusion, .audioFront, + .basic, .basicReverse, .cloze, .typing, .multipleChoice ] + init(mode: Mode, onSaved: @escaping (Card) -> Void) { + self.mode = mode + self.onSaved = onSaved + + let initialType: CardType + var initialFront = "" + var initialBack = "" + var initialCloze = "" + var initialTyping = "" + var initialMC = "" + + switch mode { + case .create: + initialType = .basic + case let .edit(card): + initialType = card.type + switch card.type { + case .basic, .basicReverse: + initialFront = card.fields["front"] ?? "" + initialBack = card.fields["back"] ?? "" + case .cloze: + initialCloze = card.fields["text"] ?? "" + case .typing: + initialFront = card.fields["front"] ?? "" + initialTyping = card.fields["answer"] ?? "" + case .multipleChoice: + initialFront = card.fields["front"] ?? "" + initialMC = card.fields["answer"] ?? "" + } + } + + _type = State(initialValue: initialType) + _front = State(initialValue: initialFront) + _back = State(initialValue: initialBack) + _clozeText = State(initialValue: initialCloze) + _typingAnswer = State(initialValue: initialTyping) + _multipleChoiceAnswer = State(initialValue: initialMC) + } + var body: some View { Form { - Section("Card-Type") { - Picker("Typ", selection: $type) { - ForEach(Self.supportedTypes, id: \.self) { t in - Text(label(for: t)).tag(t) + if isCreate { + Section("Card-Type") { + Picker("Typ", selection: $type) { + ForEach(Self.supportedTypes, id: \.self) { cardType in + Text(label(for: cardType)).tag(cardType) + } } + .pickerStyle(.menu) } - .pickerStyle(.menu) } typeFields @@ -58,11 +89,12 @@ struct CardEditorView: View { Section { Text(errorMessage) .font(.footnote) - .foregroundStyle(CardsTheme.error) + .foregroundStyle(WordeckTheme.error) } } } - .navigationTitle("Neue Karte") + .disabled(isSubmitting) + .navigationTitle(isCreate ? "Neue Karte" : "Karte bearbeiten") #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif @@ -71,8 +103,10 @@ struct CardEditorView: View { Button("Abbrechen") { dismiss() } } ToolbarItem(placement: .confirmationAction) { - Button("Erstellen") { Task { await submit() } } - .disabled(!canSubmit || isSubmitting) + Button(isCreate ? "Erstellen" : "Speichern") { + Task { await submit() } + } + .disabled(!canSubmit || isSubmitting) } } } @@ -93,33 +127,36 @@ struct CardEditorView: View { Section { Text("Beide Richtungen werden gelernt — front→back und back→front.") .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) } } case .cloze: Section("Cloze-Text") { - TextField("Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.", - text: $clozeText, axis: .vertical) - .lineLimit(3 ... 8) - .autocorrectionDisabled() - .textInputAutocapitalization(.sentences) - .monospaced() + TextField( + "Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.", + text: $clozeText, + axis: .vertical + ) + .lineLimit(3 ... 8) + .autocorrectionDisabled() + .textInputAutocapitalization(.sentences) + .monospaced() } Section { let count = Cloze.subIndexCount(clozeText) if count > 0 { Label("\(count) Lücken erkannt → \(count) Reviews", systemImage: "checkmark.circle.fill") .font(.caption) - .foregroundStyle(CardsTheme.success) + .foregroundStyle(WordeckTheme.success) } else { Label("Mindestens ein Cluster `{{c1::...}}` erforderlich", systemImage: "exclamationmark.circle") .font(.caption) - .foregroundStyle(CardsTheme.warning) + .foregroundStyle(WordeckTheme.warning) } Text("Mit Hint: `{{c1::Berlin::Hauptstadt von DE}}`") .font(.caption2) - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) } case .typing: @@ -142,127 +179,14 @@ struct CardEditorView: View { Section { Text("Distractor-Optionen werden zur Lernzeit automatisch aus anderen Karten desselben Decks gezogen.") .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - } - - case .imageOcclusion: - imageOcclusionFields - - case .audioFront: - audioFrontFields - } - } - - @ViewBuilder - private var imageOcclusionFields: some View { - Section("Bild") { - PhotosPicker(selection: $imagePickerItem, matching: .images) { - if occlusionImage == nil { - Label("Bild auswählen", systemImage: "photo") - } else { - Label("Bild ersetzen", systemImage: "arrow.triangle.2.circlepath") - } - } - .onChange(of: imagePickerItem) { _, newItem in - Task { await loadPickedImage(newItem) } - } - } - - if let image = occlusionImage { - Section("Masken") { - MaskEditorView(image: image, regions: $occlusionRegions) - } - } - - Section("Hinweis (optional)") { - TextField("z.B. Kurz-Erklärung", text: $occlusionNote, axis: .vertical) - .lineLimit(1 ... 3) - } - - Section { - if occlusionImage == nil { - Label("Erst Bild wählen", systemImage: "info.circle") - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - } else if occlusionRegions.isEmpty { - Label("Mindestens eine Maske nötig", systemImage: "exclamationmark.circle") - .font(.caption) - .foregroundStyle(CardsTheme.warning) - } else { - Label("\(occlusionRegions.count) Masken → \(occlusionRegions.count) Reviews", - systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(CardsTheme.success) + .foregroundStyle(WordeckTheme.mutedForeground) } } } - @ViewBuilder - private var audioFrontFields: some View { - Section("Audio-Datei") { - Button { - showAudioPicker = true - } label: { - if let audioFileURL { - Label(audioFileURL.lastPathComponent, systemImage: "waveform") - } else { - Label("Audio auswählen", systemImage: "waveform.badge.plus") - } - } - .fileImporter( - isPresented: $showAudioPicker, - allowedContentTypes: [.audio, .mp3, .wav, .mpeg4Audio], - allowsMultipleSelection: false - ) { result in - if case let .success(urls) = result, let first = urls.first { - audioFileURL = first - } - } - } - Section("Antwort") { - TextField("Was zu hören ist", text: $back, axis: .vertical) - .lineLimit(2 ... 4) - } - } - - private func loadPickedImage(_ item: PhotosPickerItem?) async { - guard let item else { return } - do { - guard let data = try await item.loadTransferable(type: Data.self) else { return } - occlusionImageData = data - occlusionMimeType = inferMimeType(from: data) - if let img = PlatformImage(data: data) { - occlusionImage = img - occlusionRegions = [] // neue Bildauswahl resetet Masken - } - } catch { - errorMessage = "Bild konnte nicht geladen werden: \(error.localizedDescription)" - } - } - - private func inferMimeType(from data: Data) -> String { - // Schneller Magic-Byte-Check für die häufigsten Formate - guard data.count > 4 else { return "image/jpeg" } - let bytes = Array(data.prefix(8)) - if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { return "image/jpeg" } - if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "image/png" } - if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) { return "image/gif" } - // WebP: starts with "RIFF" + 4 bytes size + "WEBP" - if bytes.count >= 8, - bytes[0 ... 3] == [0x52, 0x49, 0x46, 0x46] { - return "image/webp" - } - return "image/jpeg" - } - - private func audioMimeType(for url: URL) -> String { - switch url.pathExtension.lowercased() { - case "mp3": "audio/mpeg" - case "wav": "audio/wav" - case "m4a", "mp4": "audio/mp4" - case "ogg", "oga": "audio/ogg" - default: "audio/mpeg" - } + private var isCreate: Bool { + if case .create = mode { return true } + return false } private var canSubmit: Bool { @@ -275,10 +199,22 @@ struct CardEditorView: View { !front.trimmed.isEmpty && !typingAnswer.trimmed.isEmpty case .multipleChoice: !front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty - case .imageOcclusion: - occlusionImageData != nil && !occlusionRegions.isEmpty - case .audioFront: - audioFileURL != nil && !back.trimmed.isEmpty + } + } + + private func buildFields() -> [String: String] { + switch type { + case .basic, .basicReverse: + CardFieldsBuilder.basic(front: front.trimmed, back: back.trimmed) + case .cloze: + CardFieldsBuilder.cloze(text: clozeText.trimmed) + case .typing: + CardFieldsBuilder.typing(front: front.trimmed, answer: typingAnswer.trimmed) + case .multipleChoice: + CardFieldsBuilder.multipleChoice( + front: front.trimmed, + answer: multipleChoiceAnswer.trimmed + ) } } @@ -286,50 +222,21 @@ struct CardEditorView: View { isSubmitting = true errorMessage = nil defer { isSubmitting = false } - let api = CardsAPI(auth: auth) + let api = WordeckAPI(auth: auth) + let fields = buildFields() do { - let fields: [String: String] - var mediaRefs: [String]? = nil - switch type { - case .basic, .basicReverse: - fields = CardFieldsBuilder.basic(front: front.trimmed, back: back.trimmed) - case .cloze: - fields = CardFieldsBuilder.cloze(text: clozeText.trimmed) - case .typing: - fields = CardFieldsBuilder.typing(front: front.trimmed, answer: typingAnswer.trimmed) - case .multipleChoice: - fields = CardFieldsBuilder.multipleChoice(front: front.trimmed, answer: multipleChoiceAnswer.trimmed) - case .imageOcclusion: - guard let data = occlusionImageData else { return } - let media = try await api.uploadMedia( - data: data, - filename: "occlusion.\(occlusionMimeType.contains("png") ? "png" : "jpg")", - mimeType: occlusionMimeType - ) - fields = CardFieldsBuilder.imageOcclusion( - imageRef: media.id, - regions: occlusionRegions, - note: occlusionNote.trimmed.isEmpty ? nil : occlusionNote.trimmed - ) - mediaRefs = [media.id] - case .audioFront: - guard let url = audioFileURL else { return } - let didStart = url.startAccessingSecurityScopedResource() - defer { if didStart { url.stopAccessingSecurityScopedResource() } } - let data = try Data(contentsOf: url) - let media = try await api.uploadMedia( - data: data, - filename: url.lastPathComponent, - mimeType: audioMimeType(for: url) - ) - fields = CardFieldsBuilder.audioFront(audioRef: media.id, back: back.trimmed) - mediaRefs = [media.id] + let card: Card = switch mode { + case let .create(deckId): + try await api.createCard(CardCreateBody( + deckId: deckId, + type: type, + fields: fields + )) + case let .edit(existing): + try await api.updateCard(id: existing.id, body: CardUpdateBody(fields: fields)) } - - let body = CardCreateBody(deckId: deckId, type: type, fields: fields, mediaRefs: mediaRefs) - let card = try await api.createCard(body) - onCreated(card) + onSaved(card) dismiss() } catch { errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) @@ -343,8 +250,6 @@ struct CardEditorView: View { case .cloze: "Lückentext" case .typing: "Eintippen" case .multipleChoice: "Multiple Choice" - case .imageOcclusion: "Bild-Verdeckung" - case .audioFront: "Audio" } } } diff --git a/Sources/Features/Editor/DeckEditorHelpers.swift b/Sources/Features/Editor/DeckEditorHelpers.swift new file mode 100644 index 0000000..e160927 --- /dev/null +++ b/Sources/Features/Editor/DeckEditorHelpers.swift @@ -0,0 +1,55 @@ +import Foundation +import ManaCore + +/// Konstanten für `DeckEditorView` — Farbpalette. +/// Werte gespiegelt aus dem `forest`-Theme. +enum DeckEditorPresets { + /// 8 Farb-Presets aus dem forest-Theme. Freie Hex-Werte später + /// via Custom-Picker. + static let colors: [String] = [ + "#10803D", // forest primary light + "#1E3A2F", // forest dark + "#D97706", // amber + "#DC2626", // red + "#2563EB", // blue + "#7C3AED", // violet + "#0D9488", // teal + "#737373" // neutral + ] +} + +/// Reine Hilfsfunktionen für `DeckEditorView` — kein State, keine Bindings. +enum DeckEditorHelpers { + /// Nil zurück wenn String nach Trim leer ist. + static func nonEmpty(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespaces) + return trimmed.isEmpty ? nil : trimmed + } + + /// http:// oder https:// und nicht-leer. + static func isValidURL(_ value: String) -> Bool { + let trimmed = value.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return false } + guard let url = URL(string: trimmed), let scheme = url.scheme else { return false } + return scheme == "http" || scheme == "https" + } + + /// AuthError-Server-Codes auf nutzerfreundliche deutsche Texte mappen. + static func mapAIError(_ error: AuthError) -> String { + if case let .serverError(status, _, message) = error { + switch status { + case 429: + return "Zu viele KI-Anfragen. Bitte eine Minute warten." + case 413: + return message ?? "Datei zu groß." + case 422, 400: + return message ?? "Eingabe ungültig." + case 502: + return message ?? "KI-Server gerade nicht erreichbar." + default: + break + } + } + return error.errorDescription ?? "Unbekannter Fehler." + } +} diff --git a/Sources/Features/Editor/DeckEditorView.swift b/Sources/Features/Editor/DeckEditorView.swift index 6be1022..1ae7e70 100644 --- a/Sources/Features/Editor/DeckEditorView.swift +++ b/Sources/Features/Editor/DeckEditorView.swift @@ -1,187 +1,547 @@ import ManaCore import SwiftUI -/// Deck-Create und Deck-Edit in einer View. `existing == nil` → Create- -/// Modus mit "Erstellen"-Button. Sonst Edit-Modus mit "Speichern". +// swiftlint:disable file_length +// swiftlint:disable type_body_length + +/// Deck-Create und Deck-Edit in einer View. Im Create-Modus stehen drei +/// Sub-Modi zur Wahl: manuell („Leer"), AI-Text („Mit KI") und CSV. +/// Edit-Modus zeigt nur das manuelle Formular. +/// +/// Web-Vorbild: `wordeck/apps/web/src/routes/decks/new/+page.svelte`. struct DeckEditorView: View { - enum Mode: Sendable { + enum Mode { case create case edit(deckId: String) } + /// Drei Sub-Modi im Create-Sheet. + enum CreateMode: Hashable { + case manual + case aiText + case csv + } + let mode: Mode let onSaved: (Deck) -> Void @Environment(AuthClient.self) private var auth @Environment(\.dismiss) private var dismiss + // Manual fields (Edit + Create.manual) @State private var name: String @State private var description: String @State private var color: String @State private var category: DeckCategory? @State private var visibility: DeckVisibility - @State private var isSubmitting = false - @State private var errorMessage: String? + @State private var archived: Bool - /// Vorgefüllte Farbpalette aus dem forest-Theme. User können - /// freie Hex-Werte später via Picker setzen (β-3-extension). - private static let presetColors: [String] = [ - "#10803D", // forest primary light - "#1E3A2F", // forest dark - "#D97706", // amber - "#DC2626", // red - "#2563EB", // blue - "#7C3AED", // violet - "#0D9488", // teal - "#737373", // neutral - ] + /// Create-mode selector + @State private var createMode: CreateMode = .manual + + // AI-Text + @State private var aiPrompt: String = "" + @State private var aiCount: Int = 15 + @State private var aiLanguage: GenerationLanguage = .de + @State private var aiUrl: String = "" + + // CSV-Import + @State private var csvRows: [CSVRow] = [] + @State private var csvDeckName: String = "" + @State private var showCSVImporter: Bool = false + @State private var csvImportProgress: Int = 0 + + // Submission + @State private var isSubmitting = false + @State private var generationTask: Task? + @State private var errorMessage: String? init(mode: Mode, existing: CachedDeck? = nil, onSaved: @escaping (Deck) -> Void) { self.mode = mode self.onSaved = onSaved _name = State(initialValue: existing?.name ?? "") _description = State(initialValue: existing?.deckDescription ?? "") - _color = State(initialValue: existing?.color ?? Self.presetColors[0]) + _color = State(initialValue: existing?.color ?? DeckEditorPresets.colors[0]) _category = State(initialValue: existing?.category) _visibility = State(initialValue: DeckVisibility(rawValue: existing?.visibilityRaw ?? "private") ?? .private) + _archived = State(initialValue: existing?.archivedAt != nil) } var body: some View { - Form { - Section("Name") { - TextField("Deck-Name", text: $name) - .textInputAutocapitalization(.sentences) - } - - Section("Beschreibung") { - TextField("optional", text: $description, axis: .vertical) - .lineLimit(2 ... 4) - } - - Section("Farbe") { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 10) { - ForEach(Self.presetColors, id: \.self) { hex in - colorSwatch(hex) - } - } - .padding(.vertical, 4) + ZStack { + Form { + if isCreate { + modePickerSection } + formSections + errorSection } + .disabled(isSubmitting) - Section("Kategorie") { - Picker("Kategorie", selection: $category) { - Text("Keine").tag(DeckCategory?.none) - ForEach(DeckCategory.allCases, id: \.self) { cat in - Text(cat.label).tag(DeckCategory?.some(cat)) - } - } - } - - Section("Sichtbarkeit") { - Picker("Sichtbarkeit", selection: $visibility) { - Text("Privat").tag(DeckVisibility.private) - Text("Space").tag(DeckVisibility.space) - Text("Öffentlich").tag(DeckVisibility.public) - } - .pickerStyle(.segmented) - } - - if let errorMessage { - Section { - Text(errorMessage) - .font(.footnote) - .foregroundStyle(CardsTheme.error) - } + if isSubmitting, activeMode != .manual { + GenerationOverlay( + message: overlayMessage, + onCancel: { generationTask?.cancel() } + ) } } - .navigationTitle(isCreate ? "Neues Deck" : "Deck bearbeiten") + .navigationTitle(navTitle) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Abbrechen") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button(isCreate ? "Erstellen" : "Speichern") { - Task { await submit() } - } - .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || isSubmitting) - } - } + .toolbar { toolbar } + .fileImporter( + isPresented: $showCSVImporter, + allowedContentTypes: [.commaSeparatedText, .plainText], + allowsMultipleSelection: false, + onCompletion: handleCSVImport + ) } + // MARK: - Sections + + private var modePickerSection: some View { + Section { + Picker("Modus", selection: $createMode) { + Text("Leer").tag(CreateMode.manual) + Text("KI").tag(CreateMode.aiText) + Text("CSV").tag(CreateMode.csv) + } + .pickerStyle(.segmented) + } footer: { + modeFooter + } + } + + @ViewBuilder + private var modeFooter: some View { + switch createMode { + case .manual: + Text("Leeres Deck — Karten anschließend selbst anlegen.") + case .aiText: + Text("KI generiert das Deck aus einer kurzen Beschreibung. 10 Anfragen pro Minute.") + case .csv: + Text("CSV-Datei einlesen. Format: vorne,hinten[,typ] pro Zeile.") + } + } + + @ViewBuilder + private var formSections: some View { + switch activeMode { + case .manual: + ManualFormSections( + name: $name, + description: $description, + color: $color, + category: $category, + visibility: $visibility, + archived: isCreate ? nil : $archived + ) + case .aiText: + AITextFormSections(prompt: $aiPrompt) + AISharedSections(count: $aiCount, language: $aiLanguage, url: $aiUrl) + case .csv: + CSVImportFormSections( + rows: $csvRows, + deckName: $csvDeckName, + showImporter: $showCSVImporter + ) + } + } + + @ViewBuilder + private var errorSection: some View { + if let errorMessage { + Section { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(WordeckTheme.error) + } + } + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { + generationTask?.cancel() + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button(confirmLabel) { + startSubmit() + } + .disabled(!canSubmit || isSubmitting) + } + } + + // MARK: - Computed state + private var isCreate: Bool { if case .create = mode { return true } return false } - @ViewBuilder - private func colorSwatch(_ hex: String) -> some View { - let isSelected = color == hex + private var activeMode: CreateMode { + isCreate ? createMode : .manual + } + + private var navTitle: String { + switch activeMode { + case .manual: isCreate ? "Neues Deck" : "Deck bearbeiten" + case .aiText: "Mit KI generieren" + case .csv: "Aus CSV importieren" + } + } + + private var confirmLabel: String { + switch activeMode { + case .manual: isCreate ? "Erstellen" : "Speichern" + case .aiText: "Generieren" + case .csv: csvRows.isEmpty ? "Importieren" : "\(csvRows.count) Karten importieren" + } + } + + private var canSubmit: Bool { + switch activeMode { + case .manual: + !name.trimmingCharacters(in: .whitespaces).isEmpty + case .aiText: + aiPrompt.trimmingCharacters(in: .whitespaces).count >= 3 + case .csv: + !csvRows.isEmpty && !csvDeckName.trimmingCharacters(in: .whitespaces).isEmpty + } + } + + private var overlayMessage: String { + switch activeMode { + case .csv: + csvImportProgress > 0 + ? "Karten werden importiert (\(csvImportProgress) / \(csvRows.count)) …" + : "Import wird vorbereitet …" + default: + "Karten werden generiert …" + } + } + + // MARK: - CSV ingest + + private func handleCSVImport(_ result: Result<[URL], Error>) { + switch result { + case let .success(urls): + guard let url = urls.first else { return } + let didStart = url.startAccessingSecurityScopedResource() + defer { if didStart { url.stopAccessingSecurityScopedResource() } } + do { + let text = try String(contentsOf: url, encoding: .utf8) + let rows = try CSVParser.parse(text) + csvRows = rows + if csvDeckName.trimmingCharacters(in: .whitespaces).isEmpty { + csvDeckName = url.deletingPathExtension().lastPathComponent + } + } catch { + errorMessage = "CSV-Import fehlgeschlagen: \(error.localizedDescription)" + } + case let .failure(error): + errorMessage = "Datei-Auswahl fehlgeschlagen: \(error.localizedDescription)" + } + } + + // MARK: - Submit + + private func startSubmit() { + errorMessage = nil + isSubmitting = true + generationTask = Task { + await submit() + isSubmitting = false + generationTask = nil + } + } + + private func submit() async { + let api = WordeckAPI(auth: auth) + do { + switch (mode, activeMode) { + case (.create, .manual): + let deck = try await api.createDeck(manualCreateBody) + onSaved(deck) + dismiss() + case let (.edit(deckId), _): + let deck = try await api.updateDeck(id: deckId, body: manualUpdateBody) + onSaved(deck) + dismiss() + case (.create, .aiText): + let response = try await api.generateDeckFromText(aiTextBody) + try Task.checkCancellation() + onSaved(response.deck) + dismiss() + case (.create, .csv): + let deck = try await submitCSVImport(api: api) + onSaved(deck) + dismiss() + } + } catch is CancellationError { + // User-Abbruch → kein Banner. + } catch let error as AuthError { + errorMessage = DeckEditorHelpers.mapAIError(error) + } catch { + errorMessage = error.localizedDescription + } + } + + private var manualCreateBody: DeckCreateBody { + DeckCreateBody( + name: name.trimmingCharacters(in: .whitespaces), + description: DeckEditorHelpers.nonEmpty(description), + color: color, + category: category, + visibility: visibility + ) + } + + private var manualUpdateBody: DeckUpdateBody { + DeckUpdateBody( + name: name.trimmingCharacters(in: .whitespaces), + description: DeckEditorHelpers.nonEmpty(description), + color: color, + category: category, + visibility: visibility, + archived: archived + ) + } + + private func submitCSVImport(api: WordeckAPI) async throws -> Deck { + let deck = try await api.createDeck(DeckCreateBody( + name: csvDeckName.trimmingCharacters(in: .whitespaces), + description: "Aus CSV-Import (\(csvRows.count) Karten)", + color: color, + category: category, + visibility: visibility + )) + csvImportProgress = 0 + for (index, row) in csvRows.enumerated() { + try Task.checkCancellation() + let fields: [String: String] = switch row.type { + case .basic, .basicReverse: + CardFieldsBuilder.basic(front: row.front, back: row.back) + case .cloze: + CardFieldsBuilder.cloze(text: row.front) + case .typing: + CardFieldsBuilder.typing(front: row.front, answer: row.back) + case .multipleChoice: + CardFieldsBuilder.multipleChoice(front: row.front, answer: row.back) + } + _ = try await api.createCard(CardCreateBody( + deckId: deck.id, + type: row.type, + fields: fields + )) + csvImportProgress = index + 1 + } + return deck + } + + private var aiTextBody: DeckGenerateBody { + DeckGenerateBody( + prompt: aiPrompt.trimmingCharacters(in: .whitespaces), + language: aiLanguage, + count: aiCount, + url: DeckEditorHelpers.nonEmpty(aiUrl) + ) + } +} + +// swiftlint:enable type_body_length + +// MARK: - Manual form + +private struct ManualFormSections: View { + @Binding var name: String + @Binding var description: String + @Binding var color: String + @Binding var category: DeckCategory? + @Binding var visibility: DeckVisibility + /// `nil` im Create-Modus — dann wird der Toggle nicht gezeigt. + var archived: Binding? + + var body: some View { + Section("Name") { + TextField("Deck-Name", text: $name) + .textInputAutocapitalization(.sentences) + } + + Section("Beschreibung") { + TextField("optional", text: $description, axis: .vertical) + .lineLimit(2 ... 4) + } + + Section("Farbe") { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(DeckEditorPresets.colors, id: \.self) { hex in + ColorSwatchButton(hex: hex, isSelected: color == hex) { + color = hex + } + } + } + .padding(.vertical, 4) + } + } + + Section("Kategorie") { + Picker("Kategorie", selection: $category) { + Text("Keine").tag(DeckCategory?.none) + ForEach(DeckCategory.allCases, id: \.self) { cat in + Text(cat.label).tag(DeckCategory?.some(cat)) + } + } + } + + Section("Sichtbarkeit") { + Picker("Sichtbarkeit", selection: $visibility) { + Text("Privat").tag(DeckVisibility.private) + Text("Space").tag(DeckVisibility.space) + Text("Öffentlich").tag(DeckVisibility.public) + } + .pickerStyle(.segmented) + } + + if let archived { + Section { + Toggle("Archiviert", isOn: archived) + } footer: { + Text("Archivierte Decks erscheinen nicht in der Hauptliste. Bestehende FSRS-Reviews bleiben erhalten.") + } + } + } +} + +private struct ColorSwatchButton: View { + let hex: String + let isSelected: Bool + let onTap: () -> Void + + var body: some View { Circle() .fill(Color.swatchFromHex(hex)) .frame(width: 36, height: 36) .overlay( Circle() - .stroke(isSelected ? CardsTheme.foreground : CardsTheme.border, lineWidth: isSelected ? 3 : 1) + .stroke(isSelected ? WordeckTheme.foreground : WordeckTheme.border, lineWidth: isSelected ? 3 : 1) ) - .onTapGesture { color = hex } - } - - private func submit() async { - isSubmitting = true - errorMessage = nil - defer { isSubmitting = false } - let api = CardsAPI(auth: auth) - - do { - switch mode { - case .create: - let body = DeckCreateBody( - name: name.trimmingCharacters(in: .whitespaces), - description: nonEmpty(description), - color: color, - category: category, - visibility: visibility - ) - let deck = try await api.createDeck(body) - onSaved(deck) - dismiss() - case let .edit(deckId): - let body = DeckUpdateBody( - name: name.trimmingCharacters(in: .whitespaces), - description: nonEmpty(description), - color: color, - category: category, - visibility: visibility - ) - let deck = try await api.updateDeck(id: deckId, body: body) - onSaved(deck) - dismiss() - } - } catch { - errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) - } - } - - private func nonEmpty(_ s: String) -> String? { - let trimmed = s.trimmingCharacters(in: .whitespaces) - return trimmed.isEmpty ? nil : trimmed + .onTapGesture(perform: onTap) } } +// MARK: - AI text form + +private struct AITextFormSections: View { + @Binding var prompt: String + + var body: some View { + Section { + TextField( + "z.B. Bodensee-Geographie, französische Verben", + text: $prompt, + axis: .vertical + ) + .lineLimit(3 ... 6) + .textInputAutocapitalization(.sentences) + } header: { + Text("Thema") + } footer: { + Text("3–500 Zeichen. Je präziser, desto besser die Karten.") + } + } +} + +// MARK: - Shared AI controls + +private struct AISharedSections: View { + @Binding var count: Int + @Binding var language: GenerationLanguage + @Binding var url: String + + var body: some View { + Section("Anzahl Karten") { + Stepper(value: $count, in: 3 ... 40) { + Text("\(count) Karten") + } + } + + Section("Sprache") { + Picker("Sprache", selection: $language) { + ForEach(GenerationLanguage.allCases, id: \.self) { lang in + Text(lang.label).tag(lang) + } + } + .pickerStyle(.segmented) + } + + Section { + TextField("https://…", text: $url) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.URL) + } header: { + Text("Zusätzliche URL (optional)") + } footer: { + Text("KI liest den Inhalt der Seite als zusätzliche Quelle.") + } + } +} + +// MARK: - Generation overlay + +private struct GenerationOverlay: View { + let message: String + let onCancel: () -> Void + + var body: some View { + ZStack { + Color.black.opacity(0.55) + .ignoresSafeArea() + VStack(spacing: 16) { + ProgressView() + .controlSize(.large) + .tint(WordeckTheme.primary) + Text(message) + .font(.headline) + .foregroundStyle(WordeckTheme.foreground) + .multilineTextAlignment(.center) + Text("Das kann eine Weile dauern.") + .font(.caption) + .foregroundStyle(WordeckTheme.mutedForeground) + .multilineTextAlignment(.center) + Button("Abbrechen", action: onCancel) + .buttonStyle(.bordered) + .tint(WordeckTheme.mutedForeground) + .padding(.top, 4) + } + .padding(24) + .frame(maxWidth: 320) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + .transition(.opacity) + } +} + +// MARK: - Color helper + extension Color { static func swatchFromHex(_ hex: String) -> Color { var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("#") { trimmed = String(trimmed.dropFirst()) } guard let rgb = UInt32(trimmed, radix: 16) else { - return CardsTheme.primary + return WordeckTheme.primary } - let r = Double((rgb >> 16) & 0xFF) / 255.0 - let g = Double((rgb >> 8) & 0xFF) / 255.0 - let b = Double(rgb & 0xFF) / 255.0 - return Color(red: r, green: g, blue: b) + let red = Double((rgb >> 16) & 0xFF) / 255.0 + let green = Double((rgb >> 8) & 0xFF) / 255.0 + let blue = Double(rgb & 0xFF) / 255.0 + return Color(red: red, green: green, blue: blue) } } diff --git a/Sources/Features/Editor/MaskEditorView.swift b/Sources/Features/Editor/MaskEditorView.swift deleted file mode 100644 index ba04c75..0000000 --- a/Sources/Features/Editor/MaskEditorView.swift +++ /dev/null @@ -1,147 +0,0 @@ -import SwiftUI - -#if canImport(UIKit) -import UIKit -#endif - -/// Mask-Editor: Bild anzeigen, mit Drag-Gesten Rechtecke zeichnen, jede -/// Region mit Label versehen. Coordinaten 0..1 relativ zur Bild-Größe. -/// -/// Output binding ist `regions`. Caller serialisiert via `MaskRegions.encode()`. -struct MaskEditorView: View { - let image: PlatformImage - @Binding var regions: [MaskRegion] - - @State private var dragStart: CGPoint? - @State private var dragEnd: CGPoint? - @State private var nextIdCounter: Int = 0 - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Tippe und ziehe auf das Bild, um eine Maske zu erstellen.") - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - - imageCanvas - .aspectRatio(image.size.width / max(image.size.height, 1), contentMode: .fit) - .frame(maxWidth: .infinity) - .clipShape(RoundedRectangle(cornerRadius: 8)) - - if regions.isEmpty { - Text("Noch keine Maske") - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - } else { - ForEach(regions) { region in - maskRow(region: region) - } - } - } - } - - @ViewBuilder - private var imageCanvas: some View { - GeometryReader { geo in - ZStack(alignment: .topLeading) { - #if canImport(UIKit) - Image(uiImage: image).resizable().aspectRatio(contentMode: .fit) - #else - Image(nsImage: image).resizable().aspectRatio(contentMode: .fit) - #endif - - ForEach(regions) { region in - overlayRect(for: region, in: geo.size) - } - - if let dragStart, let dragEnd { - let rect = normalizedRect(from: dragStart, to: dragEnd) - Rectangle() - .stroke(CardsTheme.warning, lineWidth: 2) - .background(Rectangle().fill(CardsTheme.warning.opacity(0.2))) - .frame(width: rect.width, height: rect.height) - .offset(x: rect.minX, y: rect.minY) - } - } - .contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 4) - .onChanged { value in - if dragStart == nil { dragStart = value.startLocation } - dragEnd = value.location - } - .onEnded { value in - commitDrag(start: value.startLocation, end: value.location, in: geo.size) - } - ) - } - } - - private func overlayRect(for region: MaskRegion, in size: CGSize) -> some View { - Rectangle() - .fill(CardsTheme.primary.opacity(0.6)) - .frame(width: region.w * size.width, height: region.h * size.height) - .offset(x: region.x * size.width, y: region.y * size.height) - .overlay( - Text(region.label?.isEmpty == false ? region.label! : region.id) - .font(.caption2.weight(.bold)) - .foregroundStyle(CardsTheme.primaryForeground) - .padding(2) - .offset(x: region.x * size.width + 2, y: region.y * size.height + 2), - alignment: .topLeading - ) - } - - private func maskRow(region: MaskRegion) -> some View { - HStack(spacing: 8) { - Image(systemName: "square.dashed") - .foregroundStyle(CardsTheme.primary) - TextField("Label (optional)", text: Binding( - get: { region.label ?? "" }, - set: { newValue in updateLabel(for: region.id, to: newValue) } - )) - .textFieldStyle(.roundedBorder) - Button(role: .destructive) { - regions.removeAll { $0.id == region.id } - } label: { - Image(systemName: "trash") - .foregroundStyle(CardsTheme.error) - } - .buttonStyle(.plain) - } - } - - private func updateLabel(for id: String, to value: String) { - guard let idx = regions.firstIndex(where: { $0.id == id }) else { return } - let old = regions[idx] - regions[idx] = MaskRegion(id: old.id, x: old.x, y: old.y, w: old.w, h: old.h, label: value) - } - - private func normalizedRect(from start: CGPoint, to end: CGPoint) -> CGRect { - let x = min(start.x, end.x) - let y = min(start.y, end.y) - let w = abs(end.x - start.x) - let h = abs(end.y - start.y) - return CGRect(x: x, y: y, width: w, height: h) - } - - private func commitDrag(start: CGPoint, end: CGPoint, in size: CGSize) { - defer { - dragStart = nil - dragEnd = nil - } - let rect = normalizedRect(from: start, to: end) - // Mindestgröße 1% der Bildkante — Tap-Klicks ignorieren - guard rect.width > size.width * 0.01, rect.height > size.height * 0.01 else { return } - nextIdCounter += 1 - let id = String(format: "m%03d", nextIdCounter) - let normalized = MaskRegion( - id: id, - x: rect.minX / size.width, - y: rect.minY / size.height, - w: rect.width / size.width, - h: rect.height / size.height, - label: nil - ) - regions.append(normalized) - } -} diff --git a/Sources/Features/Marketplace/BrowseView.swift b/Sources/Features/Marketplace/BrowseView.swift index 37b9ba9..c364631 100644 --- a/Sources/Features/Marketplace/BrowseView.swift +++ b/Sources/Features/Marketplace/BrowseView.swift @@ -9,10 +9,10 @@ struct BrowseView: View { var body: some View { ZStack { - CardsTheme.background.ignoresSafeArea() + WordeckTheme.background.ignoresSafeArea() VStack(spacing: 0) { filters - Divider().background(CardsTheme.border) + Divider().background(WordeckTheme.border) resultsList } } @@ -20,8 +20,11 @@ struct BrowseView: View { #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif - .searchable(text: $queryText, placement: .navigationBarDrawer(displayMode: .always), - prompt: "Decks suchen") + .searchable( + text: $queryText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Decks suchen" + ) .onSubmit(of: .search) { store?.browseQuery = queryText Task { await store?.browse() } @@ -62,7 +65,7 @@ struct BrowseView: View { if store.isLoadingBrowse, store.browseResults.isEmpty { Spacer() ProgressView() - .tint(CardsTheme.primary) + .tint(WordeckTheme.primary) Spacer() } else if store.browseResults.isEmpty { ContentUnavailableView( @@ -70,7 +73,7 @@ struct BrowseView: View { systemImage: "magnifyingglass", description: Text("Versuche eine andere Suche oder Sortierung.") ) - .foregroundStyle(CardsTheme.foreground) + .foregroundStyle(WordeckTheme.foreground) } else { List { ForEach(store.browseResults) { entry in @@ -101,17 +104,17 @@ struct BrowseRow: View { HStack { Text(entry.title) .font(.headline) - .foregroundStyle(CardsTheme.foreground) + .foregroundStyle(WordeckTheme.foreground) if entry.isFeatured { Image(systemName: "star.fill") .font(.caption) - .foregroundStyle(CardsTheme.warning) + .foregroundStyle(WordeckTheme.warning) } } if let description = entry.description, !description.isEmpty { Text(description) .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) .lineLimit(2) } HStack(spacing: 12) { @@ -119,23 +122,23 @@ struct BrowseRow: View { Label("\(entry.starCount)", systemImage: "star") if entry.isPaid { Label("\(entry.priceCredits)", systemImage: "creditcard") - .foregroundStyle(CardsTheme.primary) + .foregroundStyle(WordeckTheme.primary) } if let language = entry.language { Text(language.uppercased()) .font(.caption2.weight(.semibold)) .padding(.horizontal, 4) .padding(.vertical, 1) - .background(CardsTheme.muted, in: Capsule()) + .background(WordeckTheme.muted, in: Capsule()) } } .font(.caption2) - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) } Spacer() Image(systemName: "chevron.right") .font(.footnote) - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) } .padding(.vertical, 8) } diff --git a/Sources/Features/Marketplace/ExploreView.swift b/Sources/Features/Marketplace/ExploreView.swift index 34a0c40..da72ea6 100644 --- a/Sources/Features/Marketplace/ExploreView.swift +++ b/Sources/Features/Marketplace/ExploreView.swift @@ -16,35 +16,38 @@ struct ExploreView: View { var body: some View { NavigationStack(path: $path) { ZStack { - CardsTheme.background.ignoresSafeArea() + WordeckTheme.background.ignoresSafeArea() content } .navigationTitle("Entdecken") - .navigationDestination(for: MarketplaceRoute.self) { route in - switch route { - case .browse: - BrowseView() - case let .publicDeck(slug): - PublicDeckView(slug: slug) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .navigationDestination(for: MarketplaceRoute.self) { route in + switch route { + case .browse: + BrowseView() + case let .publicDeck(slug): + PublicDeckView(slug: slug) + } } - } - .navigationDestination(for: String.self) { deckId in - DeckDetailView(deckId: deckId) - } - .refreshable { - await store?.loadExplore() - } - .task { - if store == nil { - store = MarketplaceStore(auth: auth) + .navigationDestination(for: String.self) { deckId in + DeckDetailView(deckId: deckId) + } + .refreshable { + await store?.loadExplore() + } + .task { + if store == nil { + store = MarketplaceStore(auth: auth) + } + await store?.loadExplore() + } + .onChange(of: deepLinkSlug) { _, newSlug in + guard let slug = newSlug else { return } + path = [.publicDeck(slug: slug)] + deepLinkSlug = nil } - await store?.loadExplore() - } - .onChange(of: deepLinkSlug) { _, newSlug in - guard let slug = newSlug else { return } - path = [.publicDeck(slug: slug)] - deepLinkSlug = nil - } } } @@ -53,22 +56,22 @@ struct ExploreView: View { if let store { if store.isLoadingExplore, store.featured.isEmpty, store.trending.isEmpty { ProgressView() - .tint(CardsTheme.primary) + .tint(WordeckTheme.primary) } else if let message = store.errorMessage, store.featured.isEmpty { ContentUnavailableView( "Marketplace nicht erreichbar", systemImage: "wifi.exclamationmark", description: Text(message) ) - .foregroundStyle(CardsTheme.foreground) + .foregroundStyle(WordeckTheme.foreground) } else { ScrollView { VStack(alignment: .leading, spacing: 24) { if !store.featured.isEmpty { - section(title: "Vorgestellt", items: store.featured) + section(title: "Vorgestellt", icon: "star.fill", items: store.featured) } if !store.trending.isEmpty { - section(title: "Im Trend", items: store.trending) + section(title: "Im Trend", icon: "flame.fill", items: store.trending) } NavigationLink(value: MarketplaceRoute.browse) { @@ -79,40 +82,51 @@ struct ExploreView: View { .font(.footnote) } .padding() - .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) + .background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10)) .overlay( RoundedRectangle(cornerRadius: 10) - .stroke(CardsTheme.border, lineWidth: 1) + .stroke(WordeckTheme.border, lineWidth: 1) ) - .foregroundStyle(CardsTheme.foreground) + .foregroundStyle(WordeckTheme.foreground) } .buttonStyle(.plain) - .padding(.horizontal, 16) + .padding(.horizontal, 20) } - .padding(.vertical, 16) + .padding(.vertical, 12) } } } } - private func section(title: String, items: [PublicDeckEntry]) -> some View { + private func section(title: String, icon: String, items: [PublicDeckEntry]) -> some View { VStack(alignment: .leading, spacing: 12) { - Text(title) - .font(.title3.weight(.semibold)) - .foregroundStyle(CardsTheme.foreground) - .padding(.horizontal, 16) + HStack(spacing: 6) { + Image(systemName: icon) + .foregroundStyle(WordeckTheme.primary) + Text(title) + .font(.title3.weight(.semibold)) + .foregroundStyle(WordeckTheme.foreground) + Text("\(items.count)") + .font(.subheadline) + .foregroundStyle(WordeckTheme.mutedForeground) + } + .padding(.horizontal, 20) ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { + HStack(alignment: .top, spacing: 16) { ForEach(items) { item in NavigationLink(value: MarketplaceRoute.publicDeck(slug: item.slug)) { PublicDeckCard(entry: item) + .frame(width: 240) } .buttonStyle(.plain) } } - .padding(.horizontal, 16) + .padding(.horizontal, 20) + .padding(.bottom, 12) + .scrollTargetLayout() } + .scrollTargetBehavior(.viewAligned) } } } @@ -123,58 +137,57 @@ enum MarketplaceRoute: Hashable { case publicDeck(slug: String) } -/// Public-Deck-Karten-Tile in Featured/Trending-Carousels und Browse-Grid. +/// Tile für Marketplace-Decks im Explore-Tab. Nutzt `DeckCoverTile` +/// als Basis (selber Look + Größe wie `DeckStackTile` auf der Decks- +/// Seite). Footer: Karten-Count, Star-Count, Credits, Owner-Badge. struct PublicDeckCard: View { let entry: PublicDeckEntry var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(entry.title) - .font(.headline) - .foregroundStyle(CardsTheme.foreground) - .lineLimit(2) - Spacer() - if entry.isFeatured { - Image(systemName: "star.fill") - .font(.caption) - .foregroundStyle(CardsTheme.warning) - } - } - if let description = entry.description, !description.isEmpty { - Text(description) - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - .lineLimit(2) - } - HStack(spacing: 12) { - Label("\(entry.cardCount)", systemImage: "rectangle.stack") - Label("\(entry.starCount)", systemImage: "star") - if entry.isPaid { - Label("\(entry.priceCredits) Credits", systemImage: "creditcard") - .foregroundStyle(CardsTheme.primary) - } - } - .font(.caption2) - .foregroundStyle(CardsTheme.mutedForeground) + DeckCoverTile( + title: entry.title, + description: entry.description, + category: parsedCategory, + seed: entry.slug, + colorAccentHex: nil, + isFeatured: entry.isFeatured + ) { + footerContent + } + } + private var parsedCategory: DeckCategory? { + guard let category = entry.category else { return nil } + return DeckCategory(rawValue: category) + } + + private var footerContent: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Label("\(entry.cardCount)", systemImage: "rectangle.stack") + .font(.caption2) + .foregroundStyle(WordeckTheme.mutedForeground) + Label("\(entry.starCount)", systemImage: "star.fill") + .font(.caption2) + .foregroundStyle(WordeckTheme.warning) + if entry.isPaid { + Label("\(entry.priceCredits)", systemImage: "creditcard") + .font(.caption2.weight(.semibold)) + .foregroundStyle(WordeckTheme.primary) + } + Spacer() + } HStack(spacing: 4) { Text(entry.owner.displayName) .font(.caption2) - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) + .lineLimit(1) if entry.owner.verifiedMana { Image(systemName: "checkmark.seal.fill") .font(.caption2) - .foregroundStyle(CardsTheme.primary) + .foregroundStyle(WordeckTheme.primary) } } } - .padding(12) - .frame(width: 260, alignment: .leading) - .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(CardsTheme.border, lineWidth: 1) - ) } } diff --git a/Sources/Features/Marketplace/MarketplacePublishView.swift b/Sources/Features/Marketplace/MarketplacePublishView.swift new file mode 100644 index 0000000..729d172 --- /dev/null +++ b/Sources/Features/Marketplace/MarketplacePublishView.swift @@ -0,0 +1,474 @@ +import ManaCore +import SwiftUI + +// swiftlint:disable file_length +// swiftlint:disable type_body_length + +/// Publish eines privaten Decks in den Wordeck-Marketplace. +/// +/// Modi: Erst-Publish (mit Author-Setup + Init + Publish 1.0.0) oder +/// neue Version eines existierenden Marketplace-Decks (Auto-Semver-Bump). +/// Image-Occlusion- und Audio-Front-Karten werden übersprungen — der +/// Server hat heute keinen Marketplace-Media-Re-Upload-Flow. +/// +/// `type_body_length` ist bewusst übersprungen — Publish-Flow ist eine +/// zusammenhängende State-Maschine (Author → Init → Publish). +struct MarketplacePublishView: View { + enum PublishMode: Hashable { + case firstPublish + case newVersion(slug: String) + } + + let privateDeck: CachedDeck + let onPublished: (MarketplacePublishResponse) -> Void + + @Environment(AuthClient.self) private var auth + @Environment(\.dismiss) private var dismiss + + // Publish-Mode + @State private var publishMode: PublishMode = .firstPublish + @State private var ownedDecks: [OwnedMarketplaceDeck] = [] + @State private var selectedExistingSlug: String? + + // Author-Profil-State + @State private var hasAuthor: Bool? + @State private var authorSlug: String = "" + @State private var authorDisplayName: String = "" + @State private var authorBio: String = "" + @State private var authorPseudonym: Bool = false + + // Deck-Metadaten + @State private var slug: String = "" + @State private var title: String = "" + @State private var deckDescription: String = "" + @State private var language: GenerationLanguage = .de + @State private var license: MarketplaceLicense = .personalUse + @State private var priceCredits: Int = 0 + @State private var category: DeckCategory? + + // Version-Metadaten + @State private var semver: String = "1.0.0" + @State private var changelog: String = "" + + // Submit-State + @State private var isSubmitting = false + @State private var errorMessage: String? + @State private var result: MarketplacePublishResponse? + @State private var skippedCardCount: Int = 0 + + var body: some View { + Form { + if !ownedDecks.isEmpty { + publishModeSection + } + if isFirstPublish, hasAuthor == false { + authorSection + } + if isFirstPublish { + deckMetadataSection + licenseSection + categorySection + } else if let existing = currentExistingDeck { + existingDeckInfoSection(deck: existing) + } + versionSection + if skippedCardCount > 0 { + skippedNoteSection + } + if let errorMessage { + Section { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(WordeckTheme.error) + } + } + } + .disabled(isSubmitting) + .navigationTitle("Im Marketplace veröffentlichen") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Veröffentlichen") { Task { await submit() } } + .disabled(!canSubmit || isSubmitting) + } + } + .overlay { + if isSubmitting { + publishProgressOverlay + } + } + .alert(item: $result) { response in + Alert( + title: Text("Veröffentlicht: \(response.deck.title)"), + message: Text(alertMessage(for: response)), + dismissButton: .default(Text("OK")) { + onPublished(response) + dismiss() + } + ) + } + .task { + await prefill() + } + } + + private var isFirstPublish: Bool { + if case .firstPublish = publishMode { return true } + return false + } + + private var currentExistingDeck: OwnedMarketplaceDeck? { + guard let slug = selectedExistingSlug else { return nil } + return ownedDecks.first { $0.slug == slug } + } + + private var publishModeSection: some View { + Section { + Picker("Modus", selection: $publishMode) { + Text("Neues Marketplace-Deck").tag(PublishMode.firstPublish) + ForEach(ownedDecks) { deck in + Text("Neue Version: \(deck.title)") + .tag(PublishMode.newVersion(slug: deck.slug)) + } + } + .pickerStyle(.menu) + .onChange(of: publishMode) { _, newMode in + applyPublishMode(newMode) + } + } header: { + Text("Veröffentlichungs-Modus") + } footer: { + Text("Du hast schon Decks im Marketplace. Wähle eine, um eine neue Version zu publishen.") + } + } + + private func existingDeckInfoSection(deck: OwnedMarketplaceDeck) -> some View { + Section { + LabeledContent("Slug", value: deck.slug) + LabeledContent("Titel", value: deck.title) + if let latest = deck.latestVersion { + LabeledContent("Aktuelle Version", value: "v\(latest.semver) · \(latest.cardCount) Karten") + } else { + LabeledContent("Aktuelle Version", value: "—") + } + } header: { + Text("Bestehendes Deck") + } footer: { + Text("Metadaten ändern: Marketplace-Webansicht → Deck → Bearbeiten.") + } + } + + private var authorSection: some View { + Section { + TextField("Author-Slug (URL)", text: $authorSlug) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + TextField("Anzeigename", text: $authorDisplayName) + TextField("Bio (optional)", text: $authorBio, axis: .vertical) + .lineLimit(2 ... 4) + Toggle("Pseudonym-Modus", isOn: $authorPseudonym) + } header: { + Text("Author-Profil anlegen") + } footer: { + Text("Pflicht-Schritt vor dem ersten Marketplace-Deck. Slug erscheint in Marketplace-URLs.") + } + } + + private var deckMetadataSection: some View { + Section { + TextField("Slug (URL)", text: $slug) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + TextField("Titel", text: $title) + .textInputAutocapitalization(.sentences) + TextField("Beschreibung", text: $deckDescription, axis: .vertical) + .lineLimit(2 ... 6) + Picker("Sprache", selection: $language) { + ForEach(GenerationLanguage.allCases, id: \.self) { lang in + Text(lang.label).tag(lang) + } + } + .pickerStyle(.segmented) + } header: { + Text("Deck-Metadaten") + } footer: { + Text("Der Slug wird Teil der Marketplace-URL: wordeck.com/d/.") + } + } + + private var licenseSection: some View { + Section("Lizenz") { + Picker("Lizenz", selection: $license) { + ForEach(MarketplaceLicense.allCases, id: \.self) { lic in + Text(lic.label).tag(lic) + } + } + if license == .proOnly { + Stepper(value: $priceCredits, in: 0 ... 100_000, step: 10) { + Text("Preis: \(priceCredits) Credits") + } + } + } + } + + private var categorySection: some View { + Section("Kategorie") { + Picker("Kategorie", selection: $category) { + Text("Keine").tag(DeckCategory?.none) + ForEach(DeckCategory.allCases, id: \.self) { cat in + Text(cat.label).tag(DeckCategory?.some(cat)) + } + } + } + } + + private var versionSection: some View { + Section { + TextField("SemVer (z.B. 1.0.0)", text: $semver) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.numbersAndPunctuation) + TextField("Changelog (optional)", text: $changelog, axis: .vertical) + .lineLimit(2 ... 4) + } header: { + Text("Version") + } footer: { + Text("Erst-Publish: 1.0.0. Spätere Versionen müssen semver-größer sein.") + } + } + + private var skippedNoteSection: some View { + Section { + Label( + """ + \(skippedCardCount) Karten werden übersprungen — Bild-\ + Verdeckung und Audio brauchen Marketplace-Media-Upload. + """, + systemImage: "info.circle" + ) + .font(.caption) + .foregroundStyle(WordeckTheme.mutedForeground) + } + } + + private var publishProgressOverlay: some View { + ZStack { + Color.black.opacity(0.55).ignoresSafeArea() + VStack(spacing: 12) { + ProgressView().controlSize(.large).tint(WordeckTheme.primary) + Text("Wird veröffentlicht …") + .font(.headline) + .foregroundStyle(WordeckTheme.foreground) + Text("AI-Moderation läuft — kann ein paar Sekunden dauern.") + .font(.caption) + .foregroundStyle(WordeckTheme.mutedForeground) + .multilineTextAlignment(.center) + } + .padding(24) + .frame(maxWidth: 320) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + } + + private var canSubmit: Bool { + let semverOK = semver.range(of: "^\\d+\\.\\d+\\.\\d+$", options: .regularExpression) != nil + guard semverOK else { return false } + switch publishMode { + case .firstPublish: + if hasAuthor == false { + guard authorDisplayName.trimmed.count >= 1 else { return false } + guard authorSlug.trimmed.count >= 3 else { return false } + } + return slug.trimmed.count >= 3 && !title.trimmed.isEmpty + case .newVersion: + return selectedExistingSlug != nil + } + } + + private func prefill() async { + title = privateDeck.name + deckDescription = privateDeck.deckDescription ?? "" + category = privateDeck.category + slug = slugify(privateDeck.name) + let api = WordeckAPI(auth: auth) + async let authorState = api.myAuthor() + async let ownedState = api.myMarketplaceDecks() + do { + hasAuthor = try await authorState + } catch { + hasAuthor = false + errorMessage = "Author-Profil konnte nicht geladen werden: \(error.localizedDescription)" + } + ownedDecks = await (try? ownedState) ?? [] + } + + /// State-Übergang beim Wechsel des Publish-Modus. + /// - Erst-Publish: Slug aus dem privaten Deck-Namen, Semver 1.0.0. + /// - Neue Version: Slug-Feld unbenutzt (Server kennt Slug), + /// Semver-Default = Bump der aktuellen Version. + private func applyPublishMode(_ mode: PublishMode) { + switch mode { + case .firstPublish: + selectedExistingSlug = nil + semver = "1.0.0" + case let .newVersion(existingSlug): + selectedExistingSlug = existingSlug + if let latest = ownedDecks.first(where: { $0.slug == existingSlug })?.latestVersion { + semver = bumpMinor(latest.semver) + } else { + semver = "1.0.0" + } + } + } + + /// `1.4.2` → `1.5.0`. Bei unparsbarem Input bleibt 1.0.0 als Default. + private func bumpMinor(_ version: String) -> String { + let parts = version.split(separator: ".") + guard parts.count == 3, + let major = Int(parts[0]), + let minor = Int(parts[1]) + else { return "1.0.0" } + return "\(major).\(minor + 1).0" + } + + private func submit() async { + isSubmitting = true + errorMessage = nil + defer { isSubmitting = false } + let api = WordeckAPI(auth: auth) + do { + let targetSlug = try await prepareTargetSlug(api: api) + try await publishCards(toSlug: targetSlug, api: api) + } catch let error as AuthError { + errorMessage = mapPublishError(error) + } catch { + errorMessage = error.localizedDescription + } + } + + /// Erst-Publish-Pfad: Author-Profil + Marketplace-Deck-Init. + /// Liefert den Slug auf den `publishCards` veröffentlicht. + private func prepareTargetSlug(api: WordeckAPI) async throws -> String { + switch publishMode { + case .firstPublish: + if hasAuthor == false { + try await api.upsertAuthor(AuthorUpsertBody( + slug: authorSlug.trimmed, + displayName: authorDisplayName.trimmed, + bio: authorBio.trimmed.isEmpty ? nil : authorBio.trimmed, + avatarUrl: nil, + pseudonym: authorPseudonym + )) + hasAuthor = true + } + _ = try await api.initMarketplaceDeck(MarketplaceDeckInitBody( + slug: slug.trimmed, + title: title.trimmed, + description: deckDescription.trimmed.isEmpty ? nil : deckDescription.trimmed, + language: language.rawValue, + license: license.rawValue, + priceCredits: license == .proOnly ? priceCredits : 0, + category: category + )) + return slug.trimmed + case let .newVersion(existingSlug): + return existingSlug + } + } + + /// Lädt alle Karten des privaten Decks, konvertiert in Marketplace- + /// Format und veröffentlicht die neue Version. + private func publishCards(toSlug targetSlug: String, api: WordeckAPI) async throws { + let cards = try await api.listCards(deckId: privateDeck.id) + let converted = cards.compactMap(MarketplaceCardConverter.convert) + skippedCardCount = cards.count - converted.count + guard !converted.isEmpty else { + errorMessage = "Keine Karten kompatibel mit dem Marketplace-Format." + return + } + result = try await api.publishMarketplaceVersion( + slug: targetSlug, + body: MarketplacePublishBody( + semver: semver.trimmed, + changelog: changelog.trimmed.isEmpty ? nil : changelog.trimmed, + cards: converted + ) + ) + } + + private func mapPublishError(_ error: AuthError) -> String { + if case let .serverError(status, _, message) = error { + switch status { + case 409: + if let message, message.contains("slug_taken") { + return "Dieser Slug ist schon vergeben. Bitte einen anderen wählen." + } + return message ?? "Konflikt — Version-Bump nötig?" + case 403: + if let message, message.contains("moderation_block") { + return "AI-Moderation hat den Inhalt blockiert." + } + return message ?? "Aktion nicht erlaubt." + case 422: + return message ?? "Eingabe ungültig." + default: + break + } + } + return error.errorDescription ?? "Veröffentlichen fehlgeschlagen." + } + + private func alertMessage(for response: MarketplacePublishResponse) -> String { + let parts = [ + "Version \(response.version.semver)", + "\(response.version.cardCount) Karten", + skippedCardCount > 0 ? "\(skippedCardCount) übersprungen" : nil, + "Moderation: \(response.moderation.verdict)" + ].compactMap(\.self) + return parts.joined(separator: " · ") + } + + private func slugify(_ input: String) -> String { + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-") + let lowered = input + .folding(options: .diacriticInsensitive, locale: .current) + .lowercased() + var result = "" + for scalar in lowered.unicodeScalars { + if allowed.contains(scalar) { + result.unicodeScalars.append(scalar) + } else { + result.append("-") + } + } + while result.hasPrefix("-") { + result.removeFirst() + } + while result.hasSuffix("-") { + result.removeLast() + } + while result.contains("--") { + result = result.replacingOccurrences(of: "--", with: "-") + } + return String(result.prefix(60)) + } +} + +// swiftlint:enable type_body_length + +private extension String { + var trimmed: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +extension MarketplacePublishResponse: Identifiable { + var id: String { + version.id + } +} diff --git a/Sources/Features/Marketplace/MarketplaceStore.swift b/Sources/Features/Marketplace/MarketplaceStore.swift index fa6f73b..90bec5b 100644 --- a/Sources/Features/Marketplace/MarketplaceStore.swift +++ b/Sources/Features/Marketplace/MarketplaceStore.swift @@ -18,10 +18,10 @@ final class MarketplaceStore { var browseSort: MarketplaceSort = .recent var browseLanguage: String? - private let api: CardsAPI + private let api: WordeckAPI init(auth: AuthClient) { - api = CardsAPI(auth: auth) + api = WordeckAPI(auth: auth) } func loadExplore() async { @@ -33,8 +33,9 @@ final class MarketplaceStore { featured = res.featured trending = res.trending } catch { - errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) - Log.api.error("Explore failed: \(self.errorMessage ?? "", privacy: .public)") + let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + errorMessage = message + Log.api.error("Explore failed: \(message, privacy: .public)") } } diff --git a/Sources/Features/Marketplace/PublicDeckView.swift b/Sources/Features/Marketplace/PublicDeckView.swift index 1cf4b54..9cbcc83 100644 --- a/Sources/Features/Marketplace/PublicDeckView.swift +++ b/Sources/Features/Marketplace/PublicDeckView.swift @@ -1,13 +1,18 @@ +import ManaAuthUI import ManaCore import SwiftData import SwiftUI +// swiftlint:disable type_body_length + /// Detail-View für ein Public-Deck. Subscribe-Button löst Auto-Fork /// serverseitig aus und navigiert anschließend zur eigenen Deck-Detail. +/// Toolbar-Menu („…") hostet Report + Block-Author (App-Review-Pflicht). struct PublicDeckView: View { let slug: String @Environment(AuthClient.self) private var auth + @Environment(ManaAuthGate.self) private var authGate @Environment(\.modelContext) private var context @State private var detail: PublicDeckDetail? @State private var isLoading = false @@ -15,37 +20,102 @@ struct PublicDeckView: View { @State private var errorMessage: String? @State private var subscribed: SubscribeResponse? + // Moderation-State + @State private var showReportSheet = false + @State private var showBlockConfirm = false + @State private var moderationToast: String? + var body: some View { ZStack { - CardsTheme.background.ignoresSafeArea() + WordeckTheme.background.ignoresSafeArea() content } .navigationTitle(detail?.deck.title ?? "Deck") #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif + .toolbar { + if detail != nil { + ToolbarItem(placement: .topBarTrailing) { + moderationMenu + } + } + } .task(id: slug) { await load() } + .sheet(isPresented: $showReportSheet) { + NavigationStack { + ReportDeckSheet(slug: slug) { message in + moderationToast = message + } + } + } + .confirmationDialog( + "Author blockieren?", + isPresented: $showBlockConfirm, + titleVisibility: .visible, + presenting: detail?.owner + ) { owner in + Button("\(owner.displayName) blockieren", role: .destructive) { + Task { await blockAuthor(slug: owner.slug, name: owner.displayName) } + } + Button("Abbrechen", role: .cancel) {} + } message: { _ in + Text("Decks dieses Authors erscheinen für dich nicht mehr im Marketplace.") + } + .overlay(alignment: .top) { + if let toast = moderationToast { + ToastBanner(text: toast) + .padding(.top, 8) + .task { + try? await Task.sleep(for: .seconds(3)) + moderationToast = nil + } + } + } + } + + private var moderationMenu: some View { + Menu { + Button { + authGate.require(reason: "marketplace-report") { + showReportSheet = true + } + } label: { + Label("Deck melden …", systemImage: "flag") + } + if let owner = detail?.owner { + Button(role: .destructive) { + authGate.require(reason: "marketplace-block") { + showBlockConfirm = true + } + } label: { + Label("\(owner.displayName) blockieren", systemImage: "hand.raised") + } + } + } label: { + Image(systemName: "ellipsis.circle") + } } @ViewBuilder private var content: some View { if isLoading, detail == nil { ProgressView() - .tint(CardsTheme.primary) + .tint(WordeckTheme.primary) } else if let detail { ScrollView { VStack(alignment: .leading, spacing: 16) { header(detail: detail) - Divider().background(CardsTheme.border) + Divider().background(WordeckTheme.border) metadata(detail: detail) - Divider().background(CardsTheme.border) + Divider().background(WordeckTheme.border) subscribeSection(detail: detail) if let errorMessage { Text(errorMessage) .font(.caption) - .foregroundStyle(CardsTheme.error) + .foregroundStyle(WordeckTheme.error) .padding(.horizontal, 16) } } @@ -57,7 +127,7 @@ struct PublicDeckView: View { systemImage: "questionmark.folder", description: Text(errorMessage) ) - .foregroundStyle(CardsTheme.foreground) + .foregroundStyle(WordeckTheme.foreground) } } @@ -66,15 +136,15 @@ struct PublicDeckView: View { HStack { Text(detail.deck.title) .font(.title.bold()) - .foregroundStyle(CardsTheme.foreground) + .foregroundStyle(WordeckTheme.foreground) if detail.deck.isFeatured { Image(systemName: "star.fill") - .foregroundStyle(CardsTheme.warning) + .foregroundStyle(WordeckTheme.warning) } } if let description = detail.deck.description, !description.isEmpty { Text(description) - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) } } .padding(.horizontal, 16) @@ -85,13 +155,13 @@ struct PublicDeckView: View { if let owner = detail.owner { HStack(spacing: 6) { Image(systemName: "person.crop.circle") - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) Text(owner.displayName) - .foregroundStyle(CardsTheme.foreground) + .foregroundStyle(WordeckTheme.foreground) if owner.verifiedMana { Image(systemName: "checkmark.seal.fill") .font(.caption) - .foregroundStyle(CardsTheme.primary) + .foregroundStyle(WordeckTheme.primary) } } .font(.subheadline) @@ -107,63 +177,64 @@ struct PublicDeckView: View { } } .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) if let changelog = detail.latestVersion?.changelog, !changelog.isEmpty { Text("Changelog") .font(.caption.weight(.semibold)) - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) .padding(.top, 8) Text(changelog) .font(.caption) - .foregroundStyle(CardsTheme.foreground) + .foregroundStyle(WordeckTheme.foreground) } } .padding(.horizontal, 16) } - @ViewBuilder private func subscribeSection(detail: PublicDeckDetail) -> some View { VStack(spacing: 12) { if let subscribed { Label("Abonniert — dein Fork ist in deiner Bibliothek", systemImage: "checkmark.circle.fill") - .foregroundStyle(CardsTheme.success) + .foregroundStyle(WordeckTheme.success) .padding() .frame(maxWidth: .infinity) - .background(CardsTheme.success.opacity(0.1), in: RoundedRectangle(cornerRadius: 10)) + .background(WordeckTheme.success.opacity(0.1), in: RoundedRectangle(cornerRadius: 10)) NavigationLink(value: subscribed.privateDeckId) { Label("Zum eigenen Deck", systemImage: "arrow.right.circle") .frame(maxWidth: .infinity) .padding(.vertical, 10) - .background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10)) - .foregroundStyle(CardsTheme.primaryForeground) + .background(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(WordeckTheme.primaryForeground) } .buttonStyle(.plain) } else if detail.deck.isTakedown { Label("Dieses Deck wurde entfernt", systemImage: "exclamationmark.triangle") - .foregroundStyle(CardsTheme.error) + .foregroundStyle(WordeckTheme.error) } else if detail.deck.latestVersionId == nil { Label("Noch keine Version veröffentlicht", systemImage: "clock") - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) } else { Button { - Task { await subscribe(detail: detail) } + authGate.require(reason: "marketplace-subscribe") { + Task { await subscribe(detail: detail) } + } } label: { HStack { if isSubscribing { ProgressView() .controlSize(.small) - .tint(CardsTheme.primaryForeground) + .tint(WordeckTheme.primaryForeground) } Text(detail.deck.priceCredits > 0 - ? "Abonnieren (\(detail.deck.priceCredits) Credits)" - : "Abonnieren") + ? "Abonnieren (\(detail.deck.priceCredits) Credits)" + : "Abonnieren") .fontWeight(.semibold) } .frame(maxWidth: .infinity) .padding(.vertical, 14) - .background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10)) - .foregroundStyle(CardsTheme.primaryForeground) + .background(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(WordeckTheme.primaryForeground) } .buttonStyle(.plain) .disabled(isSubscribing) @@ -175,7 +246,7 @@ struct PublicDeckView: View { private func load() async { isLoading = true defer { isLoading = false } - let api = CardsAPI(auth: auth) + let api = WordeckAPI(auth: auth) do { detail = try await api.publicDeck(slug: slug) } catch { @@ -183,11 +254,21 @@ struct PublicDeckView: View { } } - private func subscribe(detail: PublicDeckDetail) async { + private func blockAuthor(slug: String, name: String) async { + let api = WordeckAPI(auth: auth) + do { + try await api.blockAuthor(slug: slug) + moderationToast = "\(name) blockiert." + } catch { + moderationToast = "Blockieren fehlgeschlagen: \(error.localizedDescription)" + } + } + + private func subscribe(detail _: PublicDeckDetail) async { isSubscribing = true errorMessage = nil defer { isSubscribing = false } - let api = CardsAPI(auth: auth) + let api = WordeckAPI(auth: auth) do { let response = try await api.subscribe(slug: slug) subscribed = response @@ -199,3 +280,5 @@ struct PublicDeckView: View { } } } + +// swiftlint:enable type_body_length diff --git a/Sources/Features/Marketplace/ReportDeckSheet.swift b/Sources/Features/Marketplace/ReportDeckSheet.swift new file mode 100644 index 0000000..026c7be --- /dev/null +++ b/Sources/Features/Marketplace/ReportDeckSheet.swift @@ -0,0 +1,109 @@ +import ManaCore +import SwiftUI + +/// Report-Form für ein Marketplace-Deck — Pflicht-Komponente nach +/// App-Store-Guideline 5.1.1(v) (Report-Mechanismus für UGC). +/// +/// Owned-State (Kategorie, Message, Submit-Status). Bei Erfolg schließt +/// das Sheet und ruft `onCompleted` mit einer Toast-Message auf. +struct ReportDeckSheet: View { + let slug: String + let onCompleted: (String) -> Void + + @Environment(AuthClient.self) private var auth + @Environment(\.dismiss) private var dismiss + + @State private var category: ReportCategory = .spam + @State private var message: String = "" + @State private var isSubmitting = false + @State private var errorMessage: String? + + var body: some View { + Form { + Section("Grund") { + Picker("Grund", selection: $category) { + ForEach(ReportCategory.allCases, id: \.self) { cat in + Text(cat.label).tag(cat) + } + } + .pickerStyle(.inline) + .labelsHidden() + } + + Section { + TextField("Optional: Details", text: $message, axis: .vertical) + .lineLimit(3 ... 6) + .textInputAutocapitalization(.sentences) + } header: { + Text("Beschreibung") + } footer: { + Text("Wir prüfen jede Meldung. Hass und Rechtsverletzungen werden bevorzugt behandelt.") + } + + if let errorMessage { + Section { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(WordeckTheme.error) + } + } + } + .disabled(isSubmitting) + .navigationTitle("Deck melden") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Senden") { Task { await submit() } } + .disabled(isSubmitting) + } + } + } + + private func submit() async { + isSubmitting = true + errorMessage = nil + defer { isSubmitting = false } + let api = WordeckAPI(auth: auth) + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + do { + let response = try await api.reportDeck( + slug: slug, + body: ReportDeckBody( + category: category, + body: trimmed.isEmpty ? nil : trimmed, + versionId: nil, + cardContentHash: nil + ) + ) + let toast = response.alreadyReported + ? "Du hast dieses Deck bereits gemeldet." + : "Meldung gesendet. Danke fürs Aufpassen." + onCompleted(toast) + dismiss() + } catch { + errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } + } +} + +/// Schlichtes Top-Banner für kurze Bestätigungen. +struct ToastBanner: View { + let text: String + + var body: some View { + Text(text) + .font(.subheadline.weight(.medium)) + .foregroundStyle(WordeckTheme.foreground) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.regularMaterial, in: Capsule()) + .overlay(Capsule().stroke(WordeckTheme.border, lineWidth: 0.5)) + .padding(.horizontal, 16) + .transition(.move(edge: .top).combined(with: .opacity)) + } +} diff --git a/Sources/Features/Media/AudioPlayerButton.swift b/Sources/Features/Media/AudioPlayerButton.swift deleted file mode 100644 index 99bc12f..0000000 --- a/Sources/Features/Media/AudioPlayerButton.swift +++ /dev/null @@ -1,73 +0,0 @@ -import AVFoundation -import SwiftUI - -/// Audio-Wiedergabe-Button für `audio-front`-Karten. Lädt das File einmal -/// per MediaCache, spielt mit AVAudioPlayer ab. -struct AudioPlayerButton: View { - let mediaId: String - - @Environment(\.mediaCache) private var mediaCache - @State private var player: AVAudioPlayer? - @State private var isPlaying = false - @State private var failed = false - - var body: some View { - Button { - togglePlayback() - } label: { - HStack(spacing: 12) { - Image(systemName: failed - ? "speaker.slash.fill" - : (isPlaying ? "pause.circle.fill" : "play.circle.fill")) - .font(.system(size: 48)) - .foregroundStyle(failed ? CardsTheme.error : CardsTheme.primary) - Text(failed ? "Audio nicht verfügbar" : (isPlaying ? "Wiedergabe läuft" : "Anhören")) - .font(.headline) - .foregroundStyle(CardsTheme.foreground) - } - .frame(maxWidth: .infinity) - .padding(20) - .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(CardsTheme.border, lineWidth: 1) - ) - } - .buttonStyle(.plain) - .disabled(failed) - .task(id: mediaId) { - await load() - } - .onDisappear { - player?.stop() - isPlaying = false - } - } - - private func load() async { - guard let cache = mediaCache else { failed = true; return } - do { - let data = try await cache.data(for: mediaId) - #if canImport(UIKit) - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) - try AVAudioSession.sharedInstance().setActive(true) - #endif - player = try AVAudioPlayer(data: data) - player?.prepareToPlay() - } catch { - failed = true - } - } - - private func togglePlayback() { - guard let player else { return } - if player.isPlaying { - player.pause() - isPlaying = false - } else { - player.currentTime = 0 - player.play() - isPlaying = true - } - } -} diff --git a/Sources/Features/Media/RemoteImage.swift b/Sources/Features/Media/RemoteImage.swift deleted file mode 100644 index 4db3842..0000000 --- a/Sources/Features/Media/RemoteImage.swift +++ /dev/null @@ -1,70 +0,0 @@ -import SwiftUI - -#if canImport(UIKit) -import UIKit -#elseif canImport(AppKit) -import AppKit -#endif - -/// Lädt ein authentifiziertes Image vom Cardecky-Media-Endpoint und -/// rendert es. Streamt erst beim ersten Mal, danach aus dem -/// MediaCache (LRU 200 MB). -struct RemoteImage: View { - let mediaId: String - let contentMode: ContentMode - - @Environment(\.mediaCache) private var mediaCache - @State private var image: PlatformImage? - @State private var failed = false - - init(mediaId: String, contentMode: ContentMode = .fit) { - self.mediaId = mediaId - self.contentMode = contentMode - } - - var body: some View { - Group { - if let image { - imageView(image) - } else if failed { - ContentUnavailableView("Bild konnte nicht geladen werden", systemImage: "photo.badge.exclamationmark") - .foregroundStyle(CardsTheme.mutedForeground) - } else { - ProgressView() - .tint(CardsTheme.primary) - } - } - .task(id: mediaId) { - await load() - } - } - - @ViewBuilder - private func imageView(_ image: PlatformImage) -> some View { - #if canImport(UIKit) - Image(uiImage: image).resizable().aspectRatio(contentMode: contentMode) - #elseif canImport(AppKit) - Image(nsImage: image).resizable().aspectRatio(contentMode: contentMode) - #endif - } - - private func load() async { - guard let cache = mediaCache else { failed = true; return } - do { - let data = try await cache.data(for: mediaId) - if let img = PlatformImage(data: data) { - image = img - } else { - failed = true - } - } catch { - failed = true - } - } -} - -#if canImport(UIKit) -typealias PlatformImage = UIImage -#elseif canImport(AppKit) -typealias PlatformImage = NSImage -#endif diff --git a/Sources/Features/Settings/BlockedAuthorsView.swift b/Sources/Features/Settings/BlockedAuthorsView.swift new file mode 100644 index 0000000..1a5f7af --- /dev/null +++ b/Sources/Features/Settings/BlockedAuthorsView.swift @@ -0,0 +1,89 @@ +import ManaCore +import SwiftUI + +/// Liste der vom User blockierten Marketplace-Authors. Entblock-Action +/// per Swipe — analog zur iOS-Mail-Inbox. +/// +/// App-Store-Guideline 5.1.1(v) verlangt: Block-Mechanismus für UGC +/// muss verwaltbar sein. Diese View ist der Verwaltungs-Endpunkt. +struct BlockedAuthorsView: View { + @Environment(AuthClient.self) private var auth + + @State private var blocks: [BlockEntry] = [] + @State private var isLoading = false + @State private var errorMessage: String? + + var body: some View { + ZStack { + WordeckTheme.background.ignoresSafeArea() + content + } + .navigationTitle("Blockierte Authors") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .task { await load() } + .refreshable { await load() } + } + + @ViewBuilder + private var content: some View { + if isLoading, blocks.isEmpty { + ProgressView().tint(WordeckTheme.primary) + } else if blocks.isEmpty { + ContentUnavailableView( + "Keine blockierten Authors", + systemImage: "hand.raised.slash", + description: Text("Blockiere Authors über das Menü oben rechts auf Marketplace-Decks.") + ) + .foregroundStyle(WordeckTheme.mutedForeground) + } else { + List { + ForEach(blocks) { block in + VStack(alignment: .leading, spacing: 2) { + Text(block.displayName) + .font(.subheadline.weight(.semibold)) + Text("@\(block.authorSlug)") + .font(.caption) + .foregroundStyle(WordeckTheme.mutedForeground) + } + .swipeActions { + Button("Entblocken") { + Task { await unblock(block) } + } + .tint(WordeckTheme.primary) + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(WordeckTheme.error) + .padding(.horizontal, 16) + } + } + } + + private func load() async { + isLoading = true + defer { isLoading = false } + let api = WordeckAPI(auth: auth) + do { + blocks = try await api.myBlocks() + } catch { + errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } + } + + private func unblock(_ block: BlockEntry) async { + let api = WordeckAPI(auth: auth) + do { + try await api.unblockAuthor(slug: block.authorSlug) + blocks.removeAll { $0.id == block.id } + } catch { + errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } + } +} diff --git a/Sources/Features/Settings/SettingsView.swift b/Sources/Features/Settings/SettingsView.swift index e5df7c0..feed9c5 100644 --- a/Sources/Features/Settings/SettingsView.swift +++ b/Sources/Features/Settings/SettingsView.swift @@ -40,15 +40,25 @@ struct SettingsView: View { } if notifications.authorization == .denied { - Label("Benachrichtigungen sind in den iOS-Einstellungen blockiert.", - systemImage: "exclamationmark.circle") - .font(.caption) - .foregroundStyle(CardsTheme.warning) + Label( + "Benachrichtigungen sind in den iOS-Einstellungen blockiert.", + systemImage: "exclamationmark.circle" + ) + .font(.caption) + .foregroundStyle(WordeckTheme.warning) + } + } + + Section("Marketplace") { + NavigationLink { + BlockedAuthorsView() + } label: { + Label("Blockierte Authors", systemImage: "hand.raised") } } Section("Über") { - LabeledContent("Server", value: "cardecky-api.mana.how") + LabeledContent("Server", value: "api.wordeck.com") LabeledContent("Auth", value: "auth.mana.how") } } diff --git a/Sources/Features/Study/CardRenderer.swift b/Sources/Features/Study/CardRenderer.swift index ebf3908..d67445a 100644 --- a/Sources/Features/Study/CardRenderer.swift +++ b/Sources/Features/Study/CardRenderer.swift @@ -1,10 +1,8 @@ import SwiftUI /// Rendert die Karten-Inhalte je nach `CardType`. Front-/Back-Seite -/// werden über `isFlipped` gesteuert. -/// -/// β-2 deckt `basic`, `basic-reverse`, `cloze` ab. Restliche Typen -/// zeigen einen Placeholder mit Hinweis auf die kommende Phase. +/// werden über `isFlipped` gesteuert. Wordeck ist text-only — alle +/// Card-Types rendern ausschließlich Markdown-Text. struct CardRenderer: View { let card: ReviewCard let subIndex: Int @@ -24,29 +22,26 @@ struct CardRenderer: View { } case .cloze: clozeView - case .imageOcclusion: - imageOcclusionView - case .audioFront: - audioFrontView - case .typing, .multipleChoice: - placeholderView + case .multipleChoice: + MultipleChoiceCardView(card: card, isFlipped: isFlipped) + case .typing: + TypingCardView(card: card, isFlipped: isFlipped) } } .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) } - @ViewBuilder private func basicView(front frontKey: String, back backKey: String) -> some View { VStack(spacing: 16) { text(card.fields[frontKey] ?? "") .font(.title2) - .foregroundStyle(CardsTheme.foreground) + .foregroundStyle(WordeckTheme.foreground) if isFlipped { - Divider().background(CardsTheme.border) + Divider().background(WordeckTheme.border) text(card.fields[backKey] ?? "") .font(.title3) - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) } } } @@ -61,84 +56,7 @@ struct CardRenderer: View { VStack(spacing: 12) { text(rendered) .font(.title3) - .foregroundStyle(CardsTheme.foreground) - } - } - - @ViewBuilder - private var imageOcclusionView: some View { - let imageRef = card.fields["image_ref"] ?? "" - let maskJSON = card.fields["mask_regions"] ?? "[]" - let regions = MaskRegions.parse(maskJSON) - let activeRegion = regions.indices.contains(subIndex) ? regions[subIndex] : nil - - VStack(spacing: 12) { - GeometryReader { geo in - ZStack(alignment: .topLeading) { - RemoteImage(mediaId: imageRef, contentMode: .fit) - .frame(width: geo.size.width, height: geo.size.height) - ForEach(regions) { region in - let isActive = region.id == activeRegion?.id - // Front: aktive Maske opak, andere transparent. - // Back: alle Masken transparent (Bild komplett sichtbar). - if !isFlipped, isActive { - Rectangle() - .fill(CardsTheme.primary.opacity(0.92)) - .frame( - width: region.w * geo.size.width, - height: region.h * geo.size.height - ) - .offset(x: region.x * geo.size.width, y: region.y * geo.size.height) - .overlay( - Text(region.label?.isEmpty == false ? region.label! : "?") - .font(.caption.weight(.bold)) - .foregroundStyle(CardsTheme.primaryForeground) - .offset(x: region.x * geo.size.width, y: region.y * geo.size.height), - alignment: .topLeading - ) - } - } - } - } - .aspectRatio(4 / 3, contentMode: .fit) - - if isFlipped, let label = activeRegion?.label, !label.isEmpty { - Text(label) - .font(.title3.weight(.semibold)) - .foregroundStyle(CardsTheme.primary) - } - if let note = card.fields["note"], !note.isEmpty { - Text(note) - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - } - } - } - - @ViewBuilder - private var audioFrontView: some View { - let audioRef = card.fields["audio_ref"] ?? "" - VStack(spacing: 16) { - AudioPlayerButton(mediaId: audioRef) - if isFlipped { - Divider().background(CardsTheme.border) - text(card.fields["back"] ?? "") - .font(.title3) - .foregroundStyle(CardsTheme.foreground) - } - } - } - - @ViewBuilder - private var placeholderView: some View { - VStack(spacing: 8) { - Image(systemName: "questionmark.square.dashed") - .font(.largeTitle) - .foregroundStyle(CardsTheme.mutedForeground) - Text("Card-Type »\(card.type.rawValue)« kommt in einer späteren Phase") - .font(.caption) - .multilineTextAlignment(.center) - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.foreground) } } diff --git a/Sources/Features/Study/MultipleChoiceCardView.swift b/Sources/Features/Study/MultipleChoiceCardView.swift new file mode 100644 index 0000000..545d7ef --- /dev/null +++ b/Sources/Features/Study/MultipleChoiceCardView.swift @@ -0,0 +1,202 @@ +import ManaCore +import SwiftUI + +/// Multiple-Choice-Karten-View: zeigt 4 shuffled Optionen (1 richtige +/// + 3 Distractors vom Server). User-Tap markiert Wahl, beim Flip +/// werden richtige Antwort + Wahl hervorgehoben. +/// +/// Web-Vorbild: `cards/apps/web/src/lib/components/MultipleChoiceView.svelte`. +struct MultipleChoiceCardView: View { + let card: ReviewCard + let isFlipped: Bool + + @Environment(AuthClient.self) private var auth + @State private var options: [String] = [] + @State private var selected: String? + @State private var phase: LoadPhase = .loading + + enum LoadPhase { + case loading + case ready + case tooFew // < 1 Distractor → manueller Modus + case failed + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + text(card.fields["front"] ?? "") + .font(.title3) + .foregroundStyle(WordeckTheme.foreground) + + switch phase { + case .loading: + ProgressView() + .tint(WordeckTheme.primary) + .frame(maxWidth: .infinity) + .padding(.top, 12) + + case .ready: + ForEach(options, id: \.self) { option in + optionRow(option) + } + + case .tooFew: + if isFlipped { + answerOnlyView + } else { + Text("Nicht genug andere Karten im Deck für Multiple-Choice — tippe auf »Antwort anzeigen«.") + .font(.caption) + .foregroundStyle(WordeckTheme.mutedForeground) + } + + case .failed: + Text("Distractors konnten nicht geladen werden.") + .font(.caption) + .foregroundStyle(WordeckTheme.error) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .task(id: card.id) { + await loadOptions() + } + } + + /// Option-Row mit dynamischem Highlight: vor Flip nur Selected-Hint, + /// nach Flip wird richtige Antwort grün, falsche-aber-gewählte rot. + @ViewBuilder + private func optionRow(_ option: String) -> some View { + let isCorrect = option == card.fields["answer"] + let isSelected = option == selected + + Button { + if selected == nil { selected = option } + } label: { + HStack(alignment: .top, spacing: 12) { + statusIcon(isCorrect: isCorrect, isSelected: isSelected) + .frame(width: 22) + Text(option) + .font(.subheadline) + .foregroundStyle(WordeckTheme.foreground) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) + } + .padding(.vertical, 12) + .padding(.horizontal, 14) + .background( + background(isCorrect: isCorrect, isSelected: isSelected), + in: RoundedRectangle(cornerRadius: 10, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(border(isCorrect: isCorrect, isSelected: isSelected), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(isFlipped || selected != nil) + } + + @ViewBuilder + private func statusIcon(isCorrect: Bool, isSelected: Bool) -> some View { + if isFlipped { + if isCorrect { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(WordeckTheme.success) + } else if isSelected { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(WordeckTheme.error) + } else { + Image(systemName: "circle") + .foregroundStyle(WordeckTheme.mutedForeground.opacity(0.4)) + } + } else if isSelected { + Image(systemName: "largecircle.fill.circle") + .foregroundStyle(WordeckTheme.primary) + } else { + Image(systemName: "circle") + .foregroundStyle(WordeckTheme.mutedForeground.opacity(0.4)) + } + } + + private func background(isCorrect: Bool, isSelected: Bool) -> Color { + if isFlipped { + if isCorrect { return WordeckTheme.success.opacity(0.12) } + if isSelected { return WordeckTheme.error.opacity(0.10) } + return WordeckTheme.surfaceHover + } + return isSelected + ? WordeckTheme.primary.opacity(0.10) + : WordeckTheme.surface + } + + private func border(isCorrect: Bool, isSelected: Bool) -> Color { + if isFlipped { + if isCorrect { return WordeckTheme.success.opacity(0.55) } + if isSelected { return WordeckTheme.error.opacity(0.55) } + return WordeckTheme.border + } + return isSelected ? WordeckTheme.primary.opacity(0.5) : WordeckTheme.border + } + + /// Fallback wenn nicht genug Distractors: zeigt die Antwort + /// direkt nach Flip, ohne Auswahl-Spiel. + private var answerOnlyView: some View { + VStack(alignment: .leading, spacing: 6) { + Divider().background(WordeckTheme.border) + Text(card.fields["answer"] ?? "") + .font(.title3) + .foregroundStyle(WordeckTheme.primary) + .padding(.top, 4) + } + } + + private func text(_ markdown: String) -> some View { + let attributed = (try? AttributedString( + markdown: markdown, + options: AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace + ) + )) ?? AttributedString(markdown) + return Text(attributed) + .multilineTextAlignment(.leading) + } + + private func loadOptions() async { + phase = .loading + selected = nil + let api = WordeckAPI(auth: auth) + let answer = card.fields["answer"] ?? "" + + var distractors: [String] = [] + // Erst answer-Feld versuchen, dann back-Feld als Fallback + // (Decks mit basic/basic-reverse-Karten daneben). + for field in ["answer", "back"] { + if distractors.count >= 3 { break } + if let result = try? await api.distractors( + deckId: card.deckId, + cardId: card.id, + field: field, + count: 3 + ) { + let filtered = result.filter { $0 != answer && !distractors.contains($0) } + distractors.append(contentsOf: filtered) + } + } + + // Fallback aus statischem distractor_pool-Field (Web-Pattern) + if distractors.count < 3, let pool = card.fields["distractor_pool"] { + let poolItems = pool + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty && $0 != answer && !distractors.contains($0) } + distractors.append(contentsOf: poolItems) + } + + if distractors.isEmpty { + phase = .tooFew + return + } + let finalDistractors = Array(distractors.prefix(3)) + options = ([answer] + finalDistractors).shuffled() + phase = .ready + } +} diff --git a/Sources/Features/Study/RatingBar.swift b/Sources/Features/Study/RatingBar.swift index 51d8181..833734f 100644 --- a/Sources/Features/Study/RatingBar.swift +++ b/Sources/Features/Study/RatingBar.swift @@ -1,11 +1,12 @@ import SwiftUI #if canImport(UIKit) -import UIKit + import UIKit #endif -/// Vier Rating-Buttons unten am Bildschirm. Tap → onRate(rating) -/// plus Haptic-Feedback. +/// Vier Rating-Buttons mit emphasis auf "Good" (full-width primary). +/// Web-Vorbild: `cards/apps/web/src/routes/study/[deckId]/+page.svelte` +/// — `.grade.again/.hard/.good/.easy`-Klassen. struct RatingBar: View { let onRate: (Rating) -> Void @@ -16,17 +17,24 @@ struct RatingBar: View { triggerHaptic(for: rating) onRate(rating) } label: { - VStack(spacing: 2) { + HStack(spacing: 6) { Text(rating.label) .font(.subheadline.weight(.semibold)) Text(rating.shortcut) - .font(.caption2) - .foregroundStyle(.secondary) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(kbdBackground(for: rating), in: RoundedRectangle(cornerRadius: 4)) + .foregroundStyle(kbdForeground(for: rating)) } .frame(maxWidth: .infinity) .padding(.vertical, 14) - .background(background(for: rating), in: RoundedRectangle(cornerRadius: 10)) + .background(background(for: rating), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) .foregroundStyle(foreground(for: rating)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(borderColor(for: rating), lineWidth: rating == .good ? 0 : 1) + ) } .buttonStyle(.plain) } @@ -34,30 +42,52 @@ struct RatingBar: View { .padding(.horizontal, 16) } + /// `good` ist die Hero-Action (primary full background) — analog + /// zum Web-Default-Klick. Andere bekommen subtle tinted borders. private func background(for rating: Rating) -> Color { switch rating { - case .again: CardsTheme.error.opacity(0.12) - case .hard: CardsTheme.warning.opacity(0.12) - case .good: CardsTheme.primary.opacity(0.12) - case .easy: CardsTheme.success.opacity(0.12) + case .again: WordeckTheme.error.opacity(0.06) + case .hard: WordeckTheme.warning.opacity(0.06) + case .good: WordeckTheme.primary + case .easy: WordeckTheme.success.opacity(0.06) } } private func foreground(for rating: Rating) -> Color { switch rating { - case .again: CardsTheme.error - case .hard: CardsTheme.warning - case .good: CardsTheme.primary - case .easy: CardsTheme.success + case .again: WordeckTheme.error + case .hard: WordeckTheme.warning + case .good: WordeckTheme.primaryForeground + case .easy: WordeckTheme.success } } + private func borderColor(for rating: Rating) -> Color { + switch rating { + case .again: WordeckTheme.error.opacity(0.4) + case .hard: WordeckTheme.warning.opacity(0.4) + case .good: .clear + case .easy: WordeckTheme.success.opacity(0.4) + } + } + + private func kbdBackground(for rating: Rating) -> Color { + rating == .good + ? WordeckTheme.primaryForeground.opacity(0.18) + : WordeckTheme.muted + } + + private func kbdForeground(for rating: Rating) -> Color { + rating == .good + ? WordeckTheme.primaryForeground.opacity(0.85) + : WordeckTheme.mutedForeground + } + private func triggerHaptic(for rating: Rating) { #if canImport(UIKit) - let generator = UIImpactFeedbackGenerator( - style: rating == .easy ? .heavy : .medium - ) - generator.impactOccurred() + let style: UIImpactFeedbackGenerator.FeedbackStyle = + rating == .easy ? .heavy : .medium + UIImpactFeedbackGenerator(style: style).impactOccurred() #endif } } diff --git a/Sources/Features/Study/StudySession.swift b/Sources/Features/Study/StudySession.swift index ae2e887..8ab8338 100644 --- a/Sources/Features/Study/StudySession.swift +++ b/Sources/Features/Study/StudySession.swift @@ -5,10 +5,15 @@ import SwiftData /// State-Machine für eine Lern-Session. Lädt Due-Reviews beim Start, /// rendert eine Karte nach der anderen, schickt Grades via GradeQueue ab. +/// +/// Seit ζ-1 (2026-05-18): wenn der Server-Call scheitert, fällt die +/// Session auf den `CachedDueReview`-Snapshot vom letzten Sync zurück. +/// Der User lernt dann offline. Grades laufen wie immer in die +/// `GradeQueue` und drainen beim Reconnect. @MainActor @Observable final class StudySession { - enum Phase: Sendable { + enum Phase { case loading case studying case finished @@ -20,17 +25,22 @@ final class StudySession { private(set) var currentIndex: Int = 0 private(set) var isFlipped: Bool = false private(set) var totalGraded: Int = 0 + /// `true` wenn die Session aus dem lokalen Snapshot statt vom Server + /// gestartet wurde. View kann ein Offline-Banner zeigen. + private(set) var isOfflineSession: Bool = false let deckId: String let deckName: String - private let api: CardsAPI + private let api: WordeckAPI + private let context: ModelContext private let gradeQueue: GradeQueue init(deckId: String, deckName: String, auth: AuthClient, context: ModelContext) { self.deckId = deckId self.deckName = deckName - api = CardsAPI(auth: auth) + self.context = context + api = WordeckAPI(auth: auth) gradeQueue = GradeQueue(api: api, context: context) } @@ -50,19 +60,47 @@ final class StudySession { currentIndex = 0 isFlipped = false totalGraded = 0 + isOfflineSession = false if queue.isEmpty { phase = .finished } else { phase = .studying } - Log.study.info("Session start — \(self.queue.count, privacy: .public) due in deck \(self.deckId, privacy: .public)") + let count = queue.count + let id = deckId + Log.study.info("Session start — \(count, privacy: .public) due in deck \(id, privacy: .public)") } catch { - let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) - phase = .failed(msg) - Log.study.error("Session start failed: \(msg, privacy: .public)") + // Server nicht erreichbar oder Auth-Fehler → Cache-Fallback. + queue = loadFromCache() + currentIndex = 0 + isFlipped = false + totalGraded = 0 + if queue.isEmpty { + let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + phase = .failed(msg) + Log.study.error("Session start failed (no cache): \(msg, privacy: .public)") + } else { + isOfflineSession = true + phase = .studying + let count = queue.count + let id = deckId + Log.study + .notice("Offline-Session — \(count, privacy: .public) cached due in deck \(id, privacy: .public)") + } } } + private func loadFromCache() -> [DueReview] { + let deckId = deckId + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.deckId == deckId }, + sortBy: [SortDescriptor(\.due, order: .forward)] + ) + descriptor.fetchLimit = 500 + let cached = (try? context.fetch(descriptor)) ?? [] + return cached.compactMap { $0.toDueReview() } + } + func flip() { guard case .studying = phase else { return } isFlipped.toggle() @@ -86,7 +124,8 @@ final class StudySession { isFlipped = false if currentIndex >= queue.count { phase = .finished - Log.study.info("Session finished — graded \(self.totalGraded, privacy: .public)") + let count = totalGraded + Log.study.info("Session finished — graded \(count, privacy: .public)") } } } diff --git a/Sources/Features/Study/StudySessionView.swift b/Sources/Features/Study/StudySessionView.swift index 43d801f..6f1b332 100644 --- a/Sources/Features/Study/StudySessionView.swift +++ b/Sources/Features/Study/StudySessionView.swift @@ -3,7 +3,7 @@ import SwiftData import SwiftUI #if canImport(UIKit) -import UIKit + import UIKit #endif /// Vollbild-Study-View. Wird per Navigation aus DeckListView geöffnet. @@ -18,7 +18,7 @@ struct StudySessionView: View { var body: some View { ZStack { - CardsTheme.background.ignoresSafeArea() + WordeckTheme.background.ignoresSafeArea() content } .navigationTitle(deckName) @@ -30,7 +30,7 @@ struct StudySessionView: View { if let session, case .studying = session.phase { Text("\(session.remaining)") .font(.subheadline.weight(.semibold)) - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) .accessibilityLabel("\(session.remaining) Karten übrig") } } @@ -50,7 +50,7 @@ struct StudySessionView: View { switch session.phase { case .loading: ProgressView("Karten werden geladen …") - .tint(CardsTheme.primary) + .tint(WordeckTheme.primary) case .studying: studyingView(session: session) case .finished: @@ -60,12 +60,15 @@ struct StudySessionView: View { } } else { ProgressView() - .tint(CardsTheme.primary) + .tint(WordeckTheme.primary) } } private func studyingView(session: StudySession) -> some View { VStack(spacing: 16) { + if session.isOfflineSession { + offlineBanner + } if let due = session.current { cardSurface(due: due, isFlipped: session.isFlipped) .onTapGesture { @@ -73,26 +76,7 @@ struct StudySessionView: View { session.flip() } keyboardShortcuts(session: session) - if session.isFlipped { - RatingBar { rating in - Task { await session.grade(rating) } - } - .transition(.move(edge: .bottom).combined(with: .opacity)) - } else { - Button { - flipHaptic() - session.flip() - } label: { - Text("Antwort anzeigen") - .font(.subheadline.weight(.semibold)) - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10)) - .foregroundStyle(CardsTheme.primaryForeground) - } - .buttonStyle(.plain) - .padding(.horizontal, 16) - } + bottomBar(session: session) } } .padding(.bottom, 20) @@ -100,36 +84,87 @@ struct StudySessionView: View { .animation(.easeInOut(duration: 0.2), value: session.currentIndex) } + /// Banner für Offline-Sessions. Erklärt dem User ehrlich, dass er + /// gerade die Karten lernt, die zum letzten Sync fällig waren — + /// neue Karten kommen erst nach Wiederverbindung. + private var offlineBanner: some View { + HStack(spacing: 8) { + Image(systemName: "wifi.slash") + Text("Offline — Karten vom letzten Sync") + } + .font(.caption.weight(.medium)) + .foregroundStyle(WordeckTheme.mutedForeground) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(WordeckTheme.muted, in: Capsule()) + .padding(.horizontal, 16) + .padding(.top, 4) + .transition(.opacity) + } + + /// Fixe Höhe, damit der Wechsel zwischen "Antwort anzeigen" und + /// `RatingBar` die Card oben nicht stauchen kann — sonst proportioniert + /// `.aspectRatio(.fit)` die Card neu und das Layout springt. + private func bottomBar(session: StudySession) -> some View { + ZStack { + if session.isFlipped { + RatingBar { rating in + Task { await session.grade(rating) } + } + .transition(.opacity) + } else { + Button { + flipHaptic() + session.flip() + } label: { + Text("Antwort anzeigen") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(WordeckTheme.primaryForeground) + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + .transition(.opacity) + } + } + .frame(height: 52) + } + private func cardSurface(due: DueReview, isFlipped: Bool) -> some View { - RoundedRectangle(cornerRadius: 16) - .fill(CardsTheme.surface) - .overlay( - CardRenderer( - card: due.card, - subIndex: due.review.subIndex, - isFlipped: isFlipped - ) + CardSurface(size: .hero, elevation: .raised) { + CardRenderer( + card: due.card, + subIndex: due.review.subIndex, + isFlipped: isFlipped ) - .overlay( - RoundedRectangle(cornerRadius: 16) - .stroke(CardsTheme.border, lineWidth: 1) - ) - .padding(.horizontal, 16) - .padding(.top, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .padding(.horizontal, 16) + .padding(.top, 12) } private func finishedView(session: StudySession) -> some View { VStack(spacing: 16) { Image(systemName: "checkmark.seal.fill") .font(.system(size: 64)) - .foregroundStyle(CardsTheme.success) + .foregroundStyle(WordeckTheme.success) Text(session.totalGraded == 0 ? "Keine Karten fällig" : "Fertig!") .font(.title.bold()) - .foregroundStyle(CardsTheme.foreground) + .foregroundStyle(WordeckTheme.foreground) if session.totalGraded > 0 { Text("\(session.totalGraded) Karten gelernt") .font(.subheadline) - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) + } + if session.isOfflineSession { + Text("Weitere Karten erst nach Verbindung verfügbar.") + .font(.caption) + .multilineTextAlignment(.center) + .foregroundStyle(WordeckTheme.mutedForeground) + .padding(.horizontal, 32) + .padding(.top, 4) } Button("Zurück") { dismiss() } .padding(.top, 24) @@ -141,14 +176,14 @@ struct StudySessionView: View { VStack(spacing: 16) { Image(systemName: "wifi.exclamationmark") .font(.system(size: 48)) - .foregroundStyle(CardsTheme.error) + .foregroundStyle(WordeckTheme.error) Text("Karten konnten nicht geladen werden") .font(.headline) - .foregroundStyle(CardsTheme.foreground) + .foregroundStyle(WordeckTheme.foreground) Text(message) .font(.caption) .multilineTextAlignment(.center) - .foregroundStyle(CardsTheme.mutedForeground) + .foregroundStyle(WordeckTheme.mutedForeground) .padding(.horizontal, 32) Button("Erneut versuchen") { Task { await session.start() } @@ -160,7 +195,6 @@ struct StudySessionView: View { /// Unsichtbare Buttons mit Keyboard-Shortcuts. Funktionieren auf /// iPad (Magic Keyboard) und macOS. Space = flip, 1-4 = Rating. - @ViewBuilder private func keyboardShortcuts(session: StudySession) -> some View { Group { Button("Flip") { @@ -185,7 +219,7 @@ struct StudySessionView: View { private func flipHaptic() { #if canImport(UIKit) - UIImpactFeedbackGenerator(style: .soft).impactOccurred() + UIImpactFeedbackGenerator(style: .soft).impactOccurred() #endif } } diff --git a/Sources/Features/Study/TypingCardView.swift b/Sources/Features/Study/TypingCardView.swift new file mode 100644 index 0000000..5cdcaa1 --- /dev/null +++ b/Sources/Features/Study/TypingCardView.swift @@ -0,0 +1,192 @@ +import SwiftUI + +/// Typing-Karten-View: User tippt Antwort, drückt Submit → Match-Badge +/// (correct/close/wrong) + User-Eingabe + erwartete Antwort. +/// +/// Web-Vorbild: `cards/apps/web/src/lib/components/TypingView.svelte`. +/// Match-Logik in `Typing.check(input:answer:aliases:)` portiert aus +/// `cards/packages/cards-domain/src/typing.ts`. +/// +/// Beim Flip vom Parent setzt sich `submitted` synthetisch — falls +/// User nicht selbst tippt und nur "Antwort anzeigen" benutzt, kommen +/// trotzdem korrekte Antwort und manuelle Bewertung über die RatingBar. +struct TypingCardView: View { + let card: ReviewCard + let isFlipped: Bool + + @State private var input: String = "" + @State private var submitted: Bool = false + @State private var result: TypingMatch? + @FocusState private var inputFocused: Bool + + private var answer: String { + card.fields["answer"] ?? "" + } + + private var aliases: String? { + card.fields["aliases"] + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + text(card.fields["front"] ?? "") + .font(.title3) + .foregroundStyle(WordeckTheme.foreground) + + if submitted || isFlipped { + resultView + } else { + inputRow + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .onChange(of: isFlipped) { _, flipped in + // Falls der User über die generische RatingBar auf "Antwort + // anzeigen" tippt ohne zu raten, springen wir trotzdem in + // den Result-Modus mit "wrong" als Default-Match (nicht + // bewertet). + if flipped, !submitted { + result = nil + submitted = true + } + } + .onChange(of: card.id) { _, _ in + input = "" + submitted = false + result = nil + } + } + + // MARK: - Input + + private var inputRow: some View { + HStack(spacing: 8) { + TextField("Antwort eingeben …", text: $input) + .textFieldStyle(.plain) + .focused($inputFocused) + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(inputFocused ? WordeckTheme.primary : WordeckTheme.border, lineWidth: 1) + ) + .autocorrectionDisabled() + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + .onSubmit { submit() } + + Button { + submit() + } label: { + Image(systemName: "return") + .font(.title3) + .frame(width: 44, height: 44) + .background(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .foregroundStyle(WordeckTheme.primaryForeground) + } + .buttonStyle(.plain) + .disabled(input.trimmingCharacters(in: .whitespaces).isEmpty) + } + .onAppear { + // SwiftUI's Focus-State braucht einen Tick nach onAppear + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + inputFocused = true + } + } + } + + // MARK: - Result + + @ViewBuilder + private var resultView: some View { + if let result { + HStack(spacing: 8) { + Text(badgeLabel(for: result)) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(badgeBackground(for: result), in: Capsule()) + .foregroundStyle(badgeForeground(for: result)) + if !input.isEmpty { + Text("„\(input)“") + .font(.caption) + .foregroundStyle(WordeckTheme.mutedForeground) + .lineLimit(1) + } + Spacer(minLength: 0) + } + } + + Divider().background(WordeckTheme.border) + + Text(answer) + .font(.title3.weight(.medium)) + .foregroundStyle(WordeckTheme.foreground) + + if result == nil, !submitted { + // unwahrscheinlich erreicht, aber als Sicherheits-Branch + EmptyView() + } + } + + // MARK: - Logic + + private func submit() { + guard !submitted else { return } + let trimmed = input.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + inputFocused = false + result = Typing.check(input: trimmed, answer: answer, aliases: aliases) + submitted = true + triggerHaptic() + } + + private func triggerHaptic() { + #if canImport(UIKit) + let style: UIImpactFeedbackGenerator.FeedbackStyle = + result == .correct ? .heavy : .light + UIImpactFeedbackGenerator(style: style).impactOccurred() + #endif + } + + private func badgeLabel(for result: TypingMatch) -> String { + switch result { + case .correct: "✓ Richtig" + case .close: "≈ Fast" + case .wrong: "✗ Falsch" + } + } + + private func badgeBackground(for result: TypingMatch) -> Color { + switch result { + case .correct: WordeckTheme.success.opacity(0.18) + case .close: WordeckTheme.warning.opacity(0.18) + case .wrong: WordeckTheme.error.opacity(0.18) + } + } + + private func badgeForeground(for result: TypingMatch) -> Color { + switch result { + case .correct: WordeckTheme.success + case .close: WordeckTheme.warning + case .wrong: WordeckTheme.error + } + } + + private func text(_ markdown: String) -> some View { + let attributed = (try? AttributedString( + markdown: markdown, + options: AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace + ) + )) ?? AttributedString(markdown) + return Text(attributed) + .multilineTextAlignment(.leading) + } +} + +#if canImport(UIKit) + import UIKit +#endif diff --git a/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 79657a3..59e99f9 100644 --- a/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -29,12 +29,6 @@ "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" - }, - { - "filename" : "AppIcon-1024.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" } ], "info" : { diff --git a/Sources/Resources/Localizable.xcstrings b/Sources/Resources/Localizable.xcstrings index 9b38231..ce84952 100644 --- a/Sources/Resources/Localizable.xcstrings +++ b/Sources/Resources/Localizable.xcstrings @@ -1,5 +1,261 @@ { "sourceLanguage" : "de", - "strings" : { }, + "strings" : { + "… und %@ weitere" : { + + }, + "„%@“" : { + + }, + "@%@" : { + + }, + "%@" : { + + }, + "%@ fällig" : { + + }, + "%@ fällige Karten aus abonnierten Decks" : { + + }, + "%@ Karten" : { + + }, + "%@ Karten gelernt" : { + + }, + "3–500 Zeichen. Je präziser, desto besser die Karten." : { + + }, + "Abmelden" : { + + }, + "Account löschen…" : { + + }, + "AI-Moderation läuft — kann ein paar Sekunden dauern." : { + + }, + "Alle Karten und Reviews dieses Decks werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden." : { + + }, + "Anmelden / Konto erstellen" : { + + }, + "Antwort anzeigen" : { + + }, + "Archivierte Decks erscheinen nicht in der Hauptliste. Bestehende FSRS-Reviews bleiben erhalten." : { + + }, + "Aus Teilen-Menü" : { + + }, + "Author-Profil anlegen" : { + + }, + "Beide Richtungen werden gelernt — front→back und back→front." : { + + }, + "Beschreibung" : { + + }, + "Bestehendes Deck" : { + + }, + "Bild" : { + + }, + "Blockiere Authors über das Menü oben rechts auf Marketplace-Decks." : { + + }, + "Browse den Marketplace im Entdecken-Tab — kein Konto nötig. Für eigene Decks und Cloud-Sync logge dich ein." : { + + }, + "Card-Type »%@« kommt in einer späteren Phase" : { + + }, + "Wordeck" : { + + }, + "Changelog" : { + + }, + "CSV" : { + + }, + "CSV-Datei einlesen. Format: vorne,hinten[,typ] pro Zeile." : { + + }, + "Das kann eine Weile dauern." : { + + }, + "Datei" : { + + }, + "Deck-Metadaten" : { + + }, + "Decks dieses Authors erscheinen für dich nicht mehr im Marketplace." : { + + }, + "Der Slug wird Teil der Marketplace-URL: wordeck.com/d/." : { + + }, + "Distractor-Optionen werden zur Lernzeit automatisch aus anderen Karten desselben Decks gezogen." : { + + }, + "Distractors konnten nicht geladen werden." : { + + }, + "Druck-Ansicht / PDF" : { + + }, + "Druck-Ansicht ist nur auf iOS verfügbar." : { + + }, + "Du hast schon Decks im Marketplace. Wähle eine, um eine neue Version zu publishen." : { + + }, + "Du nutzt Wordeck anonym" : { + + }, + "Erst ein Deck erstellen." : { + + }, + "Erst-Publish: 1.0.0. Spätere Versionen müssen semver-größer sein." : { + + }, + "Format pro Zeile: vorne,hinten,typ. Typ-Spalte optional (Default basic)." : { + + }, + "Im Marketplace veröffentlichen" : { + + }, + "Image-Occlusion und Audio-Cards werden im CSV-Import übersprungen — die brauchen Datei-Uploads." : { + + }, + "Inbox" : { + + }, + "Karteikarten des Vereins mana e.V." : { + + }, + "Karten" : { + + }, + "Karten konnten nicht geladen werden" : { + + }, + "Keine" : { + + }, + "KI" : { + + }, + "KI generiert das Deck aus einer kurzen Beschreibung. 10 Anfragen pro Minute." : { + + }, + "KI liest Bilder oder PDFs und macht daraus Karten. Bis zu 5 Dateien." : { + + }, + "KI liest den Inhalt der Seite als zusätzliche Quelle." : { + + }, + "Lade Decks …" : { + + }, + "Leer" : { + + }, + "Leeres Deck — Karten anschließend selbst anlegen." : { + + }, + "Marketplace und lokale Decks funktionieren ohne Konto. Für KI-Karten, eigene Decks im Cloud-Sync und Marketplace-Veröffentlichung brauchst du ein Konto." : { + + }, + "Max. %@ Dateien. Bilder ≤ 10 MB, PDFs ≤ 30 MB." : { + + }, + "Metadaten ändern: Marketplace-Webansicht → Deck → Bearbeiten." : { + + }, + "Mit Hint: `{{c1::Berlin::Hauptstadt von DE}}`" : { + + }, + "Neue Version: %@" : { + + }, + "Neues Marketplace-Deck" : { + + }, + "Nicht genug andere Karten im Deck für Multiple-Choice — tippe auf »Antwort anzeigen«." : { + + }, + "Noch keine Karten. Tippe auf »Karte hinzufügen«." : { + + }, + "Noch keine Maske" : { + + }, + "Öffentlich" : { + + }, + "OK" : { + + }, + "Pflicht-Schritt vor dem ersten Marketplace-Deck. Slug erscheint in Marketplace-URLs." : { + + }, + "Preis: %@ Credits" : { + + }, + "Privat" : { + + }, + "Quellen" : { + + }, + "Space" : { + + }, + "Thema" : { + + }, + "Tippe und ziehe auf das Bild, um eine Maske zu erstellen." : { + + }, + "Tippe unten auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab." : { + + }, + "Veröffentlicht: %@" : { + + }, + "Veröffentlichungs-Modus" : { + + }, + "Version" : { + + }, + "Versuche eine andere Suche oder Sortierung." : { + + }, + "Vorschau (%@ Karten)" : { + + }, + "Wählen …" : { + + }, + "Wir prüfen jede Meldung. Hass und Rechtsverletzungen werden bevorzugt behandelt." : { + + }, + "Wird veröffentlicht …" : { + + }, + "Zusätzliche URL (optional)" : { + + } + }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/Sources/Resources/WordeckNative.entitlements b/Sources/Resources/WordeckNative.entitlements new file mode 100644 index 0000000..2d1339c --- /dev/null +++ b/Sources/Resources/WordeckNative.entitlements @@ -0,0 +1,24 @@ + + + + + com.apple.developer.associated-domains + + applinks:wordeck.com + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.ev.mana.wordeck + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + keychain-access-groups + + $(AppIdentifierPrefix)ev.mana.session + + + diff --git a/Tests/UITests/CardsNativeUITests.swift b/Tests/UITests/WordeckNativeUITests.swift similarity index 67% rename from Tests/UITests/CardsNativeUITests.swift rename to Tests/UITests/WordeckNativeUITests.swift index 41b0ee2..1134668 100644 --- a/Tests/UITests/CardsNativeUITests.swift +++ b/Tests/UITests/WordeckNativeUITests.swift @@ -1,14 +1,14 @@ import XCTest -final class CardsNativeUITests: XCTestCase { - func testAppLaunches() throws { +final class WordeckNativeUITests: XCTestCase { + func testAppLaunches() { let app = XCUIApplication() app.launch() - // App ist gestartet, sobald entweder das LoginView "Cards" + // App ist gestartet, sobald entweder das LoginView "Wordeck" // oder das DeckListView mit "Decks" sichtbar ist. Welcher // von beiden hängt davon ab, ob der Simulator-Keychain noch // eine Session hält. - let loginTitle = app.staticTexts["Cards"] + let loginTitle = app.staticTexts["Wordeck"] let decksTitle = app.staticTexts["Decks"] let exploreTab = app.staticTexts["Entdecken"] @@ -21,6 +21,6 @@ final class CardsNativeUITests: XCTestCase { } usleep(100_000) } - XCTAssertTrue(found, "Erwartete App-Surface (Cards | Decks | Entdecken) erschien nicht innerhalb 5 s") + XCTAssertTrue(found, "Erwartete App-Surface (Wordeck | Decks | Entdecken) erschien nicht innerhalb 5 s") } } diff --git a/Tests/UnitTests/CardsNativeTests.swift b/Tests/UnitTests/CardsNativeTests.swift deleted file mode 100644 index b979c91..0000000 --- a/Tests/UnitTests/CardsNativeTests.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Testing -@testable import CardsNative - -@Suite("AppConfig") -struct AppConfigTests { - @Test("Cards-API zeigt auf cardecky-api.mana.how") - func apiBaseURLPointsToCardecky() { - #expect(AppConfig.apiBaseURL.absoluteString == "https://cardecky-api.mana.how") - } - - @Test("Auth zeigt auf auth.mana.how") - func authBaseURLPointsToManaAuth() { - #expect(AppConfig.manaAppConfig.authBaseURL.absoluteString == "https://auth.mana.how") - } - - @Test("Keychain-Service ist ev.mana.cardecky") - func keychainServiceIsAppSpecific() { - #expect(AppConfig.manaAppConfig.keychainService == "ev.mana.cardecky") - } -} diff --git a/Tests/UnitTests/ClozeTests.swift b/Tests/UnitTests/ClozeTests.swift index 2cff5f3..ef24430 100644 --- a/Tests/UnitTests/ClozeTests.swift +++ b/Tests/UnitTests/ClozeTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import CardsNative +@testable import WordeckNative @Suite("Cloze") struct ClozeTests { diff --git a/Tests/UnitTests/DeckDecodingTests.swift b/Tests/UnitTests/DeckDecodingTests.swift index 9f6a5f1..d2309a6 100644 --- a/Tests/UnitTests/DeckDecodingTests.swift +++ b/Tests/UnitTests/DeckDecodingTests.swift @@ -1,12 +1,12 @@ import Foundation import Testing -@testable import CardsNative +@testable import WordeckNative @Suite("Deck-JSON-Decoding") struct DeckDecodingTests { @Test("Wire-Format aus toDeckDto decodet sauber") func decodesDeckFromWireFormat() throws { - let json = """ + let json = Data(""" { "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", "user_id": "user_123", @@ -23,7 +23,7 @@ struct DeckDecodingTests { "created_at": "2026-05-12T10:30:00.123Z", "updated_at": "2026-05-12T15:45:00.456Z" } - """.data(using: .utf8)! + """.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601withFractional @@ -41,7 +41,7 @@ struct DeckDecodingTests { @Test("Marketplace-Forks werden erkannt") func recognizesMarketplaceFork() throws { - let json = """ + let json = Data(""" { "id": "deck_456", "user_id": "user_123", @@ -58,7 +58,7 @@ struct DeckDecodingTests { "created_at": "2026-05-01T00:00:00.000Z", "updated_at": "2026-05-01T00:00:00.000Z" } - """.data(using: .utf8)! + """.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601withFractional @@ -71,7 +71,7 @@ struct DeckDecodingTests { @Test("DeckListResponse-Wrapper") func decodesListResponse() throws { - let json = """ + let json = Data(""" { "decks": [ { @@ -93,7 +93,7 @@ struct DeckDecodingTests { ], "total": 1 } - """.data(using: .utf8)! + """.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601withFractional diff --git a/Tests/UnitTests/MarketplaceDecodingTests.swift b/Tests/UnitTests/MarketplaceDecodingTests.swift index c1d7a80..2fd5701 100644 --- a/Tests/UnitTests/MarketplaceDecodingTests.swift +++ b/Tests/UnitTests/MarketplaceDecodingTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import CardsNative +@testable import WordeckNative @Suite("Marketplace-JSON-Decoding") struct MarketplaceDecodingTests { @@ -12,7 +12,7 @@ struct MarketplaceDecodingTests { @Test("PublicDeckEntry aus Browse-Response") func decodesPublicDeckEntry() throws { - let json = """ + let json = Data(""" { "slug": "geografie-welt-top30", "title": "Geografie Welt Top 30", @@ -31,10 +31,10 @@ struct MarketplaceDecodingTests { "display_name": "mana-Kuratoren", "verified_mana": true, "verified_community": false, - "pseudonym": null + "pseudonym": false } } - """.data(using: .utf8)! + """.utf8) let entry = try decoder().decode(PublicDeckEntry.self, from: json) #expect(entry.slug == "geografie-welt-top30") @@ -46,12 +46,12 @@ struct MarketplaceDecodingTests { @Test("ExploreResponse mit featured + trending") func decodesExploreResponse() throws { - let json = """ + let json = Data(""" { "featured": [], "trending": [] } - """.data(using: .utf8)! + """.utf8) let res = try decoder().decode(ExploreResponse.self, from: json) #expect(res.featured.isEmpty) #expect(res.trending.isEmpty) @@ -59,7 +59,7 @@ struct MarketplaceDecodingTests { @Test("PublicDeckDetail mit camelCase 'latest_version'") func decodesPublicDeckDetail() throws { - let json = """ + let json = Data(""" { "deck": { "id": "deck_1", @@ -88,7 +88,7 @@ struct MarketplaceDecodingTests { }, "owner": null } - """.data(using: .utf8)! + """.utf8) let detail = try decoder().decode(PublicDeckDetail.self, from: json) #expect(detail.deck.slug == "english-a2") @@ -99,14 +99,14 @@ struct MarketplaceDecodingTests { @Test("SubscribeResponse mit private_deck_id") func decodesSubscribeResponse() throws { - let json = """ + let json = Data(""" { "subscribed": true, "deck_slug": "english-a2", "current_version_id": "v_1", "private_deck_id": "private_deck_xyz" } - """.data(using: .utf8)! + """.utf8) let res = try decoder().decode(SubscribeResponse.self, from: json) #expect(res.subscribed == true) #expect(res.privateDeckId == "private_deck_xyz") diff --git a/Tests/UnitTests/MaskRegionsTests.swift b/Tests/UnitTests/MaskRegionsTests.swift deleted file mode 100644 index d11fb9e..0000000 --- a/Tests/UnitTests/MaskRegionsTests.swift +++ /dev/null @@ -1,79 +0,0 @@ -import Foundation -import Testing -@testable import CardsNative - -@Suite("MaskRegions") -struct MaskRegionsTests { - @Test("Parsed Liste sortiert nach ID lexikographisch") - func parseSortsByIdLexically() { - let json = """ - [ - {"id":"m003","x":0.1,"y":0.1,"w":0.2,"h":0.2,"label":"C"}, - {"id":"m001","x":0,"y":0,"w":0.1,"h":0.1,"label":"A"}, - {"id":"m002","x":0.5,"y":0.5,"w":0.3,"h":0.3} - ] - """ - let regions = MaskRegions.parse(json) - #expect(regions.count == 3) - #expect(regions[0].id == "m001") - #expect(regions[1].id == "m002") - #expect(regions[2].id == "m003") - #expect(regions[2].label == "C") - #expect(regions[1].label == nil) - } - - @Test("Bei Parse-Fehler → leere Liste") - func parseInvalidReturnsEmpty() { - #expect(MaskRegions.parse("[}").isEmpty) - #expect(MaskRegions.parse("{}").isEmpty) - #expect(MaskRegions.parse("").isEmpty) - } - - @Test("region(forSubIndex:) mappt aufsteigend") - func subIndexLookup() { - let json = """ - [{"id":"b","x":0,"y":0,"w":0.1,"h":0.1}, - {"id":"a","x":0,"y":0,"w":0.2,"h":0.2}] - """ - #expect(MaskRegions.region(for: json, subIndex: 0)?.id == "a") - #expect(MaskRegions.region(for: json, subIndex: 1)?.id == "b") - #expect(MaskRegions.region(for: json, subIndex: 2) == nil) - } - - @Test("Encode-Roundtrip") - func encodeRoundtrip() { - let original = [ - MaskRegion(id: "m1", x: 0.1, y: 0.2, w: 0.3, h: 0.4, label: "test"), - MaskRegion(id: "m2", x: 0.5, y: 0.6, w: 0.2, h: 0.2, label: nil), - ] - let encoded = MaskRegions.encode(original) - let parsed = MaskRegions.parse(encoded) - #expect(parsed.count == 2) - #expect(parsed[0].id == "m1") - #expect(parsed[0].label == "test") - #expect(parsed[1].label == nil) - } - - @Test("CardFieldsBuilder.imageOcclusion produziert korrekte Felder") - func builderImageOcclusion() { - let regions = [MaskRegion(id: "m1", x: 0, y: 0, w: 0.5, h: 0.5, label: "x")] - let fields = CardFieldsBuilder.imageOcclusion( - imageRef: "media_123", - regions: regions, - note: "Hinweis" - ) - #expect(fields["image_ref"] == "media_123") - #expect(fields["note"] == "Hinweis") - let reparsed = MaskRegions.parse(fields["mask_regions"] ?? "") - #expect(reparsed.count == 1) - #expect(reparsed[0].id == "m1") - } - - @Test("CardFieldsBuilder.audioFront produziert korrekte Felder") - func builderAudioFront() { - let fields = CardFieldsBuilder.audioFront(audioRef: "audio_456", back: "Antwort") - #expect(fields["audio_ref"] == "audio_456") - #expect(fields["back"] == "Antwort") - #expect(fields.count == 2) - } -} diff --git a/Tests/UnitTests/MutationEncodingTests.swift b/Tests/UnitTests/MutationEncodingTests.swift index 1336ffe..34565ed 100644 --- a/Tests/UnitTests/MutationEncodingTests.swift +++ b/Tests/UnitTests/MutationEncodingTests.swift @@ -1,12 +1,19 @@ import Foundation import Testing -@testable import CardsNative +@testable import WordeckNative @Suite("Mutation Body Encoding") struct MutationEncodingTests { - private func encode(_ value: T) throws -> [String: Any] { + private func encode(_ value: some Encodable) throws -> [String: Any] { let data = try JSONEncoder().encode(value) - return try JSONSerialization.jsonObject(with: data) as! [String: Any] + guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw EncodeError.notADictionary + } + return dict + } + + private enum EncodeError: Error { + case notADictionary } @Test("DeckCreateBody nutzt snake_case und lässt nil weg") @@ -40,8 +47,7 @@ struct MutationEncodingTests { let body = CardCreateBody( deckId: "deck_1", type: .basic, - fields: CardFieldsBuilder.basic(front: "Hallo", back: "Hello"), - mediaRefs: nil + fields: CardFieldsBuilder.basic(front: "Hallo", back: "Hello") ) let json = try encode(body) #expect(json["deck_id"] as? String == "deck_1") @@ -57,8 +63,7 @@ struct MutationEncodingTests { let body = CardCreateBody( deckId: "d", type: .basicReverse, - fields: CardFieldsBuilder.basic(front: "a", back: "b"), - mediaRefs: nil + fields: CardFieldsBuilder.basic(front: "a", back: "b") ) let json = try encode(body) #expect(json["type"] as? String == "basic-reverse") @@ -69,8 +74,7 @@ struct MutationEncodingTests { let body = CardCreateBody( deckId: "d", type: .cloze, - fields: CardFieldsBuilder.cloze(text: "Die {{c1::Sonne}} scheint."), - mediaRefs: nil + fields: CardFieldsBuilder.cloze(text: "Die {{c1::Sonne}} scheint.") ) let json = try encode(body) #expect(json["type"] as? String == "cloze") @@ -83,8 +87,7 @@ struct MutationEncodingTests { let body = CardCreateBody( deckId: "d", type: .multipleChoice, - fields: CardFieldsBuilder.multipleChoice(front: "Q", answer: "A"), - mediaRefs: nil + fields: CardFieldsBuilder.multipleChoice(front: "Q", answer: "A") ) let json = try encode(body) #expect(json["type"] as? String == "multiple-choice") @@ -92,7 +95,7 @@ struct MutationEncodingTests { @Test("CardUpdateBody nur mit fields") func cardUpdateBodyFieldsOnly() throws { - let body = CardUpdateBody(fields: ["front": "neu"], mediaRefs: nil) + let body = CardUpdateBody(fields: ["front": "neu"]) let json = try encode(body) #expect((json["fields"] as? [String: String])?["front"] == "neu") #expect(json["media_refs"] == nil) diff --git a/Tests/UnitTests/ReviewDecodingTests.swift b/Tests/UnitTests/ReviewDecodingTests.swift index 1db436a..c1bba29 100644 --- a/Tests/UnitTests/ReviewDecodingTests.swift +++ b/Tests/UnitTests/ReviewDecodingTests.swift @@ -1,12 +1,12 @@ import Foundation import Testing -@testable import CardsNative +@testable import WordeckNative @Suite("Review-JSON-Decoding") struct ReviewDecodingTests { @Test("Review-Wire-Format decodet vollständig") func decodesReview() throws { - let json = """ + let json = Data(""" { "card_id": "card_1", "sub_index": 0, @@ -22,7 +22,7 @@ struct ReviewDecodingTests { "state": "review", "last_review": "2026-05-10T10:00:00.000Z" } - """.data(using: .utf8)! + """.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601withFractional @@ -39,7 +39,7 @@ struct ReviewDecodingTests { func decodesDueReview() throws { // Achtung: Server liefert hier `deckId` camelCase im embedded card, // weil das aus Drizzle direkt rauskommt, nicht durch toCardDto. - let json = """ + let json = Data(""" { "card_id": "c1", "sub_index": 0, @@ -61,7 +61,7 @@ struct ReviewDecodingTests { "fields": {"front": "Was ist 1+1?", "back": "2"} } } - """.data(using: .utf8)! + """.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601withFractional diff --git a/Tests/UnitTests/TypingTests.swift b/Tests/UnitTests/TypingTests.swift new file mode 100644 index 0000000..4bd9d85 --- /dev/null +++ b/Tests/UnitTests/TypingTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import WordeckNative + +@Suite("Typing-Match") +struct TypingTests { + @Test("Exact match → correct") + func exactMatch() { + #expect(Typing.check(input: "Berlin", answer: "Berlin") == .correct) + } + + @Test("Case + Whitespace normalisiert") + func caseAndWhitespace() { + #expect(Typing.check(input: " berlin ", answer: "Berlin") == .correct) + #expect(Typing.check(input: "BERLIN", answer: "berlin") == .correct) + } + + @Test("Umlaute via NFD-Diakritika-Stripping") + func umlauts() { + #expect(Typing.check(input: "Munchen", answer: "München") == .correct) + #expect(Typing.check(input: "muenchen", answer: "München") != .correct) + // muenchen != munchen via NFD: ä → a, aber ue ≠ ü + } + + @Test("Aliases akzeptiert als correct") + func aliasesCorrect() { + let aliases = "Frankfurt am Main,Frankfurt/Main" + #expect(Typing.check(input: "Frankfurt/Main", answer: "Frankfurt", aliases: aliases) == .correct) + } + + @Test("Levenshtein-1 bei 5+ Zeichen → close") + func closeMatch() { + #expect(Typing.check(input: "Berln", answer: "Berlin") == .close) + #expect(Typing.check(input: "Berlim", answer: "Berlin") == .close) + } + + @Test("Großer Unterschied → wrong") + func wrongMatch() { + #expect(Typing.check(input: "Madrid", answer: "Berlin") == .wrong) + } + + @Test("Leer-Input → wrong") + func emptyInput() { + #expect(Typing.check(input: "", answer: "Berlin") == .wrong) + #expect(Typing.check(input: " ", answer: "Berlin") == .wrong) + } + + @Test("Levenshtein-Helper") + func levenshteinSanity() { + #expect(Typing.levenshtein("", "") == 0) + #expect(Typing.levenshtein("abc", "abc") == 0) + #expect(Typing.levenshtein("abc", "abd") == 1) + #expect(Typing.levenshtein("kitten", "sitting") == 3) + } +} diff --git a/Tests/UnitTests/WordeckNativeTests.swift b/Tests/UnitTests/WordeckNativeTests.swift new file mode 100644 index 0000000..a77a6c9 --- /dev/null +++ b/Tests/UnitTests/WordeckNativeTests.swift @@ -0,0 +1,26 @@ +import ManaCore +import Testing +@testable import WordeckNative + +@Suite("AppConfig") +struct AppConfigTests { + @Test("Wordeck-API zeigt auf api.wordeck.com") + func apiBaseURLPointsToWordeck() { + #expect(AppConfig.apiBaseURL.absoluteString == "https://api.wordeck.com") + } + + @Test("Auth zeigt auf auth.mana.how") + func authBaseURLPointsToManaAuth() { + #expect(AppConfig.manaAppConfig.authBaseURL.absoluteString == "https://auth.mana.how") + } + + /// Cross-App-SSO: alle nativen mana-Apps teilen sich + /// `ManaSharedKeychainGroup` (= "ev.mana.session"), damit JWT + + /// Refresh-Token zwischen Apps geteilt werden können. Referenz + /// statt String-Literal, sonst driftet's bei jeder Plattform- + /// Aktualisierung. + @Test("Keychain-Service nutzt geteilte Mana-Group") + func keychainServiceUsesSharedGroup() { + #expect(AppConfig.manaAppConfig.keychainService == ManaSharedKeychainGroup) + } +} diff --git a/Widgets/CardsWidget/CardsDueWidget.swift b/Widgets/CardsWidget/CardsDueWidget.swift deleted file mode 100644 index 99023f6..0000000 --- a/Widgets/CardsWidget/CardsDueWidget.swift +++ /dev/null @@ -1,22 +0,0 @@ -import SwiftUI -import WidgetKit - -struct CardsDueWidget: Widget { - let kind: String = "ev.mana.cardecky.due" - - var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: DueProvider()) { entry in - DueWidgetView(entry: entry) - .containerBackground(.fill.tertiary, for: .widget) - } - .configurationDisplayName("Karten heute") - .description("Zeigt deine fälligen Karten und Top-Decks.") - .supportedFamilies([ - .systemSmall, - .systemMedium, - .accessoryCircular, - .accessoryInline, - .accessoryRectangular, - ]) - } -} diff --git a/Widgets/CardsWidget/DueProvider.swift b/Widgets/WordeckWidget/DueProvider.swift similarity index 100% rename from Widgets/CardsWidget/DueProvider.swift rename to Widgets/WordeckWidget/DueProvider.swift diff --git a/Widgets/CardsWidget/DueWidgetView.swift b/Widgets/WordeckWidget/DueWidgetView.swift similarity index 63% rename from Widgets/CardsWidget/DueWidgetView.swift rename to Widgets/WordeckWidget/DueWidgetView.swift index ee99536..a29c0c2 100644 --- a/Widgets/CardsWidget/DueWidgetView.swift +++ b/Widgets/WordeckWidget/DueWidgetView.swift @@ -14,6 +14,8 @@ struct DueWidgetView: View { smallView case .systemMedium: mediumView + case .systemLarge: + largeView case .accessoryCircular: circularView case .accessoryInline: @@ -75,6 +77,52 @@ struct DueWidgetView: View { } } + private var largeView: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("\(entry.totalDueCount)") + .font(.system(size: 56, weight: .bold)) + .lineLimit(1) + .minimumScaleFactor(0.6) + VStack(alignment: .leading, spacing: 2) { + Text(entry.totalDueCount == 1 ? "Karte fällig" : "Karten fällig") + .font(.subheadline.weight(.medium)) + Text("Heute") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + + Divider() + + VStack(alignment: .leading, spacing: 6) { + Text("Top-Decks") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + ForEach(entry.topDecks.prefix(6)) { deck in + HStack { + Text(deck.name) + .font(.callout) + .lineLimit(1) + Spacer(minLength: 8) + Text("\(deck.dueCount)") + .font(.callout.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + if entry.topDecks.isEmpty { + Text("Keine Decks mit fälligen Karten.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + private var circularView: some View { ZStack { Circle() diff --git a/Widgets/CardsWidget/Resources/Assets.xcassets/Contents.json b/Widgets/WordeckWidget/Resources/Assets.xcassets/Contents.json similarity index 100% rename from Widgets/CardsWidget/Resources/Assets.xcassets/Contents.json rename to Widgets/WordeckWidget/Resources/Assets.xcassets/Contents.json diff --git a/Widgets/WordeckWidget/Resources/CardsWidgetExtension.entitlements b/Widgets/WordeckWidget/Resources/CardsWidgetExtension.entitlements new file mode 100644 index 0000000..0723c65 --- /dev/null +++ b/Widgets/WordeckWidget/Resources/CardsWidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.ev.mana.wordeck + + + diff --git a/Widgets/WordeckWidget/Resources/Info.plist b/Widgets/WordeckWidget/Resources/Info.plist new file mode 100644 index 0000000..df55a24 --- /dev/null +++ b/Widgets/WordeckWidget/Resources/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Wordeck Widget + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/Widgets/WordeckWidget/Resources/WordeckWidgetExtension.entitlements b/Widgets/WordeckWidget/Resources/WordeckWidgetExtension.entitlements new file mode 100644 index 0000000..95d714b --- /dev/null +++ b/Widgets/WordeckWidget/Resources/WordeckWidgetExtension.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.ev.mana.wordeck + + keychain-access-groups + + $(AppIdentifierPrefix)ev.mana.session + + + diff --git a/Widgets/WordeckWidget/WordeckDueWidget.swift b/Widgets/WordeckWidget/WordeckDueWidget.swift new file mode 100644 index 0000000..6d85509 --- /dev/null +++ b/Widgets/WordeckWidget/WordeckDueWidget.swift @@ -0,0 +1,57 @@ +import SwiftUI +import WidgetKit + +struct WordeckDueWidget: Widget { + let kind: String = "ev.mana.wordeck.due" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: DueProvider()) { entry in + DueWidgetView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + .widgetURL(URL(string: "wordeck://study")) + } + .configurationDisplayName("Karten heute") + .description("Zeigt deine fälligen Karten und Top-Decks.") + .supportedFamilies([ + .systemSmall, + .systemMedium, + .systemLarge, + .accessoryCircular, + .accessoryInline, + .accessoryRectangular, + ]) + } +} + +// MARK: - Previews + +private let demoEntry = DueEntry( + date: .now, + totalDueCount: 23, + topDecks: [ + WidgetSnapshot.Entry(id: "d1", name: "Italienisch — Verben", dueCount: 12, colorHex: nil), + WidgetSnapshot.Entry(id: "d2", name: "iOS-Snippets", dueCount: 7, colorHex: nil), + WidgetSnapshot.Entry(id: "d3", name: "Anatomie", dueCount: 4, colorHex: nil), + WidgetSnapshot.Entry(id: "d4", name: "Sterne am Nordhimmel", dueCount: 2, colorHex: nil), + ], + isPlaceholder: false +) + +#Preview("Klein", as: .systemSmall) { + WordeckDueWidget() +} timeline: { + demoEntry + DueEntry.placeholder +} + +#Preview("Mittel", as: .systemMedium) { + WordeckDueWidget() +} timeline: { + demoEntry +} + +#Preview("Groß", as: .systemLarge) { + WordeckDueWidget() +} timeline: { + demoEntry +} diff --git a/Widgets/CardsWidget/CardsWidgetBundle.swift b/Widgets/WordeckWidget/WordeckWidgetBundle.swift similarity index 82% rename from Widgets/CardsWidget/CardsWidgetBundle.swift rename to Widgets/WordeckWidget/WordeckWidgetBundle.swift index c51370f..cbb9f89 100644 --- a/Widgets/CardsWidget/CardsWidgetBundle.swift +++ b/Widgets/WordeckWidget/WordeckWidgetBundle.swift @@ -6,8 +6,8 @@ import WidgetKit /// rectangular). Daten kommen aus dem shared App-Group-Container /// (siehe `WidgetSnapshotStore` im Haupt-Target). @main -struct CardsWidgetBundle: WidgetBundle { +struct WordeckWidgetBundle: WidgetBundle { var body: some Widget { - CardsDueWidget() + WordeckDueWidget() } } diff --git a/devlog/2026-05-12/data.json b/devlog/2026-05-12/data.json new file mode 100644 index 0000000..4c8656c --- /dev/null +++ b/devlog/2026-05-12/data.json @@ -0,0 +1,183 @@ +{ + "date": "2026-05-12", + "day_number": 1, + "weekday": "Dienstag", + "commits": 10, + "authors": [ + { + "name": "Till JS", + "count": 10 + } + ], + "additions": 6964, + "deletions": 326, + "net_lines": 6638, + "files_changed": 77, + "new_files": 0, + "deleted_files": 0, + "session": { + "first_commit_at": "2026-05-12T17:29:45.000Z", + "last_commit_at": "2026-05-12T23:56:54.000Z", + "total_span_minutes": 387, + "active_minutes": 67, + "pauses": [ + { + "from": "19:29", + "to": "00:06", + "minutes": 277 + }, + { + "from": "01:13", + "to": "01:56", + "minutes": 43 + } + ], + "longest_focus_minutes": 67 + }, + "top_dirs": [ + { + "path": "PLAN.md", + "pct": 7 + }, + { + "path": "Sources/Features/Decks", + "pct": 7 + }, + { + "path": "Sources/Core/Domain", + "pct": 7 + }, + { + "path": "Sources/Core/Sync", + "pct": 6 + }, + { + "path": "Sources/Core/API", + "pct": 5 + } + ], + "top_extensions": [ + { + "ext": ".swift", + "count": 79 + }, + { + "ext": ".md", + "count": 12 + }, + { + "ext": ".yml", + "count": 6 + }, + { + "ext": ".json", + "count": 5 + }, + { + "ext": ".entitlements", + "count": 3 + }, + { + "ext": ".plist", + "count": 3 + } + ], + "tags": [], + "commits_list": [ + { + "hash": "28b20cd", + "short": "v0.1.0 — Phase β-0 Setup", + "type": null, + "scope": null, + "additions": 896, + "deletions": 0, + "timestamp": "2026-05-12T19:29:45+02:00" + }, + { + "hash": "f664a00", + "short": "v0.2.0 — Phase β-1 Decks lesen", + "type": null, + "scope": null, + "additions": 809, + "deletions": 85, + "timestamp": "2026-05-13T00:06:28+02:00" + }, + { + "hash": "3b861af", + "short": "v0.3.0 — Phase β-2 Study-Loop", + "type": null, + "scope": null, + "additions": 1013, + "deletions": 23, + "timestamp": "2026-05-13T00:16:11+02:00" + }, + { + "hash": "cf1160b", + "short": "v0.4.0 — Phase β-3 Editor", + "type": null, + "scope": null, + "additions": 933, + "deletions": 22, + "timestamp": "2026-05-13T00:24:43+02:00" + }, + { + "hash": "80eb370", + "short": "v0.5.0 — Phase β-4 Media + Advanced Card-Types", + "type": null, + "scope": null, + "additions": 922, + "deletions": 43, + "timestamp": "2026-05-13T00:35:36+02:00" + }, + { + "hash": "07ada72", + "short": "v0.6.0 — Phase β-5 Marketplace", + "type": null, + "scope": null, + "additions": 1015, + "deletions": 24, + "timestamp": "2026-05-13T00:51:12+02:00" + }, + { + "hash": "a1770fb", + "short": "v0.7.0 — Phase β-6 Native-Polish", + "type": null, + "scope": null, + "additions": 580, + "deletions": 13, + "timestamp": "2026-05-13T01:00:04+02:00" + }, + { + "hash": "55359c5", + "short": "chore: gitignore widget-generated Info.plist + entitlements", + "type": null, + "scope": null, + "additions": 0, + "deletions": 39, + "timestamp": "2026-05-13T01:00:22+02:00" + }, + { + "hash": "0b2ae16", + "short": "v0.8.0 — Phase β-7 App-Store-Vorbereitung", + "type": null, + "scope": null, + "additions": 786, + "deletions": 62, + "timestamp": "2026-05-13T01:13:27+02:00" + }, + { + "hash": "bf23968", + "short": "docs: AASA-Endpoint markiert als done (Code-side)", + "type": null, + "scope": null, + "additions": 10, + "deletions": 15, + "timestamp": "2026-05-13T01:56:54+02:00" + } + ], + "review_state": "auto", + "llm": { + "model": null, + "generated_at": null + } +} diff --git a/devlog/2026-05-12/macher.md b/devlog/2026-05-12/macher.md new file mode 100644 index 0000000..6de61ae --- /dev/null +++ b/devlog/2026-05-12/macher.md @@ -0,0 +1,105 @@ +--- +date: 2026-05-12 +day: 1 +view: macher +weekday: Dienstag +commits: 10 +review: written +--- +# Dienstag, 2026-05-12 — Tag 1 (Macher-Sicht) + +Phasen β-0 bis β-7 aus dem Greenfield-Playbook in einer Marathon- +Session. Skeleton → App-Store-Vorbereitung. Web-Parität als +Leitprinzip, FSRS bleibt server-authoritative, alles andere ist +Native-Re-Implementation des Web-Wordeck. + +## Stats + +10 Commits, +6 964 / −326 LoC, 77 Files. 79× `.swift`. Session +17:29 → 01:56, 67 aktive Minuten, längster Fokus 67 Min. Top-Dirs +sind sehr verteilt (alle 5–7 %) — Greenfield-Tag, der jeden +Subsystem-Bereich anfasst. + +## Versions-Tags + +- **v0.1.0 — β-0 Setup.** XcodeGen-Manifest, ManaCore + ManaTokens- + Package-Dependency, ManaCore-Login + Healthz-Probe gegen + api.wordeck.com. +- **v0.2.0 — β-1 Decks lesen.** SwiftData-Cache, + `/api/v1/decks`-Liste, Detail-View mit Karten-Anzahl, + Stats-Sektion. +- **v0.3.0 — β-2 Study-Loop.** Grading gegen `POST /api/v1/reviews/ + :cardId/:subIndex/grade`. `ReviewQueue` für Offline-Grades, + Replay bei Reconnect. SubIndex-Berechnung für Cloze bleibt + server-side. +- **v0.4.0 — β-3 Editor.** Basic, Cloze, Typing, Multiple-Choice + als Editor-Surfaces. Content-Hash-Berechnung am Server. +- **v0.5.0 — β-4 Media + Advanced Card-Types.** Image- und + Audio-Front-Karten, Image-Occlusion-Mask via PencilKit + (einzige UIKit-Bridge), `mana-media`-Upload-Pfad. +- **v0.6.0 — β-5 Marketplace.** `/api/v1/marketplace/*`-Routen, + Subscribe-Flow, Universal-Links für Deck-Slugs. +- **v0.7.0 — β-6 Native-Polish.** WidgetKit (Due-Cards-Counter + + Heute-Streak), Local-Notifications für Review-Reminder, Share- + Extension „Save as Card". +- **chore: gitignore widget-generated Info.plist + entitlements.** + XcodeGen generiert die, sollen nicht im Git. +- **v0.8.0 — β-7 App-Store-Vorbereitung.** `PrivacyInfo.xcprivacy`, + AASA-Endpoint im Cards-API, Submission-Checkliste. +- **AASA-Endpoint als done markiert** code-seitig — DNS-/Cloudflare- + Roundtrip steht aus (gleiche Falle wie zitare-native). + +## Architektur-Entscheidungen + +- **Server-authoritative FSRS.** Grading-Calls immer an `POST + /api/v1/reviews/:cardId/:subIndex/grade`. Kein lokaler + ts-fsrs-Port. Vorteil: Algorithmen-Updates wirken sofort in + Native, ohne App-Update. Nachteil: Grading ist online-bedingt + (Offline-Queue löst das mit Round-Trip-Delay). +- **Offline-Read, Online-Write.** Decks + Due-Cards via SwiftData + gecacht (offline lesbar). Grades werden offline in einer + `ReviewQueue` persistiert, beim Reconnect der Reihe nach + abgesendet. +- **Cards-Domain-Logik bleibt am Server.** SubIndex-Berechnung + für Cloze, Image-Occlusion-Mask-Validation, Content-Hash — + alles Server. Native zeigt nur, was vom Server kommt. +- **Pure SwiftUI**, einzige UIKit-Bridge: `PencilKit` für Image- + Occlusion. SwiftUI hat keinen Pencil-Picker, nicht-vermeidbar. +- **mana-auth via ManaCore.** Eigene Auth-Implementierung verboten. +- **`forest`-Theme**, lokal in `WordeckTheme.swift` aus + `mana/packages/themes/src/variants/forest.css` gespiegelt. + Migration auf ManaTokens-Theme-Switch ist Phase ε. +- **Web gewinnt bei Konflikt.** Eleganteres Native-Verhalten geht + zuerst in Web, dann nach hier. Verhindert Drift. + +## Trade-offs + +- **67 Min Active für β-0 bis β-7** klingt absurd komprimiert. + Realer Grund: jeder β-Schritt ist „Web-Funktion X als SwiftUI + spiegeln" — die Domain-Arbeit (FSRS, Cloze, Content-Hash) ist + alles Server-side bereits gelöst. Native = Wire-Format + + Codable + View. +- **PencilKit-Bridge** als einzige UIKit-Konzession. Beschränkt; + Image-Occlusion kann sonst nicht ergonomisch sein. +- **Local-Notifications für Review-Reminder** sind Best-Effort. + iOS budgetiert hart; bei vielen Decks kann eine Erinnerung + verschoben werden. +- **+6 638 LoC netto in einer Schicht** — keine Architektur-Arbeit, + reine Übersetzung. Wäre das nicht so identisch zum Web-Modell, + wäre der Tag dreimal so lang. + +## Offene Punkte + +- **AASA-DNS-Roundtrip** für Universal-Links. Code da, DNS-Setup + in Cloudflare offen. +- **TestFlight-Build** mit allem an Bord — Apple-Submission Folge- + Tag. +- **Endurance-Test auf echtem Gerät** über mehrere Tage — + Battery-Drain durch Notifications, ReviewQueue-Drift bei langem + Offline, Widget-Refresh-Verhalten. +- **WebKit-Local-Cache** für rich-content-Karten (HTML-Rich-Text) + nicht implementiert — heute Plain-Text-Fallback. +- **Cards-Domain `forest`-Theme** ist hartcodiert; ManaTokens- + Theme-Switch kommt mit Plattform-Variants-Release. +- **ShareExtension** vorbereitet, aber App-Group-Entitlement + muss in Apple-Dev-Portal aktiviert werden. diff --git a/devlog/2026-05-12/spieler.md b/devlog/2026-05-12/spieler.md new file mode 100644 index 0000000..5ffc4b2 --- /dev/null +++ b/devlog/2026-05-12/spieler.md @@ -0,0 +1,45 @@ +--- +date: 2026-05-12 +day: 1 +view: spieler +weekday: Dienstag +commits: 10 +review: written +--- +# Dienstag, 2026-05-12 — Tag 1 + +Wordeck gibt es bald auch als App. Heute ist die Basis entstanden: +Anmelden, Decks ansehen, lernen, Karten bearbeiten, Bilder dazulegen, +Marketplace durchsehen — alles offline-tauglich. + +## Was die App heute schon kann + +- **Anmelden** über deinen Verein-Account. +- **Deine Decks** sehen — und auch offline weiterhin, weil die App + sie zwischenspeichert. +- **Lernen** mit dem üblichen Bewerten-System (1–4). Wenn du gerade + kein Netz hast, sammelt die App die Bewertungen und schickt sie + später durch. +- **Karten bearbeiten** — die vier Standard-Typen: Vorder-/Rückseite, + Cloze, Tippen, Mehrfachauswahl. +- **Bilder + Audio** auf Karten — inkl. Bildausblenden (Image-Occlusion) + und „Audio zuerst, dann Antwort". +- **Marketplace** durchstöbern und Decks abonnieren. +- **Universal-Links** — Marketplace-Links aus Messenger öffnen direkt + die App. +- **Widget für den Home-Screen** und „Karte aus dem Web speichern"- + Share-Extension sind vorbereitet. + +## Was offen ist + +- **TestFlight + App Store** — Codeseitig ist alles fertig, der + Vertriebs-Slot bei Apple folgt. +- **Endurance-Test auf echtem iPhone** über mehrere Tage — heute + alles im Simulator getestet. + +## Hintergrund + +Wordeck lebt im Browser; manche Leute lernen aber lieber mit der +Hand am Telefon. Die App ist deshalb keine neue Wordeck, sondern +dieselbe Wordeck in iOS-Form — gleiche Karten, gleiches FSRS, +gleicher Account. diff --git a/devlog/2026-05-13/data.json b/devlog/2026-05-13/data.json new file mode 100644 index 0000000..2bd5d15 --- /dev/null +++ b/devlog/2026-05-13/data.json @@ -0,0 +1,317 @@ +{ + "date": "2026-05-13", + "day_number": 2, + "weekday": "Mittwoch", + "commits": 22, + "authors": [ + { + "name": "Till JS", + "count": 22 + } + ], + "additions": 6206, + "deletions": 1511, + "net_lines": 4695, + "files_changed": 87, + "new_files": 0, + "deleted_files": 0, + "session": { + "first_commit_at": "2026-05-13T11:17:41.000Z", + "last_commit_at": "2026-05-14T00:04:29.000Z", + "total_span_minutes": 767, + "active_minutes": 167, + "pauses": [ + { + "from": "13:29", + "to": "14:06", + "minutes": 37 + }, + { + "from": "15:11", + "to": "15:46", + "minutes": 35 + }, + { + "from": "16:02", + "to": "17:20", + "minutes": 77 + }, + { + "from": "18:15", + "to": "19:08", + "minutes": 54 + }, + { + "from": "19:26", + "to": "01:23", + "minutes": 357 + }, + { + "from": "01:23", + "to": "02:03", + "minutes": 40 + } + ], + "longest_focus_minutes": 65 + }, + "top_dirs": [ + { + "path": "Sources/Core/Domain", + "pct": 11 + }, + { + "path": "project.yml", + "pct": 9 + }, + { + "path": "Sources/Features/Decks", + "pct": 9 + }, + { + "path": "Sources/Features/Study", + "pct": 8 + }, + { + "path": "Sources/Core/Sync", + "pct": 6 + } + ], + "top_extensions": [ + { + "ext": ".swift", + "count": 115 + }, + { + "ext": ".yml", + "count": 15 + }, + { + "ext": ".md", + "count": 10 + }, + { + "ext": ".xcstrings", + "count": 4 + }, + { + "ext": ".json", + "count": 2 + }, + { + "ext": ".gitignore", + "count": 1 + } + ], + "tags": [ + "decks", + "auth", + "study", + "editor", + "ui" + ], + "commits_list": [ + { + "hash": "468ef29", + "short": "feat: DEVELOPMENT_TEAM=QP3GLU8PH3 für Code-Signing", + "type": null, + "scope": null, + "additions": 13, + "deletions": 8, + "timestamp": "2026-05-13T13:17:41+02:00" + }, + { + "hash": "4dfb32b", + "short": "chore: Rebrand auf ev.mana.wordeck", + "type": null, + "scope": null, + "additions": 203, + "deletions": 94, + "timestamp": "2026-05-13T13:29:04+02:00" + }, + { + "hash": "6d9a191", + "short": "chore: Archive-Polish — Versions-Sync + Orientations + Mac-Icon", + "type": null, + "scope": null, + "additions": 32, + "deletions": 7, + "timestamp": "2026-05-13T14:06:11+02:00" + }, + { + "hash": "6a4d66f", + "short": "chore: Sendable-Warning + AppIcon-Asset-Cleanup", + "type": null, + "scope": null, + "additions": 19, + "deletions": 14, + "timestamp": "2026-05-13T14:12:29+02:00" + }, + { + "hash": "e24e0e6", + "short": "fix(editor): PhotosPicker Sendable-Warning durch Sub-View struct", + "type": "fix", + "scope": "editor", + "additions": 105, + "deletions": 17, + "timestamp": "2026-05-13T14:39:09+02:00" + }, + { + "hash": "2eb7f61", + "short": "fix: ITMS-90129 — DisplayName Cards → Wordeck + Build 2", + "type": null, + "scope": null, + "additions": 11, + "deletions": 11, + "timestamp": "2026-05-13T15:05:33+02:00" + }, + { + "hash": "b5edf5c", + "short": "fix: User-facing Strings Cards → Wordeck komplett, Build 3", + "type": null, + "scope": null, + "additions": 19, + "deletions": 19, + "timestamp": "2026-05-13T15:11:11+02:00" + }, + { + "hash": "f528ea4", + "short": "fix: PublicDeckOwner.pseudonym ist Bool, nicht String?", + "type": null, + "scope": null, + "additions": 10, + "deletions": 6, + "timestamp": "2026-05-13T15:46:20+02:00" + }, + { + "hash": "e8b898a", + "short": "feat(decks): Card-Liste im DeckDetailView + listCards-API", + "type": "feat", + "scope": "decks", + "additions": 169, + "deletions": 17, + "timestamp": "2026-05-13T16:02:59+02:00" + }, + { + "hash": "0b0872c", + "short": "fix: 404 auf allen Query-Endpoints — pulls ManaCore v1.0.1 fix", + "type": null, + "scope": null, + "additions": 3, + "deletions": 3, + "timestamp": "2026-05-13T17:20:03+02:00" + }, + { + "hash": "aa94601", + "short": "feat(ui): Wordeck-Web-Design — Fan-Stack-Tiles + CardSurface", + "type": "feat", + "scope": "ui", + "additions": 396, + "deletions": 162, + "timestamp": "2026-05-13T17:28:11+02:00" + }, + { + "hash": "8b1dd51", + "short": "feat(study): Multiple-Choice-Karten gerendert", + "type": "feat", + "scope": "study", + "additions": 225, + "deletions": 4, + "timestamp": "2026-05-13T17:34:07+02:00" + }, + { + "hash": "505aa9d", + "short": "feat(study): Typing-Karten + Levenshtein-Match-Logik", + "type": "feat", + "scope": "study", + "additions": 323, + "deletions": 4, + "timestamp": "2026-05-13T17:39:39+02:00" + }, + { + "hash": "33101d7", + "short": "feat(auth): DEBUG-Auto-Login (Memoro-Pattern)", + "type": "feat", + "scope": "auth", + "additions": 52, + "deletions": 12, + "timestamp": "2026-05-13T17:50:29+02:00" + }, + { + "hash": "90201d7", + "short": "feat(decks): Tile-Redesign — Tap=Study, Edit-Icon, Explore-Konsistenz", + "type": "feat", + "scope": "decks", + "additions": 154, + "deletions": 74, + "timestamp": "2026-05-13T18:03:47+02:00" + }, + { + "hash": "0e90f4b", + "short": "chore: PLAN.md auf v0.9.4-Stand + Localizable.xcstrings", + "type": null, + "scope": null, + "additions": 67, + "deletions": 8, + "timestamp": "2026-05-13T18:15:22+02:00" + }, + { + "hash": "6805bd7", + "short": "feat(decks): iOS-26 tabViewBottomAccessory für „Neues Deck\"-Pille", + "type": "feat", + "scope": "decks", + "additions": 57, + "deletions": 10, + "timestamp": "2026-05-13T19:08:57+02:00" + }, + { + "hash": "710ede6", + "short": "fix(api): code:nil bei AuthError.serverError-Calls", + "type": "fix", + "scope": "api", + "additions": 3, + "deletions": 3, + "timestamp": "2026-05-13T19:09:01+02:00" + }, + { + "hash": "da66797", + "short": "feat(auth): ManaAuthUI-Migration — vollständige Auth-Reise nativ", + "type": "feat", + "scope": "auth", + "additions": 173, + "deletions": 99, + "timestamp": "2026-05-13T19:26:12+02:00" + }, + { + "hash": "8ca7bd3", + "short": "feat(auth): Guest-Mode + Login-optionale Surface", + "type": "feat", + "scope": "auth", + "additions": 271, + "deletions": 155, + "timestamp": "2026-05-14T01:23:30+02:00" + }, + { + "hash": "73f9081", + "short": "feat(decks): γ-1 bis γ-8 — AI/CSV-Import, Card-Edit, Pull-Update, Marketplace-Publish + Moderation + PDF", + "type": "feat", + "scope": "decks", + "additions": 3412, + "deletions": 435, + "timestamp": "2026-05-14T02:03:59+02:00" + }, + { + "hash": "aece169", + "short": "chore(lint): SwiftLint-Config + 0-Warnings-Pass + Swift-6-Concurrency-Fixes", + "type": "chore", + "scope": "lint", + "additions": 489, + "deletions": 349, + "timestamp": "2026-05-14T02:04:29+02:00" + } + ], + "review_state": "auto", + "llm": { + "model": null, + "generated_at": null + } +} diff --git a/devlog/2026-05-13/macher.md b/devlog/2026-05-13/macher.md new file mode 100644 index 0000000..af21a89 --- /dev/null +++ b/devlog/2026-05-13/macher.md @@ -0,0 +1,119 @@ +--- +date: 2026-05-13 +day: 2 +view: macher +weekday: Mittwoch +commits: 22 +review: written +--- +# Mittwoch, 2026-05-13 — Tag 2 (Macher-Sicht) + +Großer Polish-Tag mit Funktions-Vollausbau. Web-Design adaptiert, +alle 7 Karten-Typen rendern, ManaAuthUI-Migration zog die Auth-Reise +nativ, Guest-Mode landete, und der γ-1-bis-γ-8-Block brachte AI-/ +CSV-Import, Card-Edit, Pull-Update, Marketplace-Publish, Moderation +und PDF-Export — alles in einer Schicht. + +## Stats + +22 Commits, +6 206 / −1 511 LoC, 87 Files. 115× `.swift`. Session +11:17 → 02:04, 167 aktive Minuten in 7 Blöcken, längster Fokus +65 Min. Top-Dirs: Domain (11 %), project.yml (9 %), Decks (9 %), +Study (8 %), Sync (6 %). Tags: decks, auth, study, editor, ui. + +## Schritte (chronologisch) + +- **DEVELOPMENT_TEAM=QP3GLU8PH3** für Code-Signing. Erstmal + Apple-Konto-Setup. +- **Rebrand auf `ev.mana.wordeck`.** Bundle-ID, DisplayName, Strings, + Icon-Asset. Vorher hieß die App intern „Cards", öffentlich aber + „Wordeck". +- **Archive-Polish** — Versions-Sync, Orientations, Mac-Icon. +- **Sendable-Warnings**: PhotosPicker zog Sub-View struct (Sendable- + Compliance für `PHPickerViewController`). +- **ITMS-90129** (Apple-Validation): DisplayName Cards → Wordeck in + Info.plist, Build 2. +- **User-facing Strings Cards → Wordeck** komplett, Build 3. +- **PublicDeckOwner.pseudonym ist Bool**, nicht `String?` — + Wire-Format-Drift gegen Server-Schema gefangen. +- **Card-Liste im DeckDetailView + listCards-API.** Vorher zeigte + Detail nur Metadaten. +- **ManaCore v1.0.1 pull** — 404 auf Query-Endpoints (URL-Encoding + von `?`, siehe mana-swift-core Tag 2). +- **Wordeck-Web-Design — Fan-Stack-Tiles + CardSurface.** Tiles + fächern Karten auf, einheitlicher Card-Frame über Front/Back/ + Cloze. +- **Multiple-Choice-Karten rendern.** UI-Render-Layer für MC, Server + liefert Choices + correctIndex. +- **Typing-Karten + Levenshtein-Match.** Antwort-Vergleich verzeiht + kleine Tippfehler (Edit-Distance ≤ 2). +- **DEBUG-Auto-Login** wie in Memoro-Native — schneller Iteration. +- **Tile-Redesign — Tap=Study, Edit-Icon, Explore-Konsistenz.** + Vorher war Tap = Edit, was niemandes Erwartung war. +- **iOS-26 `tabViewBottomAccessory` für „Neues Deck"-Pille.** Nutzt + den neuen iOS-26-API. +- **code:nil bei AuthError.serverError**-Fix in API-Wrapper. +- **ManaAuthUI-Migration** — alle Auth-Surfaces (Login/Signup/Verify/ + Reset/Account/Delete) auf das frische `mana-swift-ui`-Package. + Eigene Views weg. +- **Guest-Mode + Login-optionale Surface.** Public-Marketplace-Decks + + Read-only Study ohne Account. ManaAuthGate eskaliert pro Aktion. +- **γ-1 bis γ-8** — AI-Import-Sheet (Text-Prompt → mana-llm-Deck- + Generator), CSV-Import (Header-Parser + Map-Field), Card-Edit- + Sheet inkl. Image/Audio, Pull-Update (subscribed deck → new + version), Marketplace-Publish (Pseudonym oder echter Name), + Moderation-Surface (Report-Reason-Picker), PDF-Export + (PDFKit-Renderer). **3 412 / −435 LoC** in einem Commit — viel + generierter Sheet-Code, viel Wire-Format-Codable. +- **SwiftLint-Config + 0-Warnings-Pass + Swift-6-Concurrency-Fixes.** + CI-Bremse weg. + +## Architektur-Entscheidungen + +- **Auth-UI extern**, nicht selbst gebaut. Migration auf ManaAuthUI + (mana-swift-ui v0.1+) sparte ~600 LoC eigene Auth-Views und + zwang die anderen Apps in dieselbe Optik. +- **Guest-Mode als eigene Surface-Schicht**, nicht „signed-out". + Marketplace + Public-Study laufen ohne Account, ManaAuthGate + eskaliert pro Aktion (z.B. „Deck speichern"). +- **Levenshtein für Typing-Match.** Edit-Distance ≤ 2 verzeiht + Tippfehler ohne triviale Lösungen zu akzeptieren. Server kennt + diese Logik nicht (Web hat sie ebenfalls clientseitig). +- **Card-Surface als shared View**, statt 7× Custom-Layout. Ein + Frame, in den 7 Content-Types einsteigen. +- **iOS-26-Features als progressive enhancement** — + `tabViewBottomAccessory` ist nur ab iOS 26 verfügbar. + `#available`-Gate. +- **PDF-Export client-side** mit PDFKit, nicht Server. Server- + Render würde mana-pdf-Service nötig machen; client-Side ist + einfacher und privater. +- **DEBUG-Auto-Login-Pattern** aus Memoro übernommen — Iteration + ist 10× schneller wenn der Login-Step weg ist. + +## Trade-offs + +- **22 Commits in einer Session** ist viele Sinn-Abschnitte. Jeder + einzeln klein genug, dass `git log` lesbar bleibt. PR-Review + würde leiden, aber als Solo-Sprint OK. +- **AI-Import braucht mana-llm online.** Kein Offline-Fallback. +- **PublicDeckOwner.pseudonym=Bool** ist Web-Schema-Verschwurbel + (Server liefert `Boolean ‚use_pseudonym'`, nicht den Pseudonym- + String — der ist anderswo). Hatte 30 Min Fehlersuche, weil Codable + silent failed. +- **Web gewinnt bei Konflikt** zwingt Native zu nachzuziehen. Wenn + Web vorab Refactor macht, hat Native immer 1-Tag-Delay. +- **iOS-26-only-Features** für Sub-Surfaces ist Verstoss gegen + „iOS 18 Minimum"-Politik aus CLAUDE.md. Akzeptiert via Gate. + +## Offene Punkte + +- **Endurance-Test echtes iPhone**: ReviewQueue-Drift bei langem + Offline, Battery-Drain, Memory unter Pull-Update. +- **PDF-Export-Quality** für Image-Occlusion-Karten — Maske wird + zurzeit ohne Pencil-Trace gerendert. +- **AI-Import-Token-Budget** sichtbar machen (Credits-Anzeige). +- **Wire-Format-Drift-Detection**: Pseudonym-Bool-Fall war + silent-failure; sollte ein zod-Schema-Mismatch-Smoke-Test + fangen. +- **Localizable.xcstrings EN-Fallback** angelegt, aber nicht + übersetzt. diff --git a/devlog/2026-05-13/spieler.md b/devlog/2026-05-13/spieler.md new file mode 100644 index 0000000..1d7798e --- /dev/null +++ b/devlog/2026-05-13/spieler.md @@ -0,0 +1,50 @@ +--- +date: 2026-05-13 +day: 2 +view: spieler +weekday: Mittwoch +commits: 22 +review: written +--- +# Mittwoch, 2026-05-13 — Tag 2 + +Wordeck-Native sieht heute aus wie Wordeck — Karten-Fan auf dem +Dashboard, Multiple-Choice und Tippen werden ordentlich dargestellt, +und du kannst die App jetzt auch nutzen, **ohne dich anzumelden**: +Decks aus dem Marketplace anschauen, sogar lernen — Konto brauchst +du erst, wenn du etwas speichern willst. + +## Was sich für dich ändert + +- **Optik wie im Web** — die Tiles auf dem Dashboard fächern Karten + wie ein Kartenspiel auf, die Vorder-/Rückseiten werden so gerahmt + wie auf wordeck.com. Wer Web und App nebeneinander hat, + findet sich sofort zurecht. +- **Multiple-Choice + Tippen funktionieren** — beide Karten-Typen + werden mit den passenden Eingaben dargestellt (Auswahl mit Buttons, + Tippen mit Antwort-Vergleich, der kleine Tippfehler verzeiht). +- **Gast-Modus** — du kannst Decks aus dem öffentlichen Marketplace + ansehen, ohne dich vorher anzumelden. Wenn du eigene Decks + speichern oder Fortschritt mitnehmen willst, kommst du an die + Anmelde-Wand. +- **Vollständige Anmelde-Reise nativ** — Registrieren, E-Mail- + bestätigen, Passwort vergessen, Konto-Verwalten, Konto löschen + funktionieren alle direkt in der App. +- **Deck-Detail mit Karten-Liste** — du siehst nicht nur den Titel, + sondern auch alle Karten im Deck. +- **„Neues Deck"-Pille** unten am Tab-Bar — schnelles Anlegen ohne + Umwege. +- **Edit-Icon auf Tiles** trennt „Tap = Lernen" von „Edit = Bearbeiten". +- **Import aus KI** — du kannst aus einem Text-Beschreibungs-Prompt + ein neues Deck erzeugen lassen. +- **Import aus CSV** für vorhandene Karten-Sammlungen. +- **Marketplace-Publish** — eigenes Deck (mit deinem Pseudonym oder + echtem Namen) öffentlich machen. +- **Moderation** für veröffentlichte Decks (Meldung, Take-Down). +- **PDF-Export** eines Decks für Offline-Ausdruck. + +## Hintergrund + +Wordeck-Native ist heute funktional fertig — alles, was die Web- +App kann, geht jetzt auch hier. Was fehlt, sind Endurance-Tests am +echten Gerät und der App-Store-Slot bei Apple. diff --git a/devlog/2026-05-14/data.json b/devlog/2026-05-14/data.json new file mode 100644 index 0000000..9f372d6 --- /dev/null +++ b/devlog/2026-05-14/data.json @@ -0,0 +1,61 @@ +{ + "date": "2026-05-14", + "day_number": 3, + "weekday": "Donnerstag", + "commits": 1, + "authors": [ + { + "name": "Till JS", + "count": 1 + } + ], + "additions": 6, + "deletions": 10, + "net_lines": -4, + "files_changed": 2, + "new_files": 0, + "deleted_files": 0, + "session": { + "first_commit_at": "2026-05-14T19:31:49.000Z", + "last_commit_at": "2026-05-14T19:31:49.000Z", + "total_span_minutes": 0, + "active_minutes": 0, + "pauses": [], + "longest_focus_minutes": 0 + }, + "top_dirs": [ + { + "path": "Sources/Features/Decks", + "pct": 50 + }, + { + "path": "Sources/Features/Marketplace", + "pct": 50 + } + ], + "top_extensions": [ + { + "ext": ".swift", + "count": 2 + } + ], + "tags": [ + "decks" + ], + "commits_list": [ + { + "hash": "8280d3d", + "short": "style(decks): inline title + scroll-transition entfernt für ruhigeren Look", + "type": "style", + "scope": "decks", + "additions": 6, + "deletions": 10, + "timestamp": "2026-05-14T21:31:49+02:00" + } + ], + "review_state": "auto", + "llm": { + "model": null, + "generated_at": null + } +} diff --git a/devlog/2026-05-14/macher.md b/devlog/2026-05-14/macher.md new file mode 100644 index 0000000..53350bd --- /dev/null +++ b/devlog/2026-05-14/macher.md @@ -0,0 +1,41 @@ +--- +date: 2026-05-14 +day: 3 +view: macher +weekday: Donnerstag +commits: 1 +review: written +--- +# Donnerstag, 2026-05-14 — Tag 3 (Macher-Sicht) + +Single-Commit-Tag: Style-Polish auf Decks- und Marketplace-Liste. +Inline-Title statt Large-Title-Shrink, scroll-transition entfernt. + +## Stats + +1 Commit, +6 / −10 LoC, 2 Files (Decks-List + Marketplace-List). +Tag: `decks`. Net −4 LoC — Code-Reduktion durch Entfernen, nicht +durch Refactor. + +## Was passierte + +- **Inline NavigationBar-Title** statt Large-Title-with-Shrink. + Large-Title sah auf iPad-Sidebar seltsam aus (Title schrumpft + bei Scroll, aber Sidebar hat keinen Scroll-Bezug). +- **scroll-transition entfernt** — die Tile-Karten hatten einen + `.scrollTransition`-Effekt, der beim Scrollen Karten am Rand + scalierte und blurte. Auf 60 Hz-iPhone OK, auf 120 Hz-iPad + Pro überanimiert, auf Mac wirr. + +## Architektur-Bemerkung + +CardSurface bleibt unverändert — der Effekt war nur in der List- +View-Wrapper-Schicht. Die einzelnen Cards animieren weiterhin +(Flip, Reveal), nur das Scroll-Drumherum nicht mehr. + +## Offene Punkte + +- **iPad-Split-Layout** könnte noch ein Polish-Pass — Sidebar + + Detail-Pane, statt iPhone-Stack adapted. +- **macOS-NavigationSplitView** im Cards-Stil (vergleiche + nutriphi-native ν-12). diff --git a/devlog/2026-05-14/spieler.md b/devlog/2026-05-14/spieler.md new file mode 100644 index 0000000..1a17b3d --- /dev/null +++ b/devlog/2026-05-14/spieler.md @@ -0,0 +1,18 @@ +--- +date: 2026-05-14 +day: 3 +view: spieler +weekday: Donnerstag +commits: 1 +review: written +--- +# Donnerstag, 2026-05-14 — Tag 3 + +Kleiner visueller Schliff: die Deck-Listen wirken jetzt **ruhiger +beim Scrollen**. Vorher gab es einen Schrumpf-Effekt am oberen Rand, +der eher abgelenkt als geholfen hat. + +## Hintergrund + +Manchmal sind UI-Animationen schön gemeint und stören doch. Eine +weniger ist eine weniger. diff --git a/docs/MARKETING_COPY.md b/docs/MARKETING_COPY.md index 8b493ef..7eb9a69 100644 --- a/docs/MARKETING_COPY.md +++ b/docs/MARKETING_COPY.md @@ -1,4 +1,4 @@ -# MARKETING_COPY — cards-native +# MARKETING_COPY — wordeck-native Vorschläge für App-Store-Description (de + en). Zum Eintragen in App-Store-Connect. **Nicht final** — vor Submission durch dich @@ -7,7 +7,7 @@ gegenlesen, Tonalität an Vereins-Stil schärfen (siehe `mana/docs/BRAND.md`, ## Name + Subtitle -**App-Name:** `Cards` +**App-Name:** `Wordeck` **Subtitle (max 30 Zeichen):** `Karteikarten — mana e.V.` ## Keywords (max 100 Zeichen, comma-separated) @@ -21,7 +21,7 @@ Karteikarten,Spaced Repetition,Lernen,Vokabeln,Anki,Flashcards,FSRS,mana,Verein, ## Description — DE (max 4000 Zeichen) ``` -Cards ist die Karteikarten-App des Vereins mana e.V. — Spaced +Wordeck ist die Karteikarten-App des Vereins mana e.V. — Spaced Repetition wie es sein soll: ohne Werbung, ohne Tracking, ohne Abo-Zwang. @@ -33,13 +33,13 @@ KARTEN, WIE DU SIE BRAUCHST - Audio-Karten für Sprachen und Aussprache MODERNER LERN-ALGORITHMUS -Cards nutzt FSRS (Free Spaced Repetition Scheduler), den genauesten +Wordeck nutzt FSRS (Free Spaced Repetition Scheduler), den genauesten offenen Algorithmus für Karteikarten. Karten kommen wieder, wenn du sie wirklich brauchst — nicht nach willkürlichen Intervallen. OFFLINE LERNEN Karten cachen lokal auf deinem Gerät. Im Flugzeug, in der U-Bahn, -ohne WLAN — Cards funktioniert. Deine Bewertungen werden gequeued +ohne WLAN — Wordeck funktioniert. Deine Bewertungen werden gequeued und beim nächsten Online-Moment automatisch hochgeladen. WIDGETS UND NOTIFICATIONS @@ -48,12 +48,12 @@ oder Lock-Screen. Erinnerung zur Lieblings-Lern-Uhrzeit — lokal, ohne externe Push-Server. CARDECKY — MARKETPLACE FÜR LERN-DECKS -Stöbere durch öffentliche Decks aus der Cardecky-Community: Geografie, +Stöbere durch öffentliche Decks aus der Wordeck-Community: Geografie, Sprachen, Wissenschaft. Abonnieren = Karten landen direkt in deiner Bibliothek, du kannst sie nach Belieben anpassen. VEREIN, NICHT FIRMA -Cards wird vom Verein mana e.V. (Schweiz, in Gründung) betrieben. +Wordeck wird vom Verein mana e.V. (Schweiz, in Gründung) betrieben. Kein Tracking, kein Werbe-Anbieter, kein Crash-Reporter. Wenn du dem Verein etwas zurückgeben willst: mana-ev.ch/spende. @@ -71,7 +71,7 @@ selbst. ## Description — EN (max 4000 Zeichen) ``` -Cards is the flashcard app from mana e.V. — spaced repetition the +Wordeck is the flashcard app from mana e.V. — spaced repetition the way it should be: no ads, no tracking, no subscription pressure. THE CARDS YOU NEED @@ -80,12 +80,12 @@ THE CARDS YOU NEED - Audio cards for languages and pronunciation MODERN SPACED-REPETITION -Cards uses FSRS (Free Spaced Repetition Scheduler), the most accurate -open algorithm available. Cards come back when you actually need them. +Wordeck uses FSRS (Free Spaced Repetition Scheduler), the most accurate +open algorithm available. Your cards come back when you actually need them. OFFLINE-FIRST Decks cache locally on your device. On a plane, in the subway, off -the grid — Cards keeps working. Your reviews queue up and sync on +the grid — Wordeck keeps working. Your reviews queue up and sync on the next online moment. WIDGETS AND REMINDERS @@ -93,11 +93,11 @@ See today's due cards on your Home Screen or Lock Screen. Daily reminder at your preferred time — local, no push servers. CARDECKY MARKETPLACE -Browse public decks from the Cardecky community: geography, languages, +Browse public decks from the Wordeck community: geography, languages, science. Subscribe = decks land in your library, ready to be edited. ASSOCIATION, NOT CORPORATION -Cards is operated by mana e.V. (Switzerland, formation in progress). +Wordeck is operated by mana e.V. (Switzerland, formation in progress). No tracking, no ad networks, no crash reporters. Want to give back? mana-ev.ch/donate. @@ -125,9 +125,9 @@ keine `Diagnostics`, keine `Location`, keine `Contacts`. ## Privacy-Policy- + Support-URLs Vor Submission setzen — vermutlich: -- Privacy-Policy: `https://cardecky.mana.how/privacy` (existiert? prüfen) -- Support: `https://cardecky.mana.how/help` oder `kontakt@mana-ev.ch` -- Marketing: `https://cardecky.mana.how` +- Privacy-Policy: `https://wordeck.com/privacy` (existiert? prüfen) +- Support: `https://wordeck.com/help` oder `kontakt@mana-ev.ch` +- Marketing: `https://wordeck.com` Falls die URLs noch nicht live sind, vor Submission in cards-web ergänzen (Routes `/privacy`, `/help`). diff --git a/docs/OFFLINE_SYNC.md b/docs/OFFLINE_SYNC.md new file mode 100644 index 0000000..d6eb42c --- /dev/null +++ b/docs/OFFLINE_SYNC.md @@ -0,0 +1,244 @@ +# Offline-Sync — wordeck-native + +> **Status:** Konzept-Draft (2026-05-18). Implementierung als Phase +> ζ-1 / ζ-2 in `PLAN.md` geplant, noch nicht begonnen. + +## Ziel + +Alle Decks des Users — **eigene + abonnierte Marketplace-Forks** — +sollen automatisch beim Login / App-Foreground gecacht werden, so +dass der komplette **„Heute fällige Karten lernen"**-Pfad ohne Netz +funktioniert. Grades laufen wie heute über die `GradeQueue` und +drainen beim Reconnect. + +## Warum jetzt einfach + +Mit dem Wordeck-Text-Only-Rebrand (2026-05-17) sind Bilder und +Audio aus dem Schema raus. Eine Karte ist jetzt nur noch +`{type, fields: [String:String], deck_id, …}` — pure Text. Damit +ist die komplette Offline-Payload **JSON-only**: + +| Bestandteil | Größe pro Eintrag | Bei 10 000 Karten | +|---|---:|---:| +| Card-Record (text-only) | ≈ 300 B JSON | ≈ 3 MB | +| Review-Snapshot (FSRS-State) | ≈ 150 B | ≈ 1,5 MB | +| Distractor-Pool (nur MC, ≈ 10/Karte) | ≈ 500 B | ≈ 0,5 MB (pro MC-Karte) | + +Selbst Power-User mit 50 Decks und 5 000 Karten landen unter +**5 MB** Total-Footprint. SwiftData verkraftet das mit Links. + +## Server-Invariante bleibt + +**FSRS rechnet weiterhin nur am Server.** Lokales FSRS bleibt +verboten (CLAUDE.md §1). Der Offline-Modus ist ein **Snapshot- +Modell**: der Client lernt das, was der Server beim letzten Sync +als „due" markiert hat, schickt Grades hinterher, holt nach Sync +einen frischen Snapshot. Mehr ist nicht erlaubt. + +## Architektur + +``` +┌────────────────────────────────────────────────────────────┐ +│ DeckListStore.refresh() │ +│ │ +│ GET /decks ──┐ │ +│ ├── TaskGroup ── per Deck ──┬── listCards() │ +│ │ ├── dueReviews()│ +│ │ └── distractors │ +│ │ │ +│ ▼ │ +│ SwiftData-Persistenz │ +│ ┌─────────────┐ ┌────────────┐ ┌──────────────┐ │ +│ │ CachedDeck │ │ CachedCard │ │ CachedDue │ │ +│ │ │ │ │ │ Review │ │ +│ │ (heute) │ │ (neu) │ │ (neu) │ │ +│ └─────────────┘ └────────────┘ └──────────────┘ │ +│ │ +└────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌────────────────────────────────────────────┐ + │ StudySession.start() │ + │ │ + │ try: api.dueReviews(deckId) │ + │ fall: CachedDueReview (deckId) │ + │ │ + │ grade → GradeQueue (PendingGrade) │ + │ │ + └────────────────────────────────────────────┘ +``` + +## Daten-Modelle (neu) + +```swift +@Model +final class CachedCard { + @Attribute(.unique) var id: String // card_id + var deckId: String + var userId: String + var typeRaw: String // CardType.rawValue + var fields: [String: String] // pures JSON-Field-Bag + var contentHash: String? + var createdAt: Date + var updatedAt: Date + var lastFetchedAt: Date + + // Multiple-Choice-Pool für Offline-Rendering. + // Leer für non-MC-Karten. + var distractorPool: [String] = [] +} + +@Model +final class CachedDueReview { + @Attribute(.unique) var compoundId: String // "\(cardId)-\(subIndex)" + var cardId: String + var subIndex: Int + var deckId: String // Index für StudySession-Lookup + var due: Date // Server-berechnet + var stability: Double + var difficulty: Double + var stateRaw: String // ReviewState + var lastReview: Date? + var snapshottedAt: Date // wann gepullt +} +``` + +## API-Endpoints (vorhanden, kein Server-Change nötig) + +| Endpoint | Verwendung | Limit | +|---|---|---| +| `GET /api/v1/cards?deck_id=X` | komplette Card-Liste pro Deck | **kein Limit** | +| `GET /api/v1/reviews/due?deck_id=X&limit=500` | due-Snapshot | **500** ⚠ | +| `GET /api/v1/decks/:deckId/distractors?card_id=Y&field=back&count=10` | MC-Pool | 10 | + +## Sync-Algorithmus (`DeckListStore.refresh()` erweitert) + +``` +1. GET /decks → remoteDecks +2. Diff Cache ↔ remoteDecks, gelöschte Decks aus Cache entfernen +3. Für jedes Deck parallel (TaskGroup): + a. listCards(deckId) → in CachedCard upserten + b. dueReviews(deckId, limit: 500) → CachedDueReview ersetzen + (nicht mergen — Snapshot überschreibt komplett, weil due-Zeiten + sich serverseitig ändern können) + c. Für jede MC-Karte: distractors(deckId, cardId, count: 10) → + CachedCard.distractorPool +4. WidgetSnapshot updaten (heute schon, bleibt) +``` + +## StudySession-Anpassung + +```swift +func start() async { + phase = .loading + do { + queue = try await api.dueReviews(deckId: deckId, limit: 500) + // ... wie heute + } catch { + // Netz-Fehler → Cache befragen + queue = loadFromCache(deckId: deckId) + if queue.isEmpty { + phase = .failed("Kein Netz und keine gecachten Karten.") + } else { + Log.study.notice("Offline-Mode: \(queue.count) cached due reviews") + phase = .studying + isOfflineSession = true + } + } +} +``` + +Beim Grade-Submit ändert sich nichts: `GradeQueue.submit()` +persistiert eh erst lokal und drained später. Das funktioniert +heute schon offline. + +## Trigger + +| Wann | Was | +|---|---| +| App-Foreground / Login | `DeckListStore.refresh()` (heute) → erweitert auf Card+Review+Distractor-Sync | +| Pull-to-Refresh in `DeckListView` | dasselbe | +| Nach `subscribe(slug:)` im Marketplace | direkt `refresh()` aufrufen, damit das frisch abonnierte Deck sofort komplett gecacht ist | +| `BGAppRefreshTask` (alle ~12 h, optional, β-7-Polish) | Drain Grade-Queue + Refresh; nur wenn `wifi_only=true` erlaubt oder User hat Mobile-Sync aktiv | + +## Settings (in `SettingsView`) + +- **Auto-Sync** (Default: an) — schaltet Card/Review-Prefetch ein/aus +- **Background-Refresh** (Default: aus) — `BGAppRefreshTask` +- **Cache-Footprint anzeigen** — „17 Decks, 1 234 Karten, 4,2 MB" +- **Cache leeren** — Wipe aller `CachedCard` + `CachedDueReview`, + `CachedDeck` bleibt (sonst Deck-Liste leer) + +## Phasen + +| Phase | Inhalt | Aufwand | +|---|---|---| +| **ζ-1** | `CachedCard` + Sync in `DeckListStore`, `StudySession`-Cache-Fallback | 1 Tag | +| **ζ-2** | `CachedDueReview` + Distractor-Pool für MC-Karten | 0,5 Tag | +| **ζ-3** | `SettingsView`-Footprint + Cache-Clear | 0,5 Tag | +| **ζ-4** (optional) | `BGAppRefreshTask`, Wi-Fi-Only-Toggle | 0,5 Tag | + +Endurance-Pflicht (siehe `PLAN.md`): 200+ Karten offline lernen, +Flugmodus, alle Grades landen nach Reconnect am Server, Cross- +Check mit Web-Review-State. + +## Offene Punkte + +- 🛑 **`dueReviews(limit: 500)` ist hardcoded — Decks > 500 Karten + haben einen stillen Cap.** Wenn ein Marketplace-Deck mehr als + 500 fällige Karten hat (passiert bei frischen Abos), bekommt + der Client offline nur die ersten 500. Optionen: + - (a) Pagination einbauen (`offset=…`) und mehrere Calls + chainen — billig. + - (b) Server-Endpoint `/api/v1/reviews/due-all?deck_id=X` der + paginiert in einer Response liefert — sauberer, braucht + Backend-PR. + - (c) Aktzeptieren, Banner „Sync unvollständig — weitere + Karten erst nach Online-Refresh". + Vorschlag: **(a)** zunächst, Schwelle im Snapshot loggen. +- 🛑 **Distractor-Pool drifted, wenn der User Karten löscht.** + Ein Pool von 10 Distractors zur Sync-Zeit kann nach Lösch- + Aktionen Treffer in der Liste haben, die offline nicht mehr + existieren. Akzeptabel, weil MC-Distractors ohnehin + „Fülltext" sind — Reveal-Korrekt-Highlight kommt vom + `answer`-Feld der Karte, nicht aus dem Pool. +- 🛑 **„Mehr Karten als der Snapshot enthält"** — wenn User + offline alle 100 fälligen Karten durchgelernt hat und weiter + klickt, gibt es keinen lokalen Weg, „nächste fällige Karte" zu + bestimmen. UX-Honest: am Ende der Session Banner zeigen + („Weitere Karten erst nach Verbindung verfügbar"), Server- + authoritative-FSRS bleibt damit intakt. +- 🛑 **SwiftData-Migration.** Schema-Update von Build 11 → ζ-1 + legt zwei neue `@Model`-Klassen an. Bei In-Place-Upgrade von + TestFlight-Buildern muss der `ModelContainer` mit + `MigrationPlan` versorgt werden — sonst Crash beim ersten Start + nach Update. Wir haben das vorher noch nicht gebraucht; für ζ-1 + Pflicht-Aufgabe vor Submit. +- 🛑 **Cache-Invalidierung bei Cross-Device-Edits.** User editiert + Karte auf Web → Native zeigt offline noch alte Version, bis der + nächste Refresh läuft. Heute akzeptabel — `updatedAt`-Vergleich + beim Sync wirft die alte Version raus. Wenn das in der Praxis + weh tut, kann später ein Web-Push-Hook auf `card.updated` + einen Targeted-Refresh triggern (nicht ζ-Scope). +- 🛑 **Logout = Cache-Wipe.** Bei Sign-out alle `CachedCard` + + `CachedDueReview` löschen. Heute macht `auth.signOut()` das + nicht. Muss in ζ-1 mit rein. + +## Was *nicht* in ζ kommt + +- **Lokales FSRS-Berechnen** — verboten per CLAUDE.md §1. +- **Offline-Card-Create** — Editor bleibt online-only. Drafting + ohne Netz wäre nett, hat aber Konflikt-Auflösung als Folge- + Problem. Aufgeschoben bis nach v1. +- **Media-Prefetch** — gegenstandslos seit Wordeck-Rebrand + (text-only). + +## Cross-Refs + +- `CLAUDE.md` — Architektur-Invarianten (§1 FSRS, §2 Offline-Read) +- `PLAN.md` — Phasen-Stand +- `../mana/docs/playbooks/WORDECK_REBRAND.md` — Text-Only-Cut +- `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md` — Greenfield-SOT +- `../wordeck/apps/api/src/routes/cards.ts` — `GET /cards?deck_id` +- `../wordeck/apps/api/src/routes/reviews.ts` — `GET /reviews/due` +- `../wordeck/apps/api/src/routes/decks.ts` — `/distractors` diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index f4f7fe4..5d433c0 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -1,4 +1,4 @@ -# RELEASE_CHECKLIST — cards-native +# RELEASE_CHECKLIST — wordeck-native Externe Schritte vor App-Store-Submission. Alles unter dieser Sektion läuft NICHT durch das Repo — sondern durch das Apple- @@ -11,14 +11,14 @@ AASA) und über Xcode (für Build + Sign). - [x] **Team-ID gesetzt** (`QP3GLU8PH3`, mana e.V.) — `DEVELOPMENT_TEAM` in `project.yml > settings > base`. Greift bei Archive automatisch. -- [ ] **App-ID `ev.mana.cardecky`** im Developer-Portal anlegen, falls +- [ ] **App-ID `ev.mana.wordeck`** im Developer-Portal anlegen, falls noch nicht da. Mit Capabilities: App Groups, Keychain Sharing, Associated Domains. -- [ ] **App-ID `ev.mana.cardecky.share`** + **`ev.mana.cardecky.widget`** für +- [ ] **App-ID `ev.mana.wordeck.share`** + **`ev.mana.wordeck.widget`** für die Extensions analog anlegen, ebenfalls mit App Groups. -- [ ] **App-Group `group.ev.mana.cardecky`** im Portal anlegen und allen +- [ ] **App-Group `group.ev.mana.wordeck`** im Portal anlegen und allen drei App-IDs zuweisen. -- [ ] **Keychain-Access-Group**: heute `ev.mana.cardecky`. Wenn +- [ ] **Keychain-Access-Group**: heute `ev.mana.wordeck`. Wenn Shared-Keychain mit `memoro-native` gewünscht (siehe `mana/docs/MANA_SWIFT.md` Phase γ), auf `$(AppIdentifierPrefix)ev.mana.shared` umstellen und @@ -40,7 +40,7 @@ AASA) und über Xcode (für Build + Sign). ### Server-seitige Vorbedingungen -- [x] **AASA-Endpoint** auf `cardecky.mana.how/.well-known/apple-app-site-association` +- [x] **AASA-Endpoint** auf `wordeck.com/.well-known/apple-app-site-association` — SvelteKit-Server-Route gebaut in `cards/apps/web/src/routes/.well-known/apple-app-site-association/+server.ts` (2026-05-13). Content-Type `application/json`, paths `/d/*` und @@ -50,11 +50,11 @@ AASA) und über Xcode (für Build + Sign). (Commit folgt). Wird zur Runtime von `$env/dynamic/public` aufgelöst und in den AASA-Response geschrieben. - [x] **Production-Deploy von cards-web** durchgeführt 2026-05-13. - Probe von außen: `curl https://cardecky.mana.how/.well-known/apple-app-site-association` - liefert `application/json` mit `"appID":"QP3GLU8PH3.ev.mana.cardecky"`. + Probe von außen: `curl https://wordeck.com/.well-known/apple-app-site-association` + liefert `application/json` mit `"appID":"QP3GLU8PH3.ev.mana.wordeck"`. Cloudflare-Tunnel reicht den Endpoint sauber durch (kein HTML-Captive, kein Redirect). -- [ ] **cardecky-api.mana.how** muss erreichbar bleiben — die App +- [ ] **api.wordeck.com** muss erreichbar bleiben — die App ist 100% Online-write. Health-Probe verifizieren. ### Build + Archive @@ -70,7 +70,7 @@ AASA) und über Xcode (für Build + Sign). - [ ] **Cross-Device:** Web↔Native parallel. Karte gegrade in App → Web zeigt nach Reload identischen Review-State. - [ ] **Widget:** ans Home-Screen pinnen, Due-Count nach App-Refresh. -- [ ] **Universal-Link:** `https://cardecky.mana.how/d/` in +- [ ] **Universal-Link:** `https://wordeck.com/d/` in Safari öffnen → App startet auf Explore-Tab + Public-Deck-Detail. - [ ] **Share-Extension:** Text in Safari markieren → Teilen → "Als Karte speichern" → Karte landet in der App. @@ -86,9 +86,9 @@ AASA) und über Xcode (für Build + Sign). ### App-Store-Connect - [ ] **App-Eintrag erstellen** unter https://appstoreconnect.apple.com - mit Bundle-ID `ev.mana.cardecky`. + mit Bundle-ID `ev.mana.wordeck`. - [ ] **App-Name** + **Subtitle** (max 30 Zeichen): - - Name: "Cards" + - Name: "Wordeck" - Subtitle: "Karteikarten — Verein mana" - [ ] **Description** (de + en, max 4000 Zeichen). Vorschlag in [`docs/MARKETING_COPY.md`](MARKETING_COPY.md) — vor Submission @@ -97,10 +97,11 @@ AASA) und über Xcode (für Build + Sign). "Karteikarten,Spaced Repetition,Lernen,Vokabeln,Anki,Flashcards,FSRS,mana,Verein,Open Source" - [ ] **Screenshots** für iPhone 16 Pro Max + iPhone SE-3 + iPad Pro. 6.7", 6.5", 5.5", iPad 12.9" — siehe Apple's Specs. -- [ ] **Privacy-Policy-URL** — vermutlich `cardecky.mana.how/privacy` - oder `mana-ev.ch/privacy`. **Verifizieren.** -- [ ] **Support-URL** — `cardecky.mana.how/help` oder Verein-Kontakt. -- [ ] **Marketing-URL** (optional) — `cardecky.mana.how`. +- [x] **Privacy-Policy-URL**: `https://wordeck.com/privacy` (live + seit 2026-05-13, SvelteKit-Route mit Verein-Content). +- [x] **Support-URL**: `https://wordeck.com/help` (live, FAQ + + Kontakt-Email kontakt@mana-ev.ch). +- [ ] **Marketing-URL** (optional) — `wordeck.com`. - [ ] **Age-Rating**: vermutlich 4+ (no objectionable content). - [ ] **Pricing**: Free. - [ ] **App-Privacy** (Data Type Declaration): @@ -123,7 +124,7 @@ AASA) und über Xcode (für Build + Sign). ### Hub-App vs Standalone-App (siehe MANA_SWIFT.md) -- [x] Entschieden: separate Apps. memoro-native und cards-native sind +- [x] Entschieden: separate Apps. memoro-native und wordeck-native sind eigenständige App-Store-Einträge. Keine Hub-App. ## Carryover-Tasks (β-6 / β-7-Reste) @@ -143,7 +144,7 @@ AASA) und über Xcode (für Build + Sign). ## Nach Submission -- [ ] **Monitoring**: nach Release `cardecky-api.mana.how/healthz` und +- [ ] **Monitoring**: nach Release `api.wordeck.com/healthz` und Rate-Limit-Auslastung beobachten — Native-App kann Last-Spitzen erzeugen. - [ ] **DSGVO-Endpoint** (`/api/v1/dsgvo/export`, `/delete`) testweise diff --git a/project.yml b/project.yml index a2879d6..4293d80 100644 --- a/project.yml +++ b/project.yml @@ -1,8 +1,9 @@ -name: CardsNative +name: WordeckNative options: bundleIdPrefix: ev.mana createIntermediateGroups: true + xcodeVersion: "16.0" deploymentTarget: iOS: "18.0" macOS: "15.0" @@ -13,24 +14,26 @@ options: packages: ManaSwiftCore: path: ../mana-swift-core + ManaSwiftUI: + path: ../mana-swift-ui settings: base: SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete CURRENT_PROJECT_VERSION: "1" - MARKETING_VERSION: "0.1.0" + MARKETING_VERSION: "1.0.0" GENERATE_INFOPLIST_FILE: "NO" ENABLE_USER_SCRIPT_SANDBOXING: "YES" DEAD_CODE_STRIPPING: "YES" CLANG_ENABLE_MODULES: "YES" # mana e.V. Apple-Developer-Team-ID. Muss matchen mit dem AASA- - # Eintrag auf https://cardecky.mana.how/.well-known/apple-app-site-association + # Eintrag auf https://wordeck.com/.well-known/apple-app-site-association # (siehe cards/infrastructure/docker-compose.production.yml). DEVELOPMENT_TEAM: QP3GLU8PH3 targets: - CardsNative: + WordeckNative: type: application supportedDestinations: [iOS, macOS] dependencies: @@ -38,9 +41,11 @@ targets: product: ManaCore - package: ManaSwiftCore product: ManaTokens - - target: CardsWidgetExtension + - package: ManaSwiftUI + product: ManaAuthUI + - target: WordeckWidgetExtension embed: true - - target: CardsShareExtension + - target: WordeckShareExtension embed: true sources: - path: Sources/App @@ -49,57 +54,67 @@ targets: - path: Sources/Resources excludes: - "Info.plist" - - "CardsNative.entitlements" + - "WordeckNative.entitlements" info: path: Sources/Resources/Info.plist properties: - CFBundleShortVersionString: "0.1.0" + CFBundleShortVersionString: "1.0.0" CFBundleVersion: "1" CFBundleDevelopmentRegion: de - CFBundleDisplayName: Cards + CFBundleDisplayName: Wordeck LSApplicationCategoryType: "public.app-category.education" UILaunchScreen: {} + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight CFBundleURLTypes: - - CFBundleURLName: ev.mana.cardecky + - CFBundleURLName: ev.mana.wordeck CFBundleURLSchemes: - cards NSUserActivityTypes: - NSUserActivityTypeBrowsingWeb - NSPhotoLibraryUsageDescription: "Cards greift auf deine Fotos zu, damit du Bilder zu Image-Occlusion-Karten hinzufügen kannst." ITSAppUsesNonExemptEncryption: false entitlements: - path: Sources/Resources/CardsNative.entitlements + path: Sources/Resources/WordeckNative.entitlements properties: com.apple.security.app-sandbox: true com.apple.security.network.client: true com.apple.security.files.user-selected.read-write: true keychain-access-groups: - - $(AppIdentifierPrefix)ev.mana.cardecky + - $(AppIdentifierPrefix)ev.mana.session com.apple.developer.associated-domains: - - applinks:cardecky.mana.how + - applinks:wordeck.com com.apple.security.application-groups: - - group.ev.mana.cardecky + - group.ev.mana.wordeck settings: base: - PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cardecky + PRODUCT_BUNDLE_IDENTIFIER: ev.mana.wordeck CODE_SIGN_STYLE: Automatic ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor ENABLE_PREVIEWS: "YES" - CardsShareExtension: + WordeckShareExtension: type: app-extension supportedDestinations: [iOS] sources: - path: ShareExtension excludes: - "Resources/Info.plist" - - "Resources/CardsShareExtension.entitlements" + - "Resources/WordeckShareExtension.entitlements" - path: Sources/Core/Sync/PendingShareStore.swift info: path: ShareExtension/Resources/Info.plist properties: CFBundleDisplayName: Als Karte speichern + CFBundleShortVersionString: "1.0.0" + CFBundleVersion: "1" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController @@ -108,81 +123,87 @@ targets: NSExtensionActivationSupportsText: true NSExtensionActivationSupportsWebURLWithMaxCount: 1 entitlements: - path: ShareExtension/Resources/CardsShareExtension.entitlements + path: ShareExtension/Resources/WordeckShareExtension.entitlements properties: + keychain-access-groups: + - $(AppIdentifierPrefix)ev.mana.session com.apple.security.application-groups: - - group.ev.mana.cardecky + - group.ev.mana.wordeck settings: base: - PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cardecky.share + PRODUCT_BUNDLE_IDENTIFIER: ev.mana.wordeck.share CODE_SIGN_STYLE: Automatic SKIP_INSTALL: "YES" - CardsWidgetExtension: + WordeckWidgetExtension: type: app-extension supportedDestinations: [iOS] sources: - - path: Widgets/CardsWidget + - path: Widgets/WordeckWidget excludes: - "Resources/Info.plist" - - "Resources/CardsWidgetExtension.entitlements" + - "Resources/WordeckWidgetExtension.entitlements" - path: Sources/Core/Sync/WidgetSnapshot.swift info: - path: Widgets/CardsWidget/Resources/Info.plist + path: Widgets/WordeckWidget/Resources/Info.plist properties: - CFBundleDisplayName: Cards Widget + CFBundleDisplayName: Wordeck Widget + CFBundleShortVersionString: "1.0.0" + CFBundleVersion: "1" NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension entitlements: - path: Widgets/CardsWidget/Resources/CardsWidgetExtension.entitlements + path: Widgets/WordeckWidget/Resources/WordeckWidgetExtension.entitlements properties: + keychain-access-groups: + - $(AppIdentifierPrefix)ev.mana.session com.apple.security.application-groups: - - group.ev.mana.cardecky + - group.ev.mana.wordeck dependencies: - sdk: WidgetKit.framework - sdk: SwiftUI.framework settings: base: - PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cardecky.widget + PRODUCT_BUNDLE_IDENTIFIER: ev.mana.wordeck.widget CODE_SIGN_STYLE: Automatic SKIP_INSTALL: "YES" - INFOPLIST_KEY_CFBundleDisplayName: Cards Widget + INFOPLIST_KEY_CFBundleDisplayName: Wordeck Widget - CardsNativeTests: + WordeckNativeTests: type: bundle.unit-test supportedDestinations: [iOS, macOS] sources: - Tests/UnitTests dependencies: - - target: CardsNative + - target: WordeckNative settings: base: - PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cardecky.tests + PRODUCT_BUNDLE_IDENTIFIER: ev.mana.wordeck.tests GENERATE_INFOPLIST_FILE: "YES" - CardsNativeUITests: + WordeckNativeUITests: type: bundle.ui-testing supportedDestinations: [iOS, macOS] sources: - Tests/UITests dependencies: - - target: CardsNative + - target: WordeckNative settings: base: - PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cardecky.uitests + PRODUCT_BUNDLE_IDENTIFIER: ev.mana.wordeck.uitests GENERATE_INFOPLIST_FILE: "YES" schemes: - CardsNative: + WordeckNative: build: targets: - CardsNative: all - CardsNativeTests: [test] - CardsNativeUITests: [test] + WordeckNative: all + WordeckNativeTests: [test] + WordeckNativeUITests: [test] test: targets: - - CardsNativeTests - - CardsNativeUITests + - WordeckNativeTests + - WordeckNativeUITests gatherCoverageData: false run: config: Debug