From 586da1451cab04f4ab33d18b3869a523a0c2b079 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 29 Jul 2026 10:33:08 +0200 Subject: [PATCH] feat(client/apple): one sheet for a profile, with the colours visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create/update flow was four menu items, three bare text alerts and a submenu of colour NAMES with no colour anywhere on it. Making a "Work" profile meant: name it in an alert, find it again in the scope menu, open a submenu, and pick "Amber" on faith. A profile is a name and a colour, so they are decided together now — in one editor sheet that serves create, duplicate and edit, with a live chip at the top showing exactly what the host cards will render. The palette is swatches you can see, in a grid, with a checkmark AND a ring on the chosen one (the tick alone washes out on the pale hues) and a 44pt target under each 30pt dot. Default leads the row, so "no colour" is a choice on the same shelf as the rest rather than the absence of one. Duplicate opens the sheet rather than committing on the spot: it arrives carrying the source's colour and overrides with a free name filled in, so it can be renamed before it exists rather than after. Creating lands you in the new profile — being left on the defaults is how you end up editing the wrong layer. Delete moved into the sheet with the rest, and its warning is where the button is: the count of hosts and pinned cards that will change sits under it before you press it, not only in the confirmation after. The name's uniqueness check now reports inline and in red instead of through a disabled button and an alert message. Co-Authored-By: Claude Opus 5 (1M context) --- .../Settings/ProfileEditorSheet.swift | 280 ++++++++++++++++++ .../Settings/SettingsView+Scope.swift | 172 +---------- .../Settings/SettingsView.swift | 5 +- .../PunktfunkClient/Stores/ProfileStore.swift | 20 +- .../PunktfunkShared/ProfileAccent.swift | 3 +- 5 files changed, 299 insertions(+), 181 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkClient/Settings/ProfileEditorSheet.swift diff --git a/clients/apple/Sources/PunktfunkClient/Settings/ProfileEditorSheet.swift b/clients/apple/Sources/PunktfunkClient/Settings/ProfileEditorSheet.swift new file mode 100644 index 00000000..d61bb6ae --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Settings/ProfileEditorSheet.swift @@ -0,0 +1,280 @@ +// Creating, duplicating, renaming, recolouring and deleting a settings profile — one sheet for +// all five (design/client-settings-profiles.md §5.1). +// +// It replaced a menu of four separate actions, three of which raised their own bare text alert +// and the fourth a submenu of colour NAMES with no colour anywhere on it. Making a profile then +// meant: name it in an alert, find it again in the scope menu, open a submenu, and pick "Amber" +// on faith. The two things a profile has — a name and a colour — are decided together here, with +// a live chip showing exactly what the host cards will render. + +import PunktfunkKit +import SwiftUI + +/// What the sheet was opened to do. Carrying the seed values rather than an id keeps the sheet +/// free of "which store do I read to find out what I'm editing" — the caller already knows. +struct ProfileDraft: Identifiable { + /// nil = creating (a blank profile, or a duplicate); set = editing that profile. + var editingID: String? + var name: String + var accent: String? + /// What a newly created profile starts with. Empty for a blank one; the source's overrides + /// for a duplicate, which is the whole point of duplicating. + var overrides = SettingsOverlay() + var title: String + var accept: String + + var id: String { editingID ?? "new-\(title)" } + + static func create() -> ProfileDraft { + ProfileDraft(name: "", accent: nil, title: "New Profile", accept: "Create") + } + + static func edit(_ profile: StreamProfile) -> ProfileDraft { + ProfileDraft( + editingID: profile.id, name: profile.name, accent: profile.accent, + title: "Edit Profile", accept: "Save") + } + + static func duplicate(_ profile: StreamProfile, name: String) -> ProfileDraft { + ProfileDraft( + name: name, accent: profile.accent, overrides: profile.overrides, + title: "Duplicate Profile", accept: "Duplicate") + } +} + +struct ProfileEditorSheet: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject private var profiles = ProfileStore.shared + + let draft: ProfileDraft + /// Where the settings surface should be pointing afterwards — at the new profile, or back at + /// the defaults when this one was deleted. + let onScope: (SettingsScope) -> Void + + @State private var name: String + @State private var accent: String? + @State private var confirmingDelete = false + + init(draft: ProfileDraft, onScope: @escaping (SettingsScope) -> Void) { + self.draft = draft + self.onScope = onScope + _name = State(initialValue: draft.name) + _accent = State(initialValue: draft.accent) + } + + var body: some View { + #if os(macOS) + withDeletePrompt(VStack(spacing: 0) { + form + HStack { + Button("Cancel", role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + Spacer() + Button(draft.accept) { commit() } + .glassProminentButtonStyle() + .keyboardShortcut(.defaultAction) + .disabled(!isNameAcceptable) + } + .padding(16) + } + .frame(width: 420) + .fixedSize(horizontal: false, vertical: true)) + #else + withDeletePrompt(NavigationStack { + form + .navigationTitle(draft.title) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(draft.accept) { commit() } + .disabled(!isNameAcceptable) + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible)) + #endif + } + + // MARK: - Form + + private var form: some View { + Form { + Section { + preview + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + Section { + TextField("Name", text: $name, prompt: Text("Name — e.g. Game, Work, Travel")) + #if os(iOS) + .textInputAutocapitalization(.words) + #endif + } footer: { + Text(nameFootnote) + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(duplicateName ? Color.red : Color.secondary) + } + Section { + swatches + .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + } header: { + Text("Color") + } footer: { + Text("Tints this profile's chip on host cards and in the stream overlay.") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + if draft.editingID != nil { + Section { + Button("Delete Profile", role: .destructive) { confirmingDelete = true } + } footer: { + Text(deleteFootnote) + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + } + } + .formStyle(.grouped) + #if os(macOS) + .frame(minHeight: 420) + #endif + } + + /// The profile exactly as a host card will show it — the answer to "what am I choosing?", + /// which a list of colour names never gave. + private var preview: some View { + let shown = name.trimmingCharacters(in: .whitespaces) + return ProfileChip( + profile: StreamProfile(name: shown.isEmpty ? "Profile" : shown, accent: accent), + size: 13, prominent: true) + .opacity(shown.isEmpty ? 0.5 : 1) + .animation(.easeOut(duration: 0.15), value: accent) + } + + /// The palette, as colours. `nil` is the brand default and leads, so "no colour" is a choice + /// on the same shelf as the rest rather than the absence of one. + private var swatches: some View { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 44), spacing: 14, alignment: .center)], + spacing: 14 + ) { + swatch(nil, label: "Default") + ForEach(ProfileAccent.palette) { option in + swatch(option.hex, label: option.name) + } + } + } + + private func swatch(_ hex: String?, label: String) -> some View { + let selected = accent?.caseInsensitiveCompare(hex ?? "") == .orderedSame + || (accent == nil && hex == nil) + let color = hex.flatMap { Color(hex: $0) } ?? .brand + return Button { + accent = hex + } label: { + ZStack { + Circle() + .fill(color) + .frame(width: 30, height: 30) + if selected { + Image(systemName: "checkmark") + .font(.footnote.weight(.bold)) + .foregroundStyle(.white) + // A ring OUTSIDE the swatch as well as the checkmark: the tick alone washes + // out on the pale hues and the ring alone is easy to miss at this size. + Circle() + .strokeBorder(color, lineWidth: 2) + .frame(width: 40, height: 40) + } + } + // 30pt is under the touch minimum; the cell is the target, not the dot. + .frame(width: 44, height: 44) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(label) + .accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton) + .help(label) + } + + // MARK: - Validation + commit + + private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) } + + /// Case-insensitively unique — two "Work"s make every menu ambiguous, and the deep-link + /// grammar has to refuse an ambiguous reference rather than guess which one was meant. + private var duplicateName: Bool { + !trimmedName.isEmpty && profiles.nameTaken(trimmedName, except: draft.editingID) + } + + private var isNameAcceptable: Bool { !trimmedName.isEmpty && !duplicateName } + + private var nameFootnote: String { + if duplicateName { return "Another profile is already called “\(trimmedName)”." } + return draft.editingID == nil + ? "A new profile inherits every setting. Change one here and only that one is overridden." + : "Hosts and pinned cards follow this profile by id, so renaming keeps them attached." + } + + private var deleteFootnote: String { + guard let id = draft.editingID else { return "" } + let (bound, pinned) = profiles.usage(of: id) + var parts: [String] = [] + if bound > 0 { + parts.append("\(bound) host\(bound == 1 ? "" : "s") use\(bound == 1 ? "s" : "") it") + } + if pinned > 0 { + parts.append("\(pinned) pinned card\(pinned == 1 ? "" : "s") show\(pinned == 1 ? "s" : "") it") + } + guard !parts.isEmpty else { return "Nothing uses this profile yet." } + return parts.joined(separator: " and ") + "." + } + + private var deleteMessage: String { + let usage = deleteFootnote + return "Hosts using it fall back to Default settings and its pinned cards disappear." + + (usage.isEmpty ? "" : " \(usage)") + } + + private func commit() { + guard isNameAcceptable else { return } + if let id = draft.editingID { + profiles.rename(id, to: trimmedName) + profiles.setAccent(id, to: accent) + } else { + var profile = StreamProfile(name: trimmedName, accent: accent) + profile.overrides = draft.overrides + profiles.add(profile) + // Land in the thing that was just made — creating a profile and being left on the + // defaults is how you end up editing the wrong layer. + onScope(.profile(profile.id)) + } + dismiss() + } + + private func delete() { + guard let id = draft.editingID else { return } + profiles.delete(id) + onScope(.defaults) + dismiss() + } + + /// Deleting warns with what it changes (§6): bindings fall back to Default settings and + /// pinned cards disappear. Neither is an error; neither should be a surprise. + private func withDeletePrompt(_ content: Content) -> some View { + content.alert("Delete “\(draft.name)”?", isPresented: $confirmingDelete) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { delete() } + } message: { + Text(deleteMessage) + } + } +} diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift index cd69a567..4128c3c7 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift @@ -303,57 +303,15 @@ extension SettingsView { } .pickerStyle(.inline) Divider() - Button("New Profile…") { - nameDraft = "" - nameAction = .create - } + // Three actions, one sheet. A profile is a name and a colour; deciding them in separate + // menu items — each raising its own bare alert — is how "make a Work profile" became four + // trips through this menu. + Button("New Profile…") { profileDraft = .create() } if let active = activeProfile { - Button("Rename “\(active.name)”…") { - nameDraft = active.name - nameAction = .rename(active.id) + Button("Edit “\(active.name)”…") { profileDraft = .edit(active) } + Button("Duplicate “\(active.name)”…") { + profileDraft = .duplicate(active, name: Self.copyName(of: active.name, in: profiles)) } - Button("Duplicate “\(active.name)”") { - nameDraft = Self.copyName(of: active.name, in: profiles) - nameAction = .duplicate(active.id) - } - colorMenu(active) - Button("Delete “\(active.name)”…", role: .destructive) { - profilePendingDelete = active - } - } - } - - /// "Color ▸" — what tints this profile's chip on the host cards and its dot here. A palette - /// rather than a colour well: the chip is small tinted text on a tinted capsule, and a colour - /// picked freehand lands somewhere unreadable often enough to matter (see `ProfileAccent`). - @ViewBuilder - private func colorMenu(_ active: StreamProfile) -> some View { - Menu("Color") { - Button { - profiles.setAccent(active.id, to: nil) - } label: { - Self.checkable("Default", on: active.accent == nil) - } - ForEach(ProfileAccent.palette) { accent in - Button { - profiles.setAccent(active.id, to: accent.hex) - } label: { - Self.checkable( - accent.name, - on: active.accent?.caseInsensitiveCompare(accent.hex) == .orderedSame) - } - } - } - } - - /// A menu row carrying a checkmark when it is the current choice. Two shapes rather than one - /// `Label` with an empty symbol name — `Image(systemName: "")` is not a blank image. - @ViewBuilder - private static func checkable(_ title: String, on: Bool) -> some View { - if on { - Label(title, systemImage: "checkmark") - } else { - Text(title) } } @@ -431,41 +389,11 @@ extension SettingsView { } #endif - /// The two prompts the scope menu can raise, attached wherever the menu lives. + /// The editor, attached wherever the menu lives. private func profilePrompts(_ content: Content) -> some View { - content - // Name prompts (create / rename / duplicate) share one alert: they differ only in the - // title and what happens with the name. - .alert(nameAction?.title ?? "", isPresented: namePromptPresented) { - TextField("Name", text: $nameDraft) - Button("Cancel", role: .cancel) { nameAction = nil } - Button(nameAction?.accept ?? "OK") { commitNamePrompt() } - .disabled(!isNameAcceptable) - } message: { - Text(nameMessage) - } - // Deleting warns with what it will change — bindings fall back to Default settings - // and pinned cards disappear (§6). Neither is an error, but neither is a surprise. - .alert( - "Delete “\(profilePendingDelete?.name ?? "")”?", - isPresented: profileDeletePresented, - presenting: profilePendingDelete - ) { profile in - Button("Cancel", role: .cancel) { profilePendingDelete = nil } - Button("Delete", role: .destructive) { - profiles.delete(profile.id) - scope = .defaults - profilePendingDelete = nil - } - } message: { profile in - Text(Self.deleteWarning(for: profile, in: profiles)) - } - } - - private var profileDeletePresented: Binding { - Binding( - get: { profilePendingDelete != nil }, - set: { if !$0 { profilePendingDelete = nil } }) + content.sheet(item: $profileDraft) { draft in + ProfileEditorSheet(draft: draft) { scope = $0 } + } } /// Switching scope is a plain state change — the rows are built by one code path and simply @@ -474,48 +402,6 @@ extension SettingsView { Binding(get: { scope }, set: { scope = $0 }) } - private var namePromptPresented: Binding { - Binding(get: { nameAction != nil }, set: { if !$0 { nameAction = nil } }) - } - - /// Names are unique case-insensitively — two "Work"s make every menu ambiguous, and the - /// deep-link grammar has to refuse an ambiguous reference rather than guess. - private var isNameAcceptable: Bool { - let name = nameDraft.trimmingCharacters(in: .whitespaces) - guard !name.isEmpty else { return false } - let except: String? = { - if case .rename(let id) = nameAction { return id } - return nil - }() - return !profiles.nameTaken(name, except: except) - } - - private var nameMessage: String { - let name = nameDraft.trimmingCharacters(in: .whitespaces) - if !name.isEmpty, !isNameAcceptable { - return "Another profile is already called “\(name)”." - } - if case .create = nameAction { - return "A new profile inherits everything. Change a setting here and only that " - + "setting is overridden." - } - return "" - } - - private func commitNamePrompt() { - let name = nameDraft.trimmingCharacters(in: .whitespaces) - guard !name.isEmpty, let action = nameAction else { return } - switch action { - case .create: - scope = .profile(profiles.create(name: name).id) - case .rename(let id): - profiles.rename(id, to: name) - case .duplicate(let id): - if let copy = profiles.duplicate(id, name: name) { scope = .profile(copy.id) } - } - nameAction = nil - } - /// "Game copy", "Game copy 2", … — the first name that isn't taken, so Duplicate never opens /// with a name the accept button refuses. private static func copyName(of name: String, in store: ProfileStore) -> String { @@ -525,40 +411,4 @@ extension SettingsView { return base } - private static func deleteWarning(for profile: StreamProfile, in store: ProfileStore) -> String { - let (bound, pinned) = store.usage(of: profile.id) - var parts: [String] = [] - if bound > 0 { - parts.append("\(bound) host\(bound == 1 ? "" : "s") will fall back to Default settings") - } - if pinned > 0 { - parts.append("\(pinned) pinned card\(pinned == 1 ? "" : "s") will disappear") - } - guard !parts.isEmpty else { return "Nothing uses this profile." } - return parts.joined(separator: ", ") + "." - } -} - -/// The three name prompts the scope menu raises. One alert serves all of them; only the title, -/// the accept verb and what happens with the name differ. -enum ProfileNameAction: Equatable { - case create - case rename(String) - case duplicate(String) - - var title: String { - switch self { - case .create: return "New Profile" - case .rename: return "Rename Profile" - case .duplicate: return "Duplicate Profile" - } - } - - var accept: String { - switch self { - case .create: return "Create" - case .rename: return "Rename" - case .duplicate: return "Duplicate" - } - } } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index a6c4efc4..b517be6d 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -26,9 +26,8 @@ struct SettingsView: View { // profiles and render pinned cards, but don't edit them (design §5.4). @ObservedObject var profiles = ProfileStore.shared @State var scope: SettingsScope = .defaults - @State var nameAction: ProfileNameAction? - @State var nameDraft = "" - @State var profilePendingDelete: StreamProfile? + /// The profile editor (create / duplicate / edit / delete), when it is open. + @State var profileDraft: ProfileDraft? #if os(macOS) @State private var macTab: MacTab = .general #endif diff --git a/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift b/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift index aabbf168..faeaac0c 100644 --- a/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift +++ b/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift @@ -46,23 +46,11 @@ final class ProfileStore: ObservableObject { // MARK: - Catalog management (the scope menu's Rename / Duplicate / Delete) - /// Create an EMPTY profile — it inherits everything, which is the right creation default under - /// inherit-by-exception. "Duplicate" covers starting from another profile. - @discardableResult - func create(name: String) -> StreamProfile { - let profile = StreamProfile(name: name) + /// Add a profile the editor built. A blank one inherits everything — the right creation + /// default under inherit-by-exception; a duplicate arrives carrying the source's overrides, + /// which is what duplicating is for. + func add(_ profile: StreamProfile) { catalog.profiles.append(profile) - return profile - } - - @discardableResult - func duplicate(_ id: String, name: String) -> StreamProfile? { - guard let source = catalog.profile(id: id) else { return nil } - var copy = source - copy.id = newProfileID() - copy.name = name - catalog.profiles.append(copy) - return copy } func rename(_ id: String, to name: String) { diff --git a/clients/apple/Sources/PunktfunkShared/ProfileAccent.swift b/clients/apple/Sources/PunktfunkShared/ProfileAccent.swift index f4875ba8..41c7d3b6 100644 --- a/clients/apple/Sources/PunktfunkShared/ProfileAccent.swift +++ b/clients/apple/Sources/PunktfunkShared/ProfileAccent.swift @@ -21,8 +21,9 @@ public struct ProfileAccent: Identifiable, Sendable, Hashable { /// Hues that stay legible as tinted text on their own tinted capsule, in both appearances, /// and that stay tellable apart from each other at chip size. + /// No violet: the brand purple IS the default, and a swatch a shade off it would be a + /// choice you can't see you made. public static let palette: [ProfileAccent] = [ - ProfileAccent(hex: "#8b5cf6", name: "Violet"), ProfileAccent(hex: "#3aa0ff", name: "Blue"), ProfileAccent(hex: "#2bb8a6", name: "Teal"), ProfileAccent(hex: "#35c759", name: "Green"),