commit 28b20cd934f7939fb588925138a55dfc4adaf232 Author: Till JS Date: Tue May 12 19:29:45 2026 +0200 v0.1.0 — Phase β-0 Setup Repo-Skelett für cards-native, native SwiftUI-Universal-App für Cardecky (mana e.V.). Web-Parität zu cardecky.mana.how. - project.yml mit Bundle ev.mana.cards, ManaSwiftCore-Dep via path - AppConfig: auth.mana.how + cardecky-api.mana.how, Keychain ev.mana.cards - CardsTheme: forest-Werte aus mana/packages/themes/.../forest.css - LoginView (Email/PW gegen mana-auth via ManaCore.AuthClient) - DashboardView als β-1-Placeholder mit cardecky-api-Reachability-Probe - Log unter Subsystem ev.mana.cards - 3 AppConfig-Tests - iOS-Simulator-Build grün Phasen-Plan: mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md Co-Authored-By: Claude Opus 4.7 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a4826f --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +.build/ +.swiftpm/ +DerivedData/ +Package.resolved +xcuserdata/ + +# XcodeGen output +*.xcodeproj +Sources/Resources/Info.plist +Sources/Resources/CardsNative.entitlements diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..c1b2534 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,10 @@ +--swiftversion 6.0 +--indent 4 +--maxwidth 120 +--wraparguments before-first +--wrapparameters before-first +--wrapcollections before-first +--commas inline +--semicolons never +--self remove +--importgrouping testable-bottom diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..e2f82d1 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,26 @@ +disabled_rules: + - todo + - trailing_comma + +opt_in_rules: + - empty_count + - empty_string + - explicit_init + - first_where + - sorted_first_last + - toggle_bool + +line_length: + warning: 120 + error: 160 + ignores_comments: true + +identifier_name: + min_length: 2 + excluded: + - id + - ok + +included: + - Sources + - Tests diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4513ab1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,183 @@ +# CLAUDE.md — cards-native repo + +Guidance für Claude Code in diesem Repository. + +> **Wenn du gerade neu bist:** lies zuerst [`PLAN.md`](PLAN.md) und +> `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md` (übergeordneter +> Greenfield-Plan). Dieses CLAUDE.md ist die Konventions- und +> Cross-Repo-Referenz. + +## 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- +Affordances (Widgets, Notifications, Universal-Links, Pencil). + +``` + HTTPS/JWT ┌──────────────────┐ + cards-api ◄───────────── │ cards-native │ SwiftUI + cardecky-api.mana.how │ (this repo) │ SwiftData (Cache) + │ ev.mana.cards │ WidgetKit (β-6) + └──────────────────┘ +``` + +## Status + +**Phase β-0 — Setup (2026-05-12).** Repo-Skelett, ManaCore + ManaTokens +als Package-Dependency, Login + Cardecky-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 +Konflikt zwischen Native und Web → **Web gewinnt**. Native ist +Re-Implementation, kein neues Produkt. + +Datenmodell, FSRS-Verhalten, Marketplace-Slugs, Sharing-URLs: +identisch zu Web. + +## Architektonische Invarianten + +Beschlossen. Nicht ohne explizite Diskussion antasten. + +1. **Server-authoritative FSRS.** Grading-Calls gehen *immer* an + `POST /api/v1/reviews/:cardId/:subIndex/grade`. Kein lokaler + ts-fsrs-Port. +2. **Offline-Read, Online-Write.** Decks + Due-Cards via SwiftData + gecacht (offline sichtbar). Grades werden bei Offline in einer + lokalen Queue persistiert und beim Reconnect der Reihe nach + abgesendet. +3. **mana-auth via ManaCore.** `import ManaCore`, + `AuthClient(config: AppConfig.manaAppConfig)`. Eigene + 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.cards`.** Reverse-Domain mana-ev.ch. + Universal-Link-Domain: `cardecky.mana.how`. +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 + (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 + zuerst in die Web-App, dann nach hier. + +## Konventionen + +- **Swift 6.0**, Strict Concurrency komplett +- **iOS 18 / iPadOS 18 / macOS 15** Minimum +- **SwiftUI** als einziges UI-Framework +- **XcodeGen** als SOT: `project.yml` definiert Targets, Info.plist, + Entitlements. `.xcodeproj`, generierte Info.plist und Entitlements + sind **nicht** im Git +- **SwiftFormat** mit `.swiftformat` (4-space, 120-col, sorted imports) +- **SwiftLint** mit `.swiftlint.yml` +- **Logging:** App-Subsystem `ev.mana.cards` 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 + +Wire-Format gegen `https://cardecky-api.mana.how/api/v1/*`. Quelle der +Wahrheit: `../cards/apps/api/src/routes/*.ts`. Bei neuem DTO +verifizieren: + +1. Path + Method gegen den Hono-Handler prüfen +2. Response-Schema (`zod`) gegen `Codable`-Struct mappen +3. snake_case via `CodingKeys`, Optionale Felder explizit `Optional` +4. Test-Fixture aus echtem Server-Response in `Tests/UnitTests/` + +## Repo-Layout + +``` +cards-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 +│ ├── Features/ +│ │ ├── Account/ LoginView, AccountView (ab β-1) +│ │ ├── Decks/ DashboardView (Placeholder), DeckList (β-1) +│ │ ├── Study/ (β-2) +│ │ ├── Editor/ (β-3) +│ │ ├── Marketplace/ (β-5) +│ │ ├── Stats/ (β-1) +│ │ └── Imports/ (β-3) +│ ├── Core/ +│ │ ├── Auth/ AppConfig (ManaAppConfig-Provider) +│ │ ├── API/ CardsAPI (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.cards) +│ │ └── Theme/ CardsTheme (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) +├── Tests/ +│ ├── UnitTests/ +│ └── UITests/ +└── docs/ +``` + +## Wichtige Cross-Repo-Doks + +- `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md` — vollständiger + Phasen-Plan und Architektur-Entscheidungen +- `../mana/docs/MANA_SWIFT.md` — native-Plattform-SOT +- `../mana/docs/MANA_AUTH_FEDERATION.md` — Auth-Protokoll, das + ManaCore implementiert +- `../mana/docs/COMPLIANCE.md` — Telemetrie/Auth/Bezahl-Regeln, gilt + auch nativ +- `../cards/CLAUDE.md` — Cards-Repo, Web + API +- `../cards/STATUS.md` — Web-Phasenstand (Funktions-Referenz) +- `../mana-swift-core/CLAUDE.md` — geteilter Code, Konventionen + +## Lokal entwickeln + +**Pre-Requisites:** +- Xcode 16+ +- `brew install xcodegen swiftformat swiftlint` +- `../mana-swift-core/` muss als Schwester-Verzeichnis existieren + (Package-Dependency via `path: ../mana-swift-core`) + +**Workflow:** +```bash +xcodegen generate +open CardsNative.xcodeproj +``` + +**Vor jedem Commit:** +```bash +swiftformat Sources Tests +swiftlint --strict +``` + +## Phasen-Disziplin + +Jede Phase aus dem Greenfield-Plan hat ein verifizierbares +Erfolgskriterium. Nicht in die nächste Phase reinarbeiten, bevor +die vorherige abgeschlossen ist: + +- β-0: leerer Build + Login + API-Reachability-Probe (**JETZT**) +- β-1: Decks-Liste mit SwiftData-Cache +- β-2: Study-Loop + Offline-Grade-Queue + Endurance-Test auf realem Gerät +- β-3: Card-/Deck-Editor (basic, cloze, typing, multiple-choice) +- β-4: Media + image-occlusion + audio-front +- β-5: Marketplace + Universal-Links +- β-6: Native-Polish (Widgets, Notifications, Share-Extension) +- β-7: App-Store-Submission + +Bei Phasen-Wechsel: PLAN.md aktualisieren + Greenfield-Plan abhaken. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..85659e1 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,63 @@ +# Plan — cards-native (SwiftUI Universal) + +**Stand: 2026-05-12 — Phase β-0 abgeschlossen.** Repo lebt lokal, +ManaCore + ManaTokens als Package-Dependency, Login funktioniert, +Cardecky-API-Reachability-Probe. + +> **SOT:** `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`. +> Dieses File ist die App-lokale Status-Spur, das Greenfield-Doc +> hat die ganze Architektur-Begründung. + +## Aktueller Stand + +✅ **β-0 — Setup** +- Repo-Skelett unter `git.mana.how/till/cards-native` +- `project.yml` mit Bundle-ID `ev.mana.cards`, 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.cards` +- `CardsTheme.swift` mit forest-Werten (lokal nachgebaut aus + `mana/packages/themes/src/variants/forest.css`) +- `LoginView` (Email/PW gegen mana-auth) +- `DashboardView` als β-1-Placeholder mit API-Reachability-Indikator +- 3 Unit-Tests (AppConfig) +- iOS-Simulator-Build grün + +## Phasen (Detail in Greenfield-Plan) + +| Phase | Status | Inhalt | +|---|---|---| +| β-0 | ✅ 2026-05-12 | Setup, Login, API-Probe | +| β-1 | ⏳ | Decks lesen, SwiftData-Cache | +| β-2 | — | Study-Loop, Offline-Grade-Queue, Endurance-Test | +| β-3 | — | Card-/Deck-Editor (basic, cloze, typing, multiple-choice) | +| β-4 | — | Media, image-occlusion (PencilKit), audio-front | +| β-5 | — | Marketplace, Universal-Links | +| β-6 | — | Native-Polish (Widgets, Notifications, Share-Extension) | +| β-7 | — | App-Store-Submission | + +## Nächste Schritte für β-1 + +Aus Greenfield-Plan-Sektion "Phase β-1 — Decks lesen": + +1. `Deck`-`Codable`-Struct nach Wire-Format aus + `../cards/apps/api/src/routes/decks.ts` + `cards/packages/cards-domain/src/schemas/` +2. `CardsAPI.decks() -> [Deck]` mit `GET /api/v1/decks` +3. `DeckListView` mit Pull-to-Refresh, Card/Due-Counts +4. `CachedDeck` als SwiftData-Model mit `lastFetchedAt` +5. Offline-Display bei fehlendem Netz +6. Inbox-Banner aus `?forked_from_marketplace=true`-Query + +**Erfolgskriterium:** Web-Account-Decks vollständig in identischer +Reihenfolge sichtbar, Pull-to-Refresh aktualisiert Counts. + +## Cross-Refs + +- `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md` — Greenfield-Plan SOT +- `../mana/docs/MANA_SWIFT.md` — Plattform-SOT +- `../cards/CLAUDE.md` — Cards-Repo +- `../cards/STATUS.md` — Web-Phasenstand (Referenz) +- `../mana-swift-core/CLAUDE.md` — ManaCore-Konventionen +- `CLAUDE.md` — Repo-Konventionen diff --git a/README.md b/README.md new file mode 100644 index 0000000..b62470f --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# cards-native + +Native SwiftUI-Universal-App (iOS / iPadOS / macOS) für Cardecky — +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. +> 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- +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.cards │ WidgetKit (β-6) + └──────────────────┘ + │ + ┌─────────────────────────────────────────┐ + ▼ ▼ + ManaCore (Auth, Transport) ManaTokens (Designwerte) + git.mana.how/till/mana-swift-core v1.0.0+ +``` + +## Lokal entwickeln + +```bash +xcodegen generate +open CardsNative.xcodeproj # iPhone-17-Simulator +``` + +Konventionen, Invarianten, Phasen-Disziplin: [`CLAUDE.md`](CLAUDE.md). diff --git a/Sources/App/CardsNativeApp.swift b/Sources/App/CardsNativeApp.swift new file mode 100644 index 0000000..6c146f2 --- /dev/null +++ b/Sources/App/CardsNativeApp.swift @@ -0,0 +1,22 @@ +import ManaCore +import SwiftUI + +@main +struct CardsNativeApp: App { + @State private var auth: AuthClient + + init() { + let auth = AuthClient(config: AppConfig.manaAppConfig) + auth.bootstrap() + _auth = State(initialValue: auth) + Log.app.info("Cards starting — auth status: \(String(describing: auth.status), privacy: .public)") + } + + var body: some Scene { + WindowGroup { + RootView() + .environment(auth) + .tint(CardsTheme.primary) + } + } +} diff --git a/Sources/App/RootView.swift b/Sources/App/RootView.swift new file mode 100644 index 0000000..0b225cc --- /dev/null +++ b/Sources/App/RootView.swift @@ -0,0 +1,18 @@ +import ManaCore +import SwiftUI + +/// Top-Level-Switch: Login vs Dashboard. +/// Ab Phase β-1 wird Dashboard durch eine echte Tab-Bar (Decks / Study / +/// Stats / Account) ersetzt. +struct RootView: View { + @Environment(AuthClient.self) private var auth + + var body: some View { + switch auth.status { + case .signedIn: + DashboardView() + case .unknown, .signedOut, .signingIn, .error: + LoginView() + } + } +} diff --git a/Sources/Core/API/CardsAPI.swift b/Sources/Core/API/CardsAPI.swift new file mode 100644 index 0000000..2ba705b --- /dev/null +++ b/Sources/Core/API/CardsAPI.swift @@ -0,0 +1,22 @@ +import Foundation +import ManaCore + +/// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport` +/// aus ManaCore, der die Cardecky-Endpoints kennt. +/// +/// In Phase β-0 ist die API leer — Endpoints kommen ab β-1 (Decks), +/// β-2 (Reviews), β-3 (Editor), β-4 (Media), β-5 (Marketplace). +actor CardsAPI { + private let transport: AuthenticatedTransport + + init(auth: AuthClient) { + transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth) + } + + /// Health-Probe für β-0 — verifiziert dass cardecky-api erreichbar + /// ist und der eigene JWT akzeptiert wird. + func healthCheck() async throws -> Bool { + let (_, http) = try await transport.request(path: "/healthz") + return http.statusCode == 200 + } +} diff --git a/Sources/Core/Auth/AppConfig.swift b/Sources/Core/Auth/AppConfig.swift new file mode 100644 index 0000000..14e28da --- /dev/null +++ b/Sources/Core/Auth/AppConfig.swift @@ -0,0 +1,15 @@ +import Foundation +import ManaCore + +/// App-spezifische Konfiguration für Cards. Implementiert `ManaAppConfig` +/// aus ManaCore und ergänzt die Cards-eigene `apiBaseURL` (cardecky-api, +/// getrennt von mana-auth). +enum AppConfig { + static let manaAppConfig: ManaAppConfig = DefaultManaAppConfig( + authBaseURL: URL(string: "https://auth.mana.how")!, + keychainService: "ev.mana.cards", + keychainAccessGroup: nil + ) + + static let apiBaseURL = URL(string: "https://cardecky-api.mana.how")! +} diff --git a/Sources/Core/Telemetry/Log.swift b/Sources/Core/Telemetry/Log.swift new file mode 100644 index 0000000..0be94ed --- /dev/null +++ b/Sources/Core/Telemetry/Log.swift @@ -0,0 +1,13 @@ +import Foundation +import OSLog + +/// App-eigene OSLog-Logger unter Subsystem `ev.mana.cards`. +/// ManaCore loggt unter `ev.mana.core` parallel — siehe +/// `mana-swift-core/Sources/ManaCore/Telemetry/CoreLog.swift`. +enum Log { + static let app = Logger(subsystem: "ev.mana.cards", category: "app") + static let auth = Logger(subsystem: "ev.mana.cards", category: "auth") + static let api = Logger(subsystem: "ev.mana.cards", category: "api") + static let study = Logger(subsystem: "ev.mana.cards", category: "study") + static let sync = Logger(subsystem: "ev.mana.cards", category: "sync") +} diff --git a/Sources/Core/Theme/CardsTheme.swift b/Sources/Core/Theme/CardsTheme.swift new file mode 100644 index 0000000..064ae6e --- /dev/null +++ b/Sources/Core/Theme/CardsTheme.swift @@ -0,0 +1,99 @@ +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/Features/Account/LoginView.swift b/Sources/Features/Account/LoginView.swift new file mode 100644 index 0000000..7823745 --- /dev/null +++ b/Sources/Features/Account/LoginView.swift @@ -0,0 +1,78 @@ +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/DashboardView.swift b/Sources/Features/Decks/DashboardView.swift new file mode 100644 index 0000000..363a9d9 --- /dev/null +++ b/Sources/Features/Decks/DashboardView.swift @@ -0,0 +1,58 @@ +import ManaCore +import SwiftUI + +/// Phase β-0-Placeholder. Wird in β-1 durch eine echte Tab-Bar mit +/// Decks / Study / Stats / Account ersetzt. +struct DashboardView: View { + @Environment(AuthClient.self) private var auth + @State private var apiReachable: Bool? + + var body: some View { + ZStack { + CardsTheme.background.ignoresSafeArea() + VStack(spacing: 24) { + Text("Cards") + .font(.largeTitle.bold()) + .foregroundStyle(CardsTheme.primary) + + if let email = auth.currentEmail { + Text("Angemeldet als \(email)") + .font(.subheadline) + .foregroundStyle(CardsTheme.mutedForeground) + } + + ContentUnavailableView { + Label("β-1 in Vorbereitung", systemImage: "rectangle.stack") + .foregroundStyle(CardsTheme.foreground) + } description: { + Text("Decks- und Study-Views kommen in der nächsten Phase.") + .foregroundStyle(CardsTheme.mutedForeground) + } + + if let reachable = apiReachable { + Label( + reachable ? "cardecky-api erreichbar" : "cardecky-api nicht erreichbar", + systemImage: reachable ? "checkmark.circle.fill" : "xmark.circle.fill" + ) + .foregroundStyle(reachable ? CardsTheme.success : CardsTheme.error) + .font(.footnote) + } + + Button("Abmelden", role: .destructive) { + Task { await auth.signOut() } + } + .padding(.top, 24) + } + .padding(32) + } + .task { + let api = CardsAPI(auth: auth) + apiReachable = (try? await api.healthCheck()) ?? false + } + } +} + +#Preview { + DashboardView() + .environment(AuthClient(config: AppConfig.manaAppConfig)) +} diff --git a/Sources/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Sources/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..129f762 --- /dev/null +++ b/Sources/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.231", + "green" : "0.502", + "red" : "0.106" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..ffdfe15 --- /dev/null +++ b/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,85 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Resources/Assets.xcassets/Contents.json b/Sources/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Resources/Localizable.xcstrings b/Sources/Resources/Localizable.xcstrings new file mode 100644 index 0000000..9b38231 --- /dev/null +++ b/Sources/Resources/Localizable.xcstrings @@ -0,0 +1,5 @@ +{ + "sourceLanguage" : "de", + "strings" : { }, + "version" : "1.0" +} diff --git a/Tests/UITests/CardsNativeUITests.swift b/Tests/UITests/CardsNativeUITests.swift new file mode 100644 index 0000000..1e6d3dd --- /dev/null +++ b/Tests/UITests/CardsNativeUITests.swift @@ -0,0 +1,9 @@ +import XCTest + +final class CardsNativeUITests: XCTestCase { + func testAppLaunches() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.staticTexts["Cards"].waitForExistence(timeout: 5)) + } +} diff --git a/Tests/UnitTests/CardsNativeTests.swift b/Tests/UnitTests/CardsNativeTests.swift new file mode 100644 index 0000000..b16c9e6 --- /dev/null +++ b/Tests/UnitTests/CardsNativeTests.swift @@ -0,0 +1,20 @@ +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.cards") + func keychainServiceIsAppSpecific() { + #expect(AppConfig.manaAppConfig.keychainService == "ev.mana.cards") + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..444935c --- /dev/null +++ b/project.yml @@ -0,0 +1,97 @@ +name: CardsNative + +options: + bundleIdPrefix: ev.mana + createIntermediateGroups: true + deploymentTarget: + iOS: "18.0" + macOS: "15.0" + developmentLanguage: de + groupSortPosition: top + generateEmptyDirectories: true + +packages: + ManaSwiftCore: + path: ../mana-swift-core + +settings: + base: + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + CURRENT_PROJECT_VERSION: "1" + MARKETING_VERSION: "0.1.0" + GENERATE_INFOPLIST_FILE: "NO" + ENABLE_USER_SCRIPT_SANDBOXING: "YES" + DEAD_CODE_STRIPPING: "YES" + CLANG_ENABLE_MODULES: "YES" + +targets: + CardsNative: + type: application + supportedDestinations: [iOS, macOS] + dependencies: + - package: ManaSwiftCore + product: ManaCore + - package: ManaSwiftCore + product: ManaTokens + sources: + - path: Sources/App + - path: Sources/Features + - path: Sources/Core + - path: Sources/Resources + excludes: + - "Info.plist" + - "CardsNative.entitlements" + info: + path: Sources/Resources/Info.plist + properties: + CFBundleShortVersionString: "0.1.0" + CFBundleVersion: "1" + CFBundleDevelopmentRegion: de + CFBundleDisplayName: Cards + LSApplicationCategoryType: "public.app-category.education" + UILaunchScreen: {} + CFBundleURLTypes: + - CFBundleURLName: ev.mana.cards + CFBundleURLSchemes: + - cards + ITSAppUsesNonExemptEncryption: false + entitlements: + path: Sources/Resources/CardsNative.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.cards + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cards + CODE_SIGN_STYLE: Automatic + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + ENABLE_PREVIEWS: "YES" + + CardsNativeTests: + type: bundle.unit-test + supportedDestinations: [iOS, macOS] + sources: + - Tests/UnitTests + dependencies: + - target: CardsNative + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cards.tests + GENERATE_INFOPLIST_FILE: "YES" + + CardsNativeUITests: + type: bundle.ui-testing + supportedDestinations: [iOS, macOS] + sources: + - Tests/UITests + dependencies: + - target: CardsNative + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cards.uitests + GENERATE_INFOPLIST_FILE: "YES"