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>
This commit is contained in:
@@ -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: View>(_ content: Content) -> some View {
|
||||
content.alert("Delete “\(draft.name)”?", isPresented: $confirmingDelete) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Delete", role: .destructive) { delete() }
|
||||
} message: {
|
||||
Text(deleteMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: View>(_ 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<Bool> {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user