Add account sign-in structure

This commit is contained in:
MrDiderot
2026-07-22 22:27:47 +02:00
parent 10fca0756d
commit 77d93c4f8d
2 changed files with 181 additions and 9 deletions

View File

@@ -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<Bool> {
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<ASAuthorization, Error>) {
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 {

View File

@@ -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<ASAuthorization, Error>) {
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 {