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"),