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) <noreply@anthropic.com>
This commit is contained in:
commit
28b20cd934
21 changed files with 896 additions and 0 deletions
22
Sources/App/CardsNativeApp.swift
Normal file
22
Sources/App/CardsNativeApp.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
Sources/App/RootView.swift
Normal file
18
Sources/App/RootView.swift
Normal file
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
22
Sources/Core/API/CardsAPI.swift
Normal file
22
Sources/Core/API/CardsAPI.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
15
Sources/Core/Auth/AppConfig.swift
Normal file
15
Sources/Core/Auth/AppConfig.swift
Normal file
|
|
@ -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")!
|
||||
}
|
||||
13
Sources/Core/Telemetry/Log.swift
Normal file
13
Sources/Core/Telemetry/Log.swift
Normal file
|
|
@ -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")
|
||||
}
|
||||
99
Sources/Core/Theme/CardsTheme.swift
Normal file
99
Sources/Core/Theme/CardsTheme.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
78
Sources/Features/Account/LoginView.swift
Normal file
78
Sources/Features/Account/LoginView.swift
Normal file
|
|
@ -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))
|
||||
}
|
||||
58
Sources/Features/Decks/DashboardView.swift
Normal file
58
Sources/Features/Decks/DashboardView.swift
Normal file
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
6
Sources/Resources/Assets.xcassets/Contents.json
Normal file
6
Sources/Resources/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
5
Sources/Resources/Localizable.xcstrings
Normal file
5
Sources/Resources/Localizable.xcstrings
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"sourceLanguage" : "de",
|
||||
"strings" : { },
|
||||
"version" : "1.0"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue