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>
113 lines
5.1 KiB
Swift
113 lines
5.1 KiB
Swift
// The settings-profile catalog as an observable store — the app-side wrapper around
|
|
// `ProfileCatalog` (design/client-settings-profiles.md §4.2), matching what `HostStore` is to
|
|
// `[StoredHost]`.
|
|
//
|
|
// The catalog lives in the App Group suite beside the saved hosts, because that is where the
|
|
// things that POINT at it live: a binding is `StoredHost.profileID` and a pin is an entry in
|
|
// `StoredHost.pinnedProfileIDs`. Nothing here is keyed by host — "Work" applied to three hosts is
|
|
// one profile, and the per-host part is only the binding (§4.1).
|
|
|
|
import Foundation
|
|
import PunktfunkKit
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
final class ProfileStore: ObservableObject {
|
|
/// One catalog for the whole app. Unlike `HostStore` (which ContentView owns and hands down),
|
|
/// the settings surface reaches this from a SEPARATE macOS `Settings` scene, where no parent
|
|
/// can pass it — and two instances would mean editing a profile in Preferences while the host
|
|
/// grid still shows the old one. Same shape as `GamepadManager.shared`.
|
|
static let shared = ProfileStore()
|
|
|
|
@Published private(set) var catalog: ProfileCatalog {
|
|
didSet { catalog.save() }
|
|
}
|
|
|
|
var profiles: [StreamProfile] { catalog.profiles }
|
|
|
|
init(catalog: ProfileCatalog? = nil) {
|
|
self.catalog = catalog ?? ProfileCatalog.load()
|
|
}
|
|
|
|
func profile(id: String?) -> StreamProfile? {
|
|
id.flatMap { catalog.profile(id: $0) }
|
|
}
|
|
|
|
/// This host's default profile, dangling ids dropped — a deleted profile resolves as "Default
|
|
/// settings", never an error (§4.4).
|
|
func binding(for host: StoredHost) -> StreamProfile? { catalog.binding(for: host) }
|
|
|
|
/// This host's pinned profiles in card order, duplicates and dangling ids dropped.
|
|
func pinned(for host: StoredHost) -> [StreamProfile] { catalog.pinned(for: host) }
|
|
|
|
func nameTaken(_ name: String, except: String? = nil) -> Bool {
|
|
catalog.nameTaken(name, except: except)
|
|
}
|
|
|
|
// MARK: - Catalog management (the scope menu's Rename / Duplicate / Delete)
|
|
|
|
/// 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)
|
|
}
|
|
|
|
func rename(_ id: String, to name: String) {
|
|
guard let i = catalog.profiles.firstIndex(where: { $0.id == id }) else { return }
|
|
catalog.profiles[i].name = name
|
|
}
|
|
|
|
func setAccent(_ id: String, to accent: String?) {
|
|
guard let i = catalog.profiles.firstIndex(where: { $0.id == id }) else { return }
|
|
catalog.profiles[i].accent = accent
|
|
}
|
|
|
|
/// Delete a profile. Bindings and pins pointing at it are left alone deliberately: they
|
|
/// degrade to "Default settings" / a dropped card at read time (§6), so a delete never has to
|
|
/// walk the host store — and a host record saved by an older build can't resurrect a stale id.
|
|
func delete(_ id: String) {
|
|
catalog.profiles.removeAll { $0.id == id }
|
|
}
|
|
|
|
/// How the delete warning counts what it is about to change: hosts bound to this profile and
|
|
/// pinned cards that will disappear.
|
|
func usage(of id: String) -> (bound: Int, pinned: Int) {
|
|
let hosts = Self.savedHosts()
|
|
return (
|
|
hosts.filter { $0.profileID == id }.count,
|
|
hosts.filter { ($0.pinnedProfileIDs ?? []).contains(id) }.count
|
|
)
|
|
}
|
|
|
|
/// Saved hosts straight from the shared store. The settings surface owns no `HostStore` — it
|
|
/// only needs to COUNT what a delete is about to change, and reading the same App-Group blob
|
|
/// the widget reads beats threading a store through a separate macOS Settings scene.
|
|
static func savedHosts() -> [StoredHost] {
|
|
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
|
|
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
|
|
else { return [] }
|
|
return hosts
|
|
}
|
|
|
|
// MARK: - Overrides
|
|
|
|
/// Record an override, always by explicit write — never by comparing the new value against
|
|
/// today's global. A value that happens to equal the global is a legitimate PIN: the profile
|
|
/// keeps it when the global later moves, and that is the whole difference between this feature
|
|
/// and "copy the settings" (§4.1).
|
|
func setOverride<Value>(
|
|
_ id: String, _ keyPath: WritableKeyPath<SettingsOverlay, Value?>, _ value: Value
|
|
) {
|
|
guard let i = catalog.profiles.firstIndex(where: { $0.id == id }) else { return }
|
|
catalog.profiles[i].overrides[keyPath: keyPath] = value
|
|
}
|
|
|
|
/// The only way back to inheriting: an explicit per-row reset. `field` is the overlay's own
|
|
/// serialized name, with `resolution` covering the width/height/match-window tri-state.
|
|
func clearOverride(_ id: String, field: String) {
|
|
guard let i = catalog.profiles.firstIndex(where: { $0.id == id }) else { return }
|
|
OverlayField.clear(field, in: &catalog.profiles[i].overrides)
|
|
}
|
|
}
|