feat(sharing): hidden-profile toggle + hide non-sharing group members

- UserProfile decodes directory_hidden; updateProfile() sends it; Settings has
  a "hide my profile" toggle (excludes you from share/group pickers).
- GroupMember decodes shares_calendar; the group filter list now only shows
  members who actually share a calendar (no phantom empty rows).
- Robust custom decoders (decodeIfPresent) for the new fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-06 18:45:49 +02:00
parent 3f5de5cdfa
commit 32cb8e1689
5 changed files with 46 additions and 3 deletions

View File

@@ -223,6 +223,7 @@ struct UserProfile: Codable {
let isAdmin: Bool let isAdmin: Bool
let hasAvatar: Bool let hasAvatar: Bool
let totpEnabled: Bool let totpEnabled: Bool
var directoryHidden: Bool = false
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, username, email case id, username, email
@@ -230,6 +231,19 @@ struct UserProfile: Codable {
case isAdmin = "is_admin" case isAdmin = "is_admin"
case hasAvatar = "has_avatar" case hasAvatar = "has_avatar"
case totpEnabled = "totp_enabled" case totpEnabled = "totp_enabled"
case directoryHidden = "directory_hidden"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
username = try c.decode(String.self, forKey: .username)
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
email = try c.decodeIfPresent(String.self, forKey: .email)
isAdmin = try c.decodeIfPresent(Bool.self, forKey: .isAdmin) ?? false
hasAvatar = try c.decodeIfPresent(Bool.self, forKey: .hasAvatar) ?? false
totpEnabled = try c.decodeIfPresent(Bool.self, forKey: .totpEnabled) ?? false
directoryHidden = try c.decodeIfPresent(Bool.self, forKey: .directoryHidden) ?? false
} }
} }
@@ -258,9 +272,20 @@ struct GroupMember: Codable, Identifiable {
let displayName: String? let displayName: String?
var role: String var role: String
var color: String? var color: String?
var sharesCalendar: Bool = true
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, role, color case id, role, color
case displayName = "display_name" case displayName = "display_name"
case sharesCalendar = "shares_calendar"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
role = try c.decodeIfPresent(String.self, forKey: .role) ?? "member"
color = try c.decodeIfPresent(String.self, forKey: .color)
sharesCalendar = try c.decodeIfPresent(Bool.self, forKey: .sharesCalendar) ?? true
} }
} }

View File

@@ -132,6 +132,8 @@ private let strings: [String: [String: String]] = [
"settings.privacy": "Privatsphäre", "settings.privacy": "Privatsphäre",
"settings.private_visibility": "Private Termine für Gruppen", "settings.private_visibility": "Private Termine für Gruppen",
"settings.private_visibility.desc": "Wie private Termine für andere Gruppenmitglieder erscheinen", "settings.private_visibility.desc": "Wie private Termine für andere Gruppenmitglieder erscheinen",
"settings.directory_hidden": "Profil verbergen",
"settings.directory_hidden.desc": "Nicht in Teilen-/Gruppen-Auswahl anzeigen. In der Admin-Benutzerverwaltung bleibst du sichtbar.",
"settings.private.busy": "Als „Beschäftigt“", "settings.private.busy": "Als „Beschäftigt“",
"settings.private.hidden": "Ausblenden", "settings.private.hidden": "Ausblenden",
"settings.calendars": "Geteilter Kalender", "settings.calendars": "Geteilter Kalender",
@@ -461,6 +463,8 @@ private let strings: [String: [String: String]] = [
"settings.privacy": "Privacy", "settings.privacy": "Privacy",
"settings.private_visibility": "Private events for groups", "settings.private_visibility": "Private events for groups",
"settings.private_visibility.desc": "How your private events appear to other group members", "settings.private_visibility.desc": "How your private events appear to other group members",
"settings.directory_hidden": "Hide my profile",
"settings.directory_hidden.desc": "Don't show me in share/group pickers. You stay visible in the admin user management.",
"settings.private.busy": "Show as \"Busy\"", "settings.private.busy": "Show as \"Busy\"",
"settings.private.hidden": "Hide", "settings.private.hidden": "Hide",
"settings.calendars": "Shared calendar", "settings.calendars": "Shared calendar",

View File

@@ -450,11 +450,13 @@ class CalendarrAPI {
/// Update profile fields. A login-name change returns a fresh token (the old /// Update profile fields. A login-name change returns a fresh token (the old
/// one becomes invalid) the caller must store the returned token. /// one becomes invalid) the caller must store the returned token.
func updateProfile(displayName: String?, username: String?, email: String?) async throws -> String? { func updateProfile(displayName: String?, username: String?, email: String?,
directoryHidden: Bool? = nil) async throws -> String? {
var body: [String: Any] = [:] var body: [String: Any] = [:]
if let d = displayName { body["display_name"] = d } if let d = displayName { body["display_name"] = d }
if let u = username { body["username"] = u } if let u = username { body["username"] = u }
if let e = email { body["email"] = e } else { body["email"] = NSNull() } if let e = email { body["email"] = e } else { body["email"] = NSNull() }
if let h = directoryHidden { body["directory_hidden"] = h }
let data = try await request("/api/profile/", method: "PUT", body: body) let data = try await request("/api/profile/", method: "PUT", body: body)
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
return json?["access_token"] as? String return json?["access_token"] as? String

View File

@@ -217,7 +217,9 @@ struct CalendarFilterSheet: View {
if let g = groupDetail { if let g = groupDetail {
List { List {
Section(header: Label(g.name, systemImage: GroupIcons.symbol(g.icon))) { Section(header: Label(g.name, systemImage: GroupIcons.symbol(g.icon))) {
ForEach(g.members ?? []) { m in // Only members who actually share a calendar into the group
// avoids phantom empty rows for members who share nothing.
ForEach((g.members ?? []).filter { $0.sharesCalendar }) { m in
groupRow(name: m.displayName ?? "", groupRow(name: m.displayName ?? "",
colorHex: m.color ?? "#4285f4", colorHex: m.color ?? "#4285f4",
key: CalendarStore.groupMemberKey(m.id)) key: CalendarStore.groupMemberKey(m.id))

View File

@@ -32,6 +32,7 @@ struct SettingsView: View {
@State private var privateVisibility = "busy" @State private var privateVisibility = "busy"
@State private var groupVisibleId = 0 // 0 = none @State private var groupVisibleId = 0 // 0 = none
@State private var ownLocalCals: [LocalCalendar] = [] @State private var ownLocalCals: [LocalCalendar] = []
@State private var directoryHidden = false
@State private var profileMsg = "" @State private var profileMsg = ""
var body: some View { var body: some View {
@@ -102,6 +103,13 @@ struct SettingsView: View {
.keyboardType(.emailAddress) .keyboardType(.emailAddress)
.autocapitalization(.none) .autocapitalization(.none)
} }
Toggle(isOn: $directoryHidden) {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.t("settings.directory_hidden", appLang))
Text(L10n.t("settings.directory_hidden.desc", appLang))
.font(.caption).foregroundStyle(.secondary)
}
}
Button(L10n.t("event.save", appLang)) { Task { await saveProfile() } } Button(L10n.t("event.save", appLang)) { Task { await saveProfile() } }
if !profileMsg.isEmpty { if !profileMsg.isEmpty {
Text(profileMsg).font(.caption).foregroundStyle(.secondary) Text(profileMsg).font(.caption).foregroundStyle(.secondary)
@@ -188,6 +196,7 @@ struct SettingsView: View {
displayName = p.displayName ?? p.username displayName = p.displayName ?? p.username
loginName = p.username loginName = p.username
email = p.email ?? "" email = p.email ?? ""
directoryHidden = p.directoryHidden
} }
if let s = try? await api.getSettings() { if let s = try? await api.getSettings() {
privateVisibility = s.privateEventVisibility privateVisibility = s.privateEventVisibility
@@ -202,7 +211,8 @@ struct SettingsView: View {
do { do {
_ = try await api.updateProfile(displayName: displayName.isEmpty ? nil : displayName, _ = try await api.updateProfile(displayName: displayName.isEmpty ? nil : displayName,
username: nil, username: nil,
email: email.isEmpty ? "" : email) email: email.isEmpty ? "" : email,
directoryHidden: directoryHidden)
UserDefaults.standard.set(displayName, forKey: "displayName") UserDefaults.standard.set(displayName, forKey: "displayName")
profileMsg = L10n.t("settings.saved", appLang) profileMsg = L10n.t("settings.saved", appLang)
} catch { } catch {