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:
@@ -223,6 +223,7 @@ struct UserProfile: Codable {
|
||||
let isAdmin: Bool
|
||||
let hasAvatar: Bool
|
||||
let totpEnabled: Bool
|
||||
var directoryHidden: Bool = false
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, username, email
|
||||
@@ -230,6 +231,19 @@ struct UserProfile: Codable {
|
||||
case isAdmin = "is_admin"
|
||||
case hasAvatar = "has_avatar"
|
||||
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?
|
||||
var role: String
|
||||
var color: String?
|
||||
var sharesCalendar: Bool = true
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, role, color
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,8 @@ private let strings: [String: [String: String]] = [
|
||||
"settings.privacy": "Privatsphäre",
|
||||
"settings.private_visibility": "Private Termine für Gruppen",
|
||||
"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.hidden": "Ausblenden",
|
||||
"settings.calendars": "Geteilter Kalender",
|
||||
@@ -461,6 +463,8 @@ private let strings: [String: [String: String]] = [
|
||||
"settings.privacy": "Privacy",
|
||||
"settings.private_visibility": "Private events for groups",
|
||||
"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.hidden": "Hide",
|
||||
"settings.calendars": "Shared calendar",
|
||||
|
||||
@@ -450,11 +450,13 @@ class CalendarrAPI {
|
||||
|
||||
/// Update profile fields. A login-name change returns a fresh token (the old
|
||||
/// 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] = [:]
|
||||
if let d = displayName { body["display_name"] = d }
|
||||
if let u = username { body["username"] = u }
|
||||
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 json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
return json?["access_token"] as? String
|
||||
|
||||
@@ -217,7 +217,9 @@ struct CalendarFilterSheet: View {
|
||||
if let g = groupDetail {
|
||||
List {
|
||||
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 ?? "—",
|
||||
colorHex: m.color ?? "#4285f4",
|
||||
key: CalendarStore.groupMemberKey(m.id))
|
||||
|
||||
@@ -32,6 +32,7 @@ struct SettingsView: View {
|
||||
@State private var privateVisibility = "busy"
|
||||
@State private var groupVisibleId = 0 // 0 = none
|
||||
@State private var ownLocalCals: [LocalCalendar] = []
|
||||
@State private var directoryHidden = false
|
||||
@State private var profileMsg = ""
|
||||
|
||||
var body: some View {
|
||||
@@ -102,6 +103,13 @@ struct SettingsView: View {
|
||||
.keyboardType(.emailAddress)
|
||||
.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() } }
|
||||
if !profileMsg.isEmpty {
|
||||
Text(profileMsg).font(.caption).foregroundStyle(.secondary)
|
||||
@@ -188,6 +196,7 @@ struct SettingsView: View {
|
||||
displayName = p.displayName ?? p.username
|
||||
loginName = p.username
|
||||
email = p.email ?? ""
|
||||
directoryHidden = p.directoryHidden
|
||||
}
|
||||
if let s = try? await api.getSettings() {
|
||||
privateVisibility = s.privateEventVisibility
|
||||
@@ -202,7 +211,8 @@ struct SettingsView: View {
|
||||
do {
|
||||
_ = try await api.updateProfile(displayName: displayName.isEmpty ? nil : displayName,
|
||||
username: nil,
|
||||
email: email.isEmpty ? "" : email)
|
||||
email: email.isEmpty ? "" : email,
|
||||
directoryHidden: directoryHidden)
|
||||
UserDefaults.standard.set(displayName, forKey: "displayName")
|
||||
profileMsg = L10n.t("settings.saved", appLang)
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user