Files
punktfunk/clients/apple/Sources/PunktfunkClient/Settings/ProfileEditorSheet.swift
T
enricobuehlerandClaude Opus 5 8233722e9e feat(client/apple): the scope dropdown carries the colours, the icons, and Delete
Every profile row in the layer picker now shows its colour chip, so the dropdown is
where you SEE the catalog rather than read it. It stays a Picker for that: the
platform draws the selection checkmark in its own column, which leaves each row's
icon free to be the chip. Hand-rolling the selection would have cost one or the
other.

Delete comes back to the menu it was taken from, next to New, Edit and Duplicate —
all four with icons now. Its warning goes with it: the count of hosts that fall back
and pinned cards that disappear is in the question, not discovered afterwards. The
editor sheet drops its own delete section rather than offering the same destructive
action in two places.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00

233 lines
9.0 KiB
Swift

// 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 profile that was just
/// created, so you land in the layer you made rather than back on the defaults.
let onScope: (SettingsScope) -> Void
@State private var name: String
@State private var accent: String?
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)
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
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)
}
}
.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 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()
}
}