feat(client/apple): one sheet for a profile, with the colours visible

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 12:06:41 +02:00
co-authored by Claude Opus 5
parent f50d13752d
commit 586da1451c
5 changed files with 299 additions and 181 deletions
@@ -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: View>(_ content: Content) -> some View {
content.alert("Delete “\(draft.name)”?", isPresented: $confirmingDelete) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) { delete() }
} message: {
Text(deleteMessage)
}
}
}
@@ -303,57 +303,15 @@ extension SettingsView {
} }
.pickerStyle(.inline) .pickerStyle(.inline)
Divider() Divider()
Button("New Profile…") { // Three actions, one sheet. A profile is a name and a colour; deciding them in separate
nameDraft = "" // menu items each raising its own bare alert is how "make a Work profile" became four
nameAction = .create // trips through this menu.
} Button("New Profile…") { profileDraft = .create() }
if let active = activeProfile { if let active = activeProfile {
Button("Rename\(active.name)”…") { Button("Edit\(active.name)”…") { profileDraft = .edit(active) }
nameDraft = active.name Button("Duplicate “\(active.name)”…") {
nameAction = .rename(active.id) 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 #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: View>(_ content: Content) -> some View { private func profilePrompts<Content: View>(_ content: Content) -> some View {
content content.sheet(item: $profileDraft) { draft in
// Name prompts (create / rename / duplicate) share one alert: they differ only in the ProfileEditorSheet(draft: draft) { scope = $0 }
// 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<Bool> {
Binding(
get: { profilePendingDelete != nil },
set: { if !$0 { profilePendingDelete = nil } })
} }
/// Switching scope is a plain state change the rows are built by one code path and simply /// 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 }) Binding(get: { scope }, set: { scope = $0 })
} }
private var namePromptPresented: Binding<Bool> {
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 /// "Game copy", "Game copy 2", the first name that isn't taken, so Duplicate never opens
/// with a name the accept button refuses. /// with a name the accept button refuses.
private static func copyName(of name: String, in store: ProfileStore) -> String { private static func copyName(of name: String, in store: ProfileStore) -> String {
@@ -525,40 +411,4 @@ extension SettingsView {
return base 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"
}
}
} }
@@ -26,9 +26,8 @@ struct SettingsView: View {
// profiles and render pinned cards, but don't edit them (design §5.4). // profiles and render pinned cards, but don't edit them (design §5.4).
@ObservedObject var profiles = ProfileStore.shared @ObservedObject var profiles = ProfileStore.shared
@State var scope: SettingsScope = .defaults @State var scope: SettingsScope = .defaults
@State var nameAction: ProfileNameAction? /// The profile editor (create / duplicate / edit / delete), when it is open.
@State var nameDraft = "" @State var profileDraft: ProfileDraft?
@State var profilePendingDelete: StreamProfile?
#if os(macOS) #if os(macOS)
@State private var macTab: MacTab = .general @State private var macTab: MacTab = .general
#endif #endif
@@ -46,23 +46,11 @@ final class ProfileStore: ObservableObject {
// MARK: - Catalog management (the scope menu's Rename / Duplicate / Delete) // MARK: - Catalog management (the scope menu's Rename / Duplicate / Delete)
/// Create an EMPTY profile it inherits everything, which is the right creation default under /// Add a profile the editor built. A blank one inherits everything the right creation
/// inherit-by-exception. "Duplicate" covers starting from another profile. /// default under inherit-by-exception; a duplicate arrives carrying the source's overrides,
@discardableResult /// which is what duplicating is for.
func create(name: String) -> StreamProfile { func add(_ profile: StreamProfile) {
let profile = StreamProfile(name: name)
catalog.profiles.append(profile) 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) { func rename(_ id: String, to name: String) {
@@ -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, /// 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. /// 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] = [ public static let palette: [ProfileAccent] = [
ProfileAccent(hex: "#8b5cf6", name: "Violet"),
ProfileAccent(hex: "#3aa0ff", name: "Blue"), ProfileAccent(hex: "#3aa0ff", name: "Blue"),
ProfileAccent(hex: "#2bb8a6", name: "Teal"), ProfileAccent(hex: "#2bb8a6", name: "Teal"),
ProfileAccent(hex: "#35c759", name: "Green"), ProfileAccent(hex: "#35c759", name: "Green"),