Add account sign-in structure
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import AppIntents
|
import AppIntents
|
||||||
|
import AuthenticationServices
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -188,6 +189,9 @@ enum UserProfileStore {
|
|||||||
static let onboardingCompletedKey = "hasCompletedOnboarding"
|
static let onboardingCompletedKey = "hasCompletedOnboarding"
|
||||||
static let displayNameKey = "userDisplayName"
|
static let displayNameKey = "userDisplayName"
|
||||||
static let profileImageDataKey = "userProfileImageData"
|
static let profileImageDataKey = "userProfileImageData"
|
||||||
|
static let accountProviderKey = "accountProvider"
|
||||||
|
static let accountEmailKey = "accountEmail"
|
||||||
|
static let appleUserIDKey = "appleUserID"
|
||||||
|
|
||||||
static func saveProfile(displayName: String, imageData: Data?) {
|
static func saveProfile(displayName: String, imageData: Data?) {
|
||||||
UserDefaults.standard.set(displayName.trimmingCharacters(in: .whitespacesAndNewlines), forKey: displayNameKey)
|
UserDefaults.standard.set(displayName.trimmingCharacters(in: .whitespacesAndNewlines), forKey: displayNameKey)
|
||||||
@@ -196,12 +200,50 @@ enum UserProfileStore {
|
|||||||
UserDefaults.standard.set(imageData, forKey: profileImageDataKey)
|
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 {
|
private struct OnboardingView: View {
|
||||||
@Environment(\.appColorProfile) private var colorProfile
|
@Environment(\.appColorProfile) private var colorProfile
|
||||||
@AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false
|
@AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false
|
||||||
@AppStorage(UserProfileStore.displayNameKey) private var storedDisplayName = ""
|
@AppStorage(UserProfileStore.displayNameKey) private var storedDisplayName = ""
|
||||||
|
@AppStorage(UserProfileStore.accountProviderKey) private var accountProviderRaw = AccountProvider.local.rawValue
|
||||||
|
|
||||||
@State private var page = 0
|
@State private var page = 0
|
||||||
@State private var displayName = ""
|
@State private var displayName = ""
|
||||||
@@ -209,6 +251,7 @@ private struct OnboardingView: View {
|
|||||||
@State private var profileImageData: Data?
|
@State private var profileImageData: Data?
|
||||||
@State private var customCategories: [DiaryCategory] = []
|
@State private var customCategories: [DiaryCategory] = []
|
||||||
@State private var newCategoryName = ""
|
@State private var newCategoryName = ""
|
||||||
|
@State private var accountMessage: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -245,6 +288,11 @@ private struct OnboardingView: View {
|
|||||||
.onChange(of: selectedPhoto) { _, item in
|
.onChange(of: selectedPhoto) { _, item in
|
||||||
Task { await loadSelectedPhoto(item) }
|
Task { await loadSelectedPhoto(item) }
|
||||||
}
|
}
|
||||||
|
.alert("Account", isPresented: accountMessageBinding) {
|
||||||
|
Button("OK", role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
Text(accountMessage ?? "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var progressDots: some View {
|
private var progressDots: some View {
|
||||||
@@ -393,15 +441,41 @@ private struct OnboardingView: View {
|
|||||||
private var accountPage: some View {
|
private var accountPage: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
onboardingHeader(
|
onboardingHeader(
|
||||||
symbol: "icloud.and.arrow.up.fill",
|
symbol: "person.crop.circle.badge.checkmark",
|
||||||
title: "Zugang sichern",
|
title: "Account 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."
|
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) {
|
VStack(spacing: 12) {
|
||||||
accountRow(symbol: "person.crop.circle.badge.checkmark", title: "Profil", text: displayName.isEmpty ? "Ohne Namen fortfahren" : displayName)
|
SignInWithAppleButton(.signUp) { request in
|
||||||
accountRow(symbol: "icloud", title: "Datenzugang", text: "Über deinen Apple-Account und iCloud absichern")
|
request.requestedScopes = [.fullName, .email]
|
||||||
accountRow(symbol: "checkmark.seal", title: "Premium", text: "Käufe können über die Apple-ID wiederhergestellt werden")
|
} 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)
|
.padding(16)
|
||||||
.background(.background.opacity(0.72), in: RoundedRectangle(cornerRadius: 18))
|
.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 {
|
private var navigationControls: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
if page > 0 {
|
if page > 0 {
|
||||||
@@ -547,6 +628,30 @@ private struct OnboardingView: View {
|
|||||||
hasCompletedOnboarding = true
|
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 {
|
private func loadSelectedPhoto(_ item: PhotosPickerItem?) async {
|
||||||
guard let item,
|
guard let item,
|
||||||
let data = try? await item.loadTransferable(type: Data.self) else {
|
let data = try? await item.loadTransferable(type: Data.self) else {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import AppIntents
|
import AppIntents
|
||||||
|
import AuthenticationServices
|
||||||
import FoundationModels
|
import FoundationModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -15,6 +16,8 @@ struct SettingsView: View {
|
|||||||
@AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false
|
@AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false
|
||||||
@AppStorage(UserProfileStore.displayNameKey) private var displayName = ""
|
@AppStorage(UserProfileStore.displayNameKey) private var displayName = ""
|
||||||
@AppStorage(UserProfileStore.profileImageDataKey) private var profileImageData = Data()
|
@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("snapshotRemindersEnabled") private var remindersEnabled = false
|
||||||
@AppStorage("snapshotReminderCount") private var reminderCount = 3
|
@AppStorage("snapshotReminderCount") private var reminderCount = 3
|
||||||
@AppStorage("snapshotReminderTime1") private var reminderTime1 = 10 * 60
|
@AppStorage("snapshotReminderTime1") private var reminderTime1 = 10 * 60
|
||||||
@@ -198,7 +201,33 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.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") {
|
Section("Premium") {
|
||||||
@@ -226,7 +255,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
Section("Zugang") {
|
Section("Zugang") {
|
||||||
Button {
|
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: {
|
||||||
Label("Passwort ändern", systemImage: "key")
|
Label("Passwort ändern", systemImage: "key")
|
||||||
}
|
}
|
||||||
@@ -495,6 +524,10 @@ struct SettingsView: View {
|
|||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var currentAccountProvider: AccountProvider {
|
||||||
|
AccountProvider(rawValue: accountProviderRaw) ?? .local
|
||||||
|
}
|
||||||
|
|
||||||
private var profileAvatar: some View {
|
private var profileAvatar: some View {
|
||||||
Group {
|
Group {
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
@@ -726,8 +759,42 @@ struct SettingsView: View {
|
|||||||
private func logoutProfile() {
|
private func logoutProfile() {
|
||||||
displayName = ""
|
displayName = ""
|
||||||
profileImageData = Data()
|
profileImageData = Data()
|
||||||
|
accountEmail = ""
|
||||||
|
accountProviderRaw = AccountProvider.local.rawValue
|
||||||
|
UserProfileStore.clearAccount()
|
||||||
hasCompletedOnboarding = false
|
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 {
|
private struct SettingsExportFile: Identifiable {
|
||||||
|
|||||||
Reference in New Issue
Block a user