Files
Calendarr-IOS/Calendarr iOS/Views/ProfileView.swift
Scarriffle 8b3cc11e25 Add localization (DE/EN), vertical-scroll month view, context menus, custom colors
- Vertical-scroll month view with multi-day event spans, zig-zag month
  divider, CW number per week, on-demand event loading while scrolling
- Top bar redesign: icon-only view picker on right, month title centered
- Long-press context menus on day cells (month) and hour slots (week/day)
  for "New event", "Open in week view", "Open in day view", "Open in month view"
- Localization system with system/de/en switch covering top bar, view picker,
  settings, menu, profile, server, accounts, event editor, agenda
- Three new color pickers (text/background/line) + today-marker color
  applied in calendar views; current-time line now uses today color
- App icon: removed alpha channel, accent color set to icon green (#20A050)
- TestFlight: ITSAppUsesNonExemptEncryption=NO baked into Info.plist keys

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 22:00:49 +02:00

313 lines
11 KiB
Swift

import SwiftUI
struct ProfileView: View {
let api: CalendarrAPI
@State private var profile: UserProfile?
@State private var isLoading = true
@State private var newEmail = ""
@State private var currentPW = ""
@State private var newPW = ""
@State private var confirmPW = ""
@State private var toast = ""
@State private var showToast = false
@State private var show2FASetup = false
@State private var show2FADisable = false
@State private var totpQR = ""
@State private var totpSecret = ""
@State private var totpCode = ""
@State private var disablePW = ""
@State private var isSaving2FA = false
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView(L10n.t("profile.loading", appLang))
} else if let profile {
Form {
kontoSection(profile: profile)
passwordSection
twoFASection(profile: profile)
}
}
}
.navigationTitle(L10n.t("profile.title", appLang))
.navigationBarTitleDisplayMode(.large)
.overlay(alignment: .bottom) {
if showToast {
Text(toast)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(Capsule())
.padding(.bottom, 20)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut, value: showToast)
.sheet(isPresented: $show2FASetup) {
TwoFASetupSheet(
qrURL: totpQR,
secret: totpSecret,
code: $totpCode,
isSaving: isSaving2FA
) {
Task { await enable2FA() }
}
}
.sheet(isPresented: $show2FADisable) {
TwoFADisableSheet(password: $disablePW) {
Task { await disable2FA() }
}
}
}
.task { await load() }
}
func kontoSection(profile: UserProfile) -> some View {
Section(L10n.t("profile.account", appLang)) {
HStack {
Text(L10n.t("profile.username", appLang))
Spacer()
Text(profile.username)
.foregroundStyle(.secondary)
}
HStack {
Text(L10n.t("profile.role", appLang))
Spacer()
Text(profile.isAdmin
? L10n.t("profile.role.admin", appLang)
: L10n.t("profile.role.user", appLang))
.foregroundStyle(.secondary)
}
HStack {
Text(L10n.t("profile.email", appLang))
Spacer()
TextField(L10n.t("profile.no_email", appLang), text: $newEmail)
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
}
Button(L10n.t("profile.save_email", appLang)) {
Task { await saveEmail() }
}
.foregroundStyle(Color.accentColor)
}
}
var passwordSection: some View {
Section(L10n.t("profile.change_password", appLang)) {
SecureField(L10n.t("profile.current_password", appLang), text: $currentPW)
SecureField(L10n.t("profile.new_password", appLang), text: $newPW)
SecureField(L10n.t("profile.new_password_repeat", appLang), text: $confirmPW)
Button(L10n.t("profile.change_password", appLang)) {
Task { await changePassword() }
}
.foregroundStyle(Color.accentColor)
.disabled(currentPW.isEmpty || newPW.isEmpty || confirmPW.isEmpty)
}
}
func twoFASection(profile: UserProfile) -> some View {
Section(L10n.t("profile.twofa", appLang)) {
if profile.totpEnabled {
HStack {
Image(systemName: "checkmark.shield.fill")
.foregroundStyle(.green)
Text(L10n.t("profile.twofa.active", appLang))
}
Button(L10n.t("profile.twofa.disable", appLang)) {
show2FADisable = true
}
.foregroundStyle(.red)
} else {
HStack {
Image(systemName: "shield")
.foregroundStyle(.secondary)
Text(L10n.t("profile.twofa.inactive", appLang))
.foregroundStyle(.secondary)
}
Button(L10n.t("profile.twofa.enable", appLang)) {
Task { await setup2FA() }
}
.foregroundStyle(Color.accentColor)
}
}
}
private func load() async {
isLoading = true
defer { isLoading = false }
if let p = try? await api.getProfile() {
profile = p
newEmail = p.email ?? ""
}
}
private func saveEmail() async {
do {
try await api.updateEmail(newEmail)
showNotice(L10n.t("profile.email_saved", appLang))
} catch {
showNotice(error.localizedDescription)
}
}
private func changePassword() async {
guard newPW == confirmPW else {
showNotice(L10n.t("profile.password_mismatch", appLang))
return
}
do {
try await api.changePassword(current: currentPW, new: newPW)
currentPW = ""; newPW = ""; confirmPW = ""
showNotice(L10n.t("profile.password_changed", appLang))
} catch {
showNotice(error.localizedDescription)
}
}
private func setup2FA() async {
do {
let result = try await api.setup2FA()
totpSecret = result.secret
totpQR = result.qrUrl
totpCode = ""
show2FASetup = true
} catch {
showNotice(error.localizedDescription)
}
}
private func enable2FA() async {
isSaving2FA = true
do {
try await api.enable2FA(code: totpCode)
show2FASetup = false
showNotice(L10n.t("profile.twofa.enabled_toast", appLang))
await load()
} catch {
showNotice(error.localizedDescription)
}
isSaving2FA = false
}
private func disable2FA() async {
do {
try await api.disable2FA(password: disablePW)
show2FADisable = false
showNotice(L10n.t("profile.twofa.disabled_toast", appLang))
await load()
} catch {
showNotice(error.localizedDescription)
}
}
private func showNotice(_ msg: String) {
toast = msg
withAnimation { showToast = true }
Task {
try? await Task.sleep(for: .seconds(2))
withAnimation { showToast = false }
}
}
}
struct TwoFASetupSheet: View {
let qrURL: String
let secret: String
@Binding var code: String
let isSaving: Bool
let onEnable: () -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
Form {
Section {
Text(L10n.t("twofa.scan_hint", appLang))
.font(.body)
}
Section(L10n.t("twofa.qr_section", appLang)) {
if let url = URL(string: qrURL) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img):
img.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 200)
.frame(maxWidth: .infinity)
default:
ProgressView()
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 8)
}
HStack {
Text(secret)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
Spacer()
Button {
UIPasteboard.general.string = secret
} label: {
Image(systemName: "doc.on.doc")
}
.foregroundStyle(Color.accentColor)
}
}
Section(L10n.t("twofa.confirmation", appLang)) {
TextField(L10n.t("twofa.code_placeholder", appLang), text: $code)
.keyboardType(.numberPad)
}
}
.navigationTitle(L10n.t("twofa.setup_title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("twofa.activate", appLang)) { onEnable() }
.bold()
.disabled(code.count < 6 || isSaving)
}
}
}
}
}
struct TwoFADisableSheet: View {
@Binding var password: String
let onDisable: () -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
Form {
Section(L10n.t("twofa.password_section", appLang)) {
SecureField(L10n.t("twofa.password_placeholder", appLang), text: $password)
}
}
.navigationTitle(L10n.t("twofa.disable_title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("twofa.disable", appLang)) { onDisable() }
.bold()
.foregroundStyle(.red)
.disabled(password.isEmpty)
}
}
}
}
}