diff --git a/clients/apple/Sources/PunktfunkClient/Settings/ProfileEditorSheet.swift b/clients/apple/Sources/PunktfunkClient/Settings/ProfileEditorSheet.swift index d61bb6ae..706df49b 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/ProfileEditorSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/ProfileEditorSheet.swift @@ -47,13 +47,12 @@ struct ProfileEditorSheet: View { @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. + /// 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? - @State private var confirmingDelete = false init(draft: ProfileDraft, onScope: @escaping (SettingsScope) -> Void) { self.draft = draft @@ -64,7 +63,7 @@ struct ProfileEditorSheet: View { var body: some View { #if os(macOS) - withDeletePrompt(VStack(spacing: 0) { + VStack(spacing: 0) { form HStack { Button("Cancel", role: .cancel) { dismiss() } @@ -78,9 +77,9 @@ struct ProfileEditorSheet: View { .padding(16) } .frame(width: 420) - .fixedSize(horizontal: false, vertical: true)) + .fixedSize(horizontal: false, vertical: true) #else - withDeletePrompt(NavigationStack { + NavigationStack { form .navigationTitle(draft.title) #if os(iOS) @@ -97,7 +96,7 @@ struct ProfileEditorSheet: View { } } .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible)) + .presentationDragIndicator(.visible) #endif } @@ -132,15 +131,6 @@ struct ProfileEditorSheet: View { .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) @@ -224,26 +214,6 @@ struct ProfileEditorSheet: View { : "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 { @@ -259,22 +229,4 @@ struct ProfileEditorSheet: View { } 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 4128c3c7..ce3ec5a3 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift @@ -295,10 +295,20 @@ extension SettingsView { /// remote does well, and the pattern should prove itself on the primary surfaces first. @ViewBuilder var scopeMenuContent: some View { + // A Picker rather than hand-rolled buttons: the platform owns the selection checkmark + // and draws it in its OWN column, which leaves each row's icon free to be the profile's + // colour chip. Rolling the selection by hand would have cost one or the other. Picker("Editing", selection: scopeSelection) { - Text("Default settings").tag(SettingsScope.defaults) + Label("Default settings", systemImage: "gearshape") + .tag(SettingsScope.defaults) ForEach(profiles.profiles) { profile in - Text(profile.name).tag(SettingsScope.profile(profile.id)) + Label { + Text(profile.name) + } icon: { + Image(systemName: "circle.fill") + .foregroundStyle(profile.accentColor) + } + .tag(SettingsScope.profile(profile.id)) } } .pickerStyle(.inline) @@ -306,11 +316,27 @@ extension SettingsView { // 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() } + Button { + profileDraft = .create() + } label: { + Label("New Profile…", systemImage: "plus") + } if let active = activeProfile { - Button("Edit “\(active.name)”…") { profileDraft = .edit(active) } - Button("Duplicate “\(active.name)”…") { + Button { + profileDraft = .edit(active) + } label: { + Label("Edit “\(active.name)”…", systemImage: "pencil") + } + Button { profileDraft = .duplicate(active, name: Self.copyName(of: active.name, in: profiles)) + } label: { + Label("Duplicate “\(active.name)”…", systemImage: "plus.square.on.square") + } + Divider() + Button(role: .destructive) { + profilePendingDelete = active + } label: { + Label("Delete “\(active.name)”…", systemImage: "trash") } } } @@ -389,11 +415,48 @@ extension SettingsView { } #endif - /// The editor, attached wherever the menu lives. + /// The editor and the delete confirmation, attached wherever the menu lives. private func profilePrompts(_ content: Content) -> some View { - content.sheet(item: $profileDraft) { draft in - ProfileEditorSheet(draft: draft) { scope = $0 } + content + .sheet(item: $profileDraft) { draft in + ProfileEditorSheet(draft: draft) { scope = $0 } + } + // Deleting warns with what it changes (§6): bound hosts fall back to Default settings + // and pinned cards disappear. Neither is an error, but neither should be a surprise — + // so the counts are in the question, not discovered afterwards. + .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(deleteWarning(for: profile)) + } + } + + private var profileDeletePresented: Binding { + Binding( + get: { profilePendingDelete != nil }, + set: { if !$0 { profilePendingDelete = nil } }) + } + + private func deleteWarning(for profile: StreamProfile) -> String { + let (bound, pinned) = profiles.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 yet." } + return parts.joined(separator: ", ") + "." } /// Switching scope is a plain state change — the rows are built by one code path and simply diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index b517be6d..749705b8 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -26,8 +26,10 @@ 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 - /// The profile editor (create / duplicate / edit / delete), when it is open. + /// The profile editor (create / duplicate / edit), when it is open, and the profile a delete + /// is being confirmed for. @State var profileDraft: ProfileDraft? + @State var profilePendingDelete: StreamProfile? #if os(macOS) @State private var macTab: MacTab = .general #endif