From 77d93c4f8d1f89f9283da65ac5181319969c13e2 Mon Sep 17 00:00:00 2001 From: MrDiderot Date: Wed, 22 Jul 2026 22:27:47 +0200 Subject: [PATCH] Add account sign-in structure --- FeelAloud/RootView.swift | 119 ++++++++++++++++++++++++++++++++--- FeelAloud/SettingsView.swift | 71 ++++++++++++++++++++- 2 files changed, 181 insertions(+), 9 deletions(-) diff --git a/FeelAloud/RootView.swift b/FeelAloud/RootView.swift index ccc1970..5196921 100644 --- a/FeelAloud/RootView.swift +++ b/FeelAloud/RootView.swift @@ -1,4 +1,5 @@ import AppIntents +import AuthenticationServices import PhotosUI import SwiftData import SwiftUI @@ -188,6 +189,9 @@ enum UserProfileStore { static let onboardingCompletedKey = "hasCompletedOnboarding" static let displayNameKey = "userDisplayName" static let profileImageDataKey = "userProfileImageData" + static let accountProviderKey = "accountProvider" + static let accountEmailKey = "accountEmail" + static let appleUserIDKey = "appleUserID" static func saveProfile(displayName: String, imageData: Data?) { UserDefaults.standard.set(displayName.trimmingCharacters(in: .whitespacesAndNewlines), forKey: displayNameKey) @@ -196,12 +200,50 @@ enum UserProfileStore { UserDefaults.standard.set(imageData, forKey: profileImageDataKey) } } + + static func saveAppleAccount(userID: String, email: String?, fullName: PersonNameComponents?) { + UserDefaults.standard.set(AccountProvider.apple.rawValue, forKey: accountProviderKey) + UserDefaults.standard.set(userID, forKey: appleUserIDKey) + + if let email, !email.isEmpty { + UserDefaults.standard.set(email, forKey: accountEmailKey) + } + + let formatter = PersonNameComponentsFormatter() + let name = fullName.map { formatter.string(from: $0).trimmingCharacters(in: .whitespacesAndNewlines) } ?? "" + if !name.isEmpty { + UserDefaults.standard.set(name, forKey: displayNameKey) + } + } + + static func clearAccount() { + UserDefaults.standard.removeObject(forKey: accountProviderKey) + UserDefaults.standard.removeObject(forKey: accountEmailKey) + UserDefaults.standard.removeObject(forKey: appleUserIDKey) + } +} + +enum AccountProvider: String { + case local + case apple + case google + case email + + var displayName: String { + switch self { + case .local: "Lokales Profil" + case .apple: "Apple Account" + case .google: "Google Account" + case .email: "E-Mail und Passwort" + } + } } private struct OnboardingView: View { @Environment(\.appColorProfile) private var colorProfile @AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false @AppStorage(UserProfileStore.displayNameKey) private var storedDisplayName = "" + @AppStorage(UserProfileStore.accountProviderKey) private var accountProviderRaw = AccountProvider.local.rawValue @State private var page = 0 @State private var displayName = "" @@ -209,6 +251,7 @@ private struct OnboardingView: View { @State private var profileImageData: Data? @State private var customCategories: [DiaryCategory] = [] @State private var newCategoryName = "" + @State private var accountMessage: String? var body: some View { ZStack { @@ -245,6 +288,11 @@ private struct OnboardingView: View { .onChange(of: selectedPhoto) { _, item in Task { await loadSelectedPhoto(item) } } + .alert("Account", isPresented: accountMessageBinding) { + Button("OK", role: .cancel) { } + } message: { + Text(accountMessage ?? "") + } } private var progressDots: some View { @@ -393,15 +441,41 @@ private struct OnboardingView: View { private var accountPage: some View { VStack(spacing: 20) { onboardingHeader( - symbol: "icloud.and.arrow.up.fill", - title: "Zugang sichern", - text: "Zum Abschluss wird dein lokales Profil gespeichert. Premium-Käufe laufen später über deine Apple-ID, und deine Daten können über iCloud synchronisiert werden, sobald die iCloud-Funktion aktiv ist." + symbol: "person.crop.circle.badge.checkmark", + title: "Account sichern", + text: "Melde dich an, damit Premium später über den App Store wiederhergestellt werden kann. Google und E-Mail brauchen noch ein Backend, Apple ist in iOS nativ vorgesehen." ) - VStack(alignment: .leading, spacing: 14) { - accountRow(symbol: "person.crop.circle.badge.checkmark", title: "Profil", text: displayName.isEmpty ? "Ohne Namen fortfahren" : displayName) - accountRow(symbol: "icloud", title: "Datenzugang", text: "Über deinen Apple-Account und iCloud absichern") - accountRow(symbol: "checkmark.seal", title: "Premium", text: "Käufe können über die Apple-ID wiederhergestellt werden") + VStack(spacing: 12) { + SignInWithAppleButton(.signUp) { request in + request.requestedScopes = [.fullName, .email] + } onCompletion: { result in + handleAppleSignIn(result) + } + .signInWithAppleButtonStyle(.black) + .frame(height: 48) + + Button { + accountMessage = "Google Login braucht einen OAuth Client und ein Backend wie Firebase, Supabase oder einen eigenen Server. Sobald du dich für eines davon entscheidest, kann ich die echte Anmeldung anbinden." + } label: { + Label("Mit Google anmelden", systemImage: "g.circle") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + + Button { + accountMessage = "E-Mail und Passwort darf ich nicht lokal faken. Dafür braucht FeelAloud einen Auth-Server, der Passwörter sicher hasht, Reset-Mails verschickt und Sessions verwaltet." + } label: { + Label("Mit E-Mail registrieren", systemImage: "envelope") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + + accountRow( + symbol: accountProviderRaw == AccountProvider.apple.rawValue ? "checkmark.seal.fill" : "info.circle", + title: "Aktueller Zugang", + text: AccountProvider(rawValue: accountProviderRaw)?.displayName ?? AccountProvider.local.displayName + ) } .padding(16) .background(.background.opacity(0.72), in: RoundedRectangle(cornerRadius: 18)) @@ -415,6 +489,13 @@ private struct OnboardingView: View { } } + private var accountMessageBinding: Binding { + Binding( + get: { accountMessage != nil }, + set: { if !$0 { accountMessage = nil } } + ) + } + private var navigationControls: some View { HStack(spacing: 12) { if page > 0 { @@ -547,6 +628,30 @@ private struct OnboardingView: View { hasCompletedOnboarding = true } + private func handleAppleSignIn(_ result: Result) { + switch result { + case .success(let authorization): + guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else { + accountMessage = "Apple hat keine gültigen Accountdaten zurückgegeben." + return + } + + UserProfileStore.saveAppleAccount( + userID: credential.user, + email: credential.email, + fullName: credential.fullName + ) + accountProviderRaw = AccountProvider.apple.rawValue + if displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let formatter = PersonNameComponentsFormatter() + displayName = credential.fullName.map { formatter.string(from: $0) } ?? "" + } + accountMessage = "Apple Account wurde verknüpft." + case .failure(let error): + accountMessage = "Apple Login konnte nicht abgeschlossen werden: \(error.localizedDescription)" + } + } + private func loadSelectedPhoto(_ item: PhotosPickerItem?) async { guard let item, let data = try? await item.loadTransferable(type: Data.self) else { diff --git a/FeelAloud/SettingsView.swift b/FeelAloud/SettingsView.swift index 48b2ab5..fc09e81 100644 --- a/FeelAloud/SettingsView.swift +++ b/FeelAloud/SettingsView.swift @@ -1,4 +1,5 @@ import AppIntents +import AuthenticationServices import FoundationModels import SwiftData import SwiftUI @@ -15,6 +16,8 @@ struct SettingsView: View { @AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false @AppStorage(UserProfileStore.displayNameKey) private var displayName = "" @AppStorage(UserProfileStore.profileImageDataKey) private var profileImageData = Data() + @AppStorage(UserProfileStore.accountProviderKey) private var accountProviderRaw = AccountProvider.local.rawValue + @AppStorage(UserProfileStore.accountEmailKey) private var accountEmail = "" @AppStorage("snapshotRemindersEnabled") private var remindersEnabled = false @AppStorage("snapshotReminderCount") private var reminderCount = 3 @AppStorage("snapshotReminderTime1") private var reminderTime1 = 10 * 60 @@ -198,7 +201,33 @@ struct SettingsView: View { } .padding(.vertical, 4) - LabeledContent("Account", value: "Apple-ID / iCloud") + LabeledContent("Account", value: currentAccountProvider.displayName) + if !accountEmail.isEmpty { + LabeledContent("E-Mail", value: accountEmail) + } + } + + Section("Anmelden") { + SignInWithAppleButton(.signIn) { request in + request.requestedScopes = [.fullName, .email] + } onCompletion: { result in + handleAppleSignIn(result) + } + .signInWithAppleButtonStyle(.black) + .frame(height: 46) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + + Button { + profileMessage = "Google Login braucht einen OAuth Client und ein Backend wie Firebase, Supabase oder einen eigenen Server. Danach kann FeelAloud Google sicher als Account-Anbieter verwenden." + } label: { + Label("Mit Google anmelden", systemImage: "g.circle") + } + + Button { + profileMessage = "E-Mail und Passwort brauchen einen Auth-Server. Dort werden Passwörter sicher gehasht, Reset-Mails verschickt und Sessions verwaltet. Lokal in der App wäre das kein echter Account." + } label: { + Label("Mit E-Mail anmelden", systemImage: "envelope") + } } Section("Premium") { @@ -226,7 +255,7 @@ struct SettingsView: View { Section("Zugang") { Button { - profileMessage = "FeelAloud verwendet aktuell kein eigenes Passwort. Der Zugang läuft über dein Gerät, Face ID und später über deine Apple-ID/iCloud. Ein App-Passwort kann erst mit einem echten Login-System ergänzt werden." + profileMessage = "Passwort ändern ist nur für E-Mail-Accounts sinnvoll. Dafür braucht FeelAloud zuerst einen echten Auth-Server mit sicherer Passwortspeicherung und Reset-Mail-Funktion." } label: { Label("Passwort ändern", systemImage: "key") } @@ -495,6 +524,10 @@ struct SettingsView: View { .padding(.vertical, 2) } + private var currentAccountProvider: AccountProvider { + AccountProvider(rawValue: accountProviderRaw) ?? .local + } + private var profileAvatar: some View { Group { #if canImport(UIKit) @@ -726,8 +759,42 @@ struct SettingsView: View { private func logoutProfile() { displayName = "" profileImageData = Data() + accountEmail = "" + accountProviderRaw = AccountProvider.local.rawValue + UserProfileStore.clearAccount() hasCompletedOnboarding = false } + + private func handleAppleSignIn(_ result: Result) { + switch result { + case .success(let authorization): + guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else { + profileMessage = "Apple hat keine gültigen Accountdaten zurückgegeben." + return + } + + UserProfileStore.saveAppleAccount( + userID: credential.user, + email: credential.email, + fullName: credential.fullName + ) + + accountProviderRaw = AccountProvider.apple.rawValue + if let email = credential.email, !email.isEmpty { + accountEmail = email + } + + let formatter = PersonNameComponentsFormatter() + let appleName = credential.fullName.map { formatter.string(from: $0).trimmingCharacters(in: .whitespacesAndNewlines) } ?? "" + if !appleName.isEmpty { + displayName = appleName + } + + profileMessage = "Apple Account wurde verknüpft." + case .failure(let error): + profileMessage = "Apple Login konnte nicht abgeschlossen werden: \(error.localizedDescription)" + } + } } private struct SettingsExportFile: Identifiable {