diff --git a/Sources/App/RootView.swift b/Sources/App/RootView.swift index 3c960a9..a41195f 100644 --- a/Sources/App/RootView.swift +++ b/Sources/App/RootView.swift @@ -1,3 +1,4 @@ +import ManaAuthUI import ManaCore import SwiftUI @@ -8,6 +9,12 @@ struct RootView: View { @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://cardecky.mana.how/auth/verify")! + private let resetUniversalLink = URL(string: "https://cardecky.mana.how/auth/reset")! var body: some View { Group { @@ -19,14 +26,55 @@ struct RootView: View { if let url = activity.webpageURL { handle(url: url) } } case .unknown, .signedOut, .signingIn, .error: - LoginView() + authSurface + .onOpenURL { url in handle(url: url) } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + if let url = activity.webpageURL { handle(url: url) } + } } } + .manaBrand(CardsBrand.manaBrand) .task { await auth.ensureSignedIn() } } + @ViewBuilder + private var authSurface: some View { + ManaLoginView( + auth: auth, + onSignUpTapped: { showSignUpSheet = true }, + onForgotTapped: { showForgotSheet = true } + ) + .sheet(isPresented: $showSignUpSheet) { + ManaSignUpView( + auth: auth, + sourceAppUrl: sourceAppUrl, + onDone: { showSignUpSheet = false } + ) + .manaBrand(CardsBrand.manaBrand) + } + .sheet(isPresented: $showForgotSheet) { + ManaForgotPasswordView( + auth: auth, + resetUniversalLink: resetUniversalLink, + onDone: { showForgotSheet = false } + ) + .manaBrand(CardsBrand.manaBrand) + } + .sheet(item: Binding( + get: { resetPasswordToken.map(IdentifiedString.init) }, + set: { resetPasswordToken = $0?.value } + )) { token in + ManaResetPasswordView( + token: token.value, + auth: auth, + onDone: { resetPasswordToken = nil } + ) + .manaBrand(CardsBrand.manaBrand) + } + } + @ViewBuilder private var mainTabs: some View { TabView(selection: $selectedTab) { @@ -51,19 +99,38 @@ struct RootView: View { /// Universal-Link- und URL-Scheme-Handler: /// - `https://cardecky.mana.how/d/` → Explore-Tab + PublicDeckView + /// - `https://cardecky.mana.how/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 == "cardecky.mana.how" || url.scheme == "cards" else { return } + + let parts = url.pathComponents.filter { $0 != "/" } + + // Auth-Reset-Link aus der Passwort-Vergessen-Email. + if parts == ["auth", "reset"], + let token = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .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 } +} + + enum AppTab: Hashable { case decks case explore diff --git a/Sources/Core/Theme/CardsBrand.swift b/Sources/Core/Theme/CardsBrand.swift new file mode 100644 index 0000000..73227a2 --- /dev/null +++ b/Sources/Core/Theme/CardsBrand.swift @@ -0,0 +1,25 @@ +import ManaAuthUI + +/// Brücke zwischen Cardeckys `CardsTheme` (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: "Cardecky", …)` +/// ersetzt werden — siehe MANA_SWIFT.md Phase ε. +enum CardsBrand { + static let manaBrand = ManaBrandConfig( + appName: "Cardecky", + tagline: "Karteikarten des Vereins mana e.V.", + logoSymbol: "rectangle.stack.fill", + background: CardsTheme.background, + foreground: CardsTheme.foreground, + surface: CardsTheme.surface, + mutedForeground: CardsTheme.mutedForeground, + border: CardsTheme.border, + primary: CardsTheme.primary, + primaryForeground: CardsTheme.primaryForeground, + error: CardsTheme.error, + success: CardsTheme.success + ) +} diff --git a/Sources/Features/Account/AccountView.swift b/Sources/Features/Account/AccountView.swift index 0ab63af..f47b527 100644 --- a/Sources/Features/Account/AccountView.swift +++ b/Sources/Features/Account/AccountView.swift @@ -1,13 +1,17 @@ +import ManaAuthUI import ManaCore import SwiftUI struct AccountView: View { @Environment(AuthClient.self) private var auth + @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) { + VStack(spacing: 20) { Image(systemName: "person.crop.circle.fill") .resizable() .frame(width: 80, height: 80) @@ -19,20 +23,24 @@ struct AccountView: View { .foregroundStyle(CardsTheme.foreground) } - 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) - ) + 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) } - .buttonStyle(.plain) .padding(.horizontal, 32) Spacer() @@ -47,6 +55,17 @@ struct AccountView: View { .foregroundStyle(CardsTheme.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(CardsTheme.mutedForeground) + } + .padding(.bottom, 16) } .padding(.top, 48) } @@ -54,6 +73,43 @@ struct AccountView: View { #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif + .manaBrand(CardsBrand.manaBrand) + .sheet(isPresented: $showChangeEmail) { + ManaChangeEmailView( + auth: auth, + callbackUniversalLink: URL(string: "https://cardecky.mana.how/auth/email-changed"), + onDone: { showChangeEmail = false } + ) + .manaBrand(CardsBrand.manaBrand) + } + .sheet(isPresented: $showChangePassword) { + ManaChangePasswordView( + auth: auth, + onDone: { showChangePassword = false } + ) + .manaBrand(CardsBrand.manaBrand) + } + .sheet(isPresented: $showDeleteAccount) { + ManaDeleteAccountView( + auth: auth, + onDone: { showDeleteAccount = false } + ) + .manaBrand(CardsBrand.manaBrand) + } + } + + @ViewBuilder + 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(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 8)) + .foregroundStyle(CardsTheme.foreground) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(CardsTheme.border, lineWidth: 1) + ) } } diff --git a/Sources/Features/Account/LoginView.swift b/Sources/Features/Account/LoginView.swift deleted file mode 100644 index f9760e2..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("Cardecky") - .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/project.yml b/project.yml index 7e06a9c..9f2283b 100644 --- a/project.yml +++ b/project.yml @@ -14,6 +14,8 @@ options: packages: ManaSwiftCore: path: ../mana-swift-core + ManaSwiftUI: + path: ../mana-swift-ui settings: base: @@ -39,6 +41,8 @@ targets: product: ManaCore - package: ManaSwiftCore product: ManaTokens + - package: ManaSwiftUI + product: ManaAuthUI - target: CardsWidgetExtension embed: true - target: CardsShareExtension