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:
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user