From fec64ff07b4a5126c46df2ff96a350af939c8483 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Thu, 16 Jul 2026 11:47:29 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(setup):=20first-run=20WiFi=20provision?= =?UTF-8?q?ing=20core=20=E2=80=94=20SetupNetwork,=20provisionWifi,=20Setup?= =?UTF-8?q?ViewModel=20(T-026)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tested guts of first-run device setup: a fresh device on its SoftAP is reached, confirmed, and handed the user's WiFi — no LAN discovery involved (which is why it was invisible before). SetupNetwork the fixed setup address (192.168.4.1) + the fleet's setup SSIDs (KitchenSync-Setup / KSTouch-Config), matched as a prefix family like DeviceMatch. For the setup UI + a future NEHotspotConfiguration join. KitchenSyncClient provisionWifi(ssid:password:) — POSTs ONLY the WiFi fields to /save (a partial form, no full_form), so setting a device up can never clobber its other settings. SetupViewModel the flow's decision logic: search (confirm a device answers /config.json on the setup AP) then provision. Transport-agnostic so BLE (T-027) can swap the credential transport without touching the flow. 11 tests, all through the REAL client against StubURLProtocol (setup-AP identity, the partial /save never sending full_form, the search/provision/blank/failure states). Also files T-027: BLE provisioning as the smoother premium path (no AP juggling), gated on the ESP-026 ESP-IDF convergence — SoftAP here stays the baseline + fallback. Next: the setup screen + the "Set up a new device" entry on the empty discovery state. Claude-Session: https://claude.ai/code/session_01F7YsQgso3KNpeXbP5vNVj6 --- .../Discovery/SetupNetwork.swift | 28 ++++++++ .../Networking/KitchenSyncClient.swift | 9 +++ .../ViewModels/SetupViewModel.swift | 57 ++++++++++++++++ .../ProvisioningTests.swift | 53 +++++++++++++++ .../SetupNetworkTests.swift | 28 ++++++++ .../SetupViewModelTests.swift | 66 +++++++++++++++++++ tasks/T-027.md | 65 ++++++++++++++++++ 7 files changed, 306 insertions(+) create mode 100644 Sources/KitchenSyncApp/Discovery/SetupNetwork.swift create mode 100644 Sources/KitchenSyncApp/ViewModels/SetupViewModel.swift create mode 100644 Tests/KitchenSyncAppTests/ProvisioningTests.swift create mode 100644 Tests/KitchenSyncAppTests/SetupNetworkTests.swift create mode 100644 Tests/KitchenSyncAppTests/SetupViewModelTests.swift create mode 100644 tasks/T-027.md diff --git a/Sources/KitchenSyncApp/Discovery/SetupNetwork.swift b/Sources/KitchenSyncApp/Discovery/SetupNetwork.swift new file mode 100644 index 0000000..8ba2011 --- /dev/null +++ b/Sources/KitchenSyncApp/Discovery/SetupNetwork.swift @@ -0,0 +1,28 @@ +import Foundation + +/// A device that has never joined the user's network comes up in **SoftAP setup mode**, +/// broadcasting its own open WiFi and serving its config web server at a fixed address. This is +/// where first-run provisioning happens (T-026): the app reaches the device *on its own AP*, +/// confirms it's ours, and hands it the user's WiFi credentials. +/// +/// Pure constants + an SSID test, so the setup UI and any `NEHotspotConfiguration` join can be +/// asserted without standing up a radio — the same split `DeviceMatch` uses for LAN discovery. +enum SetupNetwork { + /// The address the firmware serves its web server on in SoftAP mode — fixed across the + /// fleet (`wifi_link.c` / `KitchenSyncTouch.ino`, both at `192.168.4.1`). + static let host = "192.168.4.1" + + /// The setup SSIDs the fleet broadcasts, by product: + /// - `KitchenSync-Setup` — the P4 (`wifi_link.c` `AP_SSID`) + /// - `KSTouch-Config` — the Touch (`KitchenSyncTouch.ino` `WiFi.softAP`) + /// Matched as a **family of prefixes**, not one string, so a new product's setup AP is + /// recognised without an app change — the same reason `DeviceMatch` matches host prefixes. + static let ssidPrefixes = ["kitchensync-setup", "kstouch-config"] + + /// True if `ssid` is one of our devices' setup APs. Case-insensitive: a WiFi SSID's case is + /// not meaningful, and iOS reports it however the radio saw it. + static func isSetupAP(ssid: String) -> Bool { + let lower = ssid.lowercased() + return ssidPrefixes.contains { lower.hasPrefix($0) } + } +} diff --git a/Sources/KitchenSyncApp/Networking/KitchenSyncClient.swift b/Sources/KitchenSyncApp/Networking/KitchenSyncClient.swift index df63b8e..f2af5b9 100644 --- a/Sources/KitchenSyncApp/Networking/KitchenSyncClient.swift +++ b/Sources/KitchenSyncApp/Networking/KitchenSyncClient.swift @@ -82,6 +82,15 @@ struct KitchenSyncClient { try await postForm("/save", fields: config.saveFormFields(wifiEdits: wifiEdits)) } + /// T-026: first-run WiFi provisioning. POSTs ONLY the credentials to `/save` — a partial + /// form (no `full_form`), so the firmware leaves every other setting alone and this can + /// never clobber a device's config while merely joining it to a network. The device stores + /// the credentials and reboots to join. Point the client at `SetupNetwork.host` — the + /// device's own SoftAP — for this, since it isn't on the LAN yet. + func provisionWifi(ssid: String, password: String) async throws { + try await postForm("/save", fields: ["wifi_ssid": ssid, "wifi_pass": password]) + } + /// POST /transport?out=N|all&play=1|0 — quantized Start/Stop (ESP-011). /// Takes effect on the next bar line; poll `/status.launch[N]` to watch it /// arm, then run. diff --git a/Sources/KitchenSyncApp/ViewModels/SetupViewModel.swift b/Sources/KitchenSyncApp/ViewModels/SetupViewModel.swift new file mode 100644 index 0000000..07e1f9d --- /dev/null +++ b/Sources/KitchenSyncApp/ViewModels/SetupViewModel.swift @@ -0,0 +1,57 @@ +import Foundation + +/// T-026: first-run setup. When a device has never joined the user's network it comes up on its +/// own SoftAP (`SetupNetwork`); the app reaches it there, confirms it's ours, and hands it the +/// user's WiFi credentials over a partial `POST /save`. The device then reboots to join and the +/// device list takes over the reconnect + rediscovery. +/// +/// Transport-agnostic on purpose (T-027): the *decision* logic — confirm, then provision — is +/// the same whether the credential transport is HTTP-over-SoftAP (today) or BLE (later); only +/// `provision` swaps. +@MainActor +final class SetupViewModel: ObservableObject { + enum Phase: Equatable { + case idle + case searching + case foundDevice // a device answered on the setup AP — safe to offer setup + case noDevice // nothing ours at the setup address + case provisioning + case provisioned // creds sent; the device is rebooting to join + case failed(String) + } + + @Published private(set) var phase: Phase = .idle + + private let session: URLSession + init(session: URLSession = .shared) { self.session = session } + + private var client: KitchenSyncClient { + KitchenSyncClient(host: SetupNetwork.host, session: session) + } + + /// Confirm we're actually on one of our devices' setup APs by reaching its config server — + /// so the setup screen only appears for a real device, not an empty or foreign AP. + func search() async { + phase = .searching + do { + _ = try await client.fetchConfig() + phase = .foundDevice + } catch { + phase = .noDevice + } + } + + /// Send the user's network to the device (WiFi fields only — never clobbers other settings). + /// A blank SSID isn't an attempt; a failure surfaces so the user knows it didn't take. + func provision(ssid: String, password: String) async { + let ssid = ssid.trimmingCharacters(in: .whitespacesAndNewlines) + guard !ssid.isEmpty else { return } + phase = .provisioning + do { + try await client.provisionWifi(ssid: ssid, password: password) + phase = .provisioned + } catch { + phase = .failed(error.localizedDescription) + } + } +} diff --git a/Tests/KitchenSyncAppTests/ProvisioningTests.swift b/Tests/KitchenSyncAppTests/ProvisioningTests.swift new file mode 100644 index 0000000..07409dd --- /dev/null +++ b/Tests/KitchenSyncAppTests/ProvisioningTests.swift @@ -0,0 +1,53 @@ +import XCTest +@testable import KitchenSyncApp + +/// T-026: provisioning a device's WiFi is a `POST /save` with ONLY the credentials — a partial +/// form. The device is being set up for the first time; the app must not send `full_form` or +/// any other field, or it would reset settings it can't even see yet. Tested through the REAL +/// client against `StubURLProtocol`, so the actual URL + form encoding is what's asserted. +final class ProvisioningTests: XCTestCase { + override func setUp() { StubURLProtocol.reset() } + + private func client() -> KitchenSyncClient { + KitchenSyncClient(host: SetupNetwork.host, session: StubURLProtocol.session()) + } + + private func formFields(_ body: Data) -> [String: String] { + var out: [String: String] = [:] + for pair in (String(data: body, encoding: .utf8) ?? "").split(separator: "&") { + let kv = pair.split(separator: "=", maxSplits: 1).map(String.init) + let key = kv[0].removingPercentEncoding ?? kv[0] + let raw = kv.count > 1 ? kv[1].replacingOccurrences(of: "+", with: " ") : "" + out[key] = raw.removingPercentEncoding ?? raw + } + return out + } + + func test_provisioning_posts_only_the_wifi_fields_to_save() async throws { + StubURLProtocol.routes["POST /save"] = .init() + try await client().provisionWifi(ssid: "StudioNet", password: "downbeat99") + + let req = try XCTUnwrap(StubURLProtocol.requests.first) + XCTAssertEqual(req.method, "POST") + XCTAssertEqual(req.url.path, "/save") + XCTAssertEqual(req.url.host, "192.168.4.1") + + let fields = formFields(req.body) + XCTAssertEqual(fields["wifi_ssid"], "StudioNet") + XCTAssertEqual(fields["wifi_pass"], "downbeat99") + // The whole point: a partial form. Sending full_form (or another field) would reset + // the device's other settings while merely joining it to WiFi. + XCTAssertNil(fields["full_form"], "provisioning must never send full_form") + XCTAssertNil(fields["clock_out"]) + } + + func test_a_save_error_during_provisioning_surfaces() async { + StubURLProtocol.routes["POST /save"] = .init(status: 500) + do { + try await client().provisionWifi(ssid: "X", password: "Y") + XCTFail("a 500 from /save must not be swallowed") + } catch { + // expected — the setup flow needs to know provisioning failed + } + } +} diff --git a/Tests/KitchenSyncAppTests/SetupNetworkTests.swift b/Tests/KitchenSyncAppTests/SetupNetworkTests.swift new file mode 100644 index 0000000..aa755e5 --- /dev/null +++ b/Tests/KitchenSyncAppTests/SetupNetworkTests.swift @@ -0,0 +1,28 @@ +import XCTest +@testable import KitchenSyncApp + +/// T-026: a device in SoftAP setup mode isn't on the LAN, so the app recognises it by its +/// setup SSID (to guide/join the AP) and reaches it at the fixed setup address. +final class SetupNetworkTests: XCTestCase { + func test_the_fleet_setup_aps_are_recognised() { + XCTAssertTrue(SetupNetwork.isSetupAP(ssid: "KitchenSync-Setup")) // the P4 + XCTAssertTrue(SetupNetwork.isSetupAP(ssid: "KSTouch-Config")) // the Touch + } + + /// A WiFi SSID's case is not meaningful and iOS reports it however the radio saw it. + func test_matching_is_case_insensitive() { + XCTAssertTrue(SetupNetwork.isSetupAP(ssid: "kstouch-config")) + XCTAssertTrue(SetupNetwork.isSetupAP(ssid: "KITCHENSYNC-SETUP")) + } + + /// The user's own networks — and a stranger's — must never be mistaken for a device AP. + func test_a_normal_network_is_not_a_setup_ap() { + XCTAssertFalse(SetupNetwork.isSetupAP(ssid: "Skeyelab")) + XCTAssertFalse(SetupNetwork.isSetupAP(ssid: "linksys")) + XCTAssertFalse(SetupNetwork.isSetupAP(ssid: "")) + } + + func test_the_setup_host_is_the_fixed_softap_address() { + XCTAssertEqual(SetupNetwork.host, "192.168.4.1") + } +} diff --git a/Tests/KitchenSyncAppTests/SetupViewModelTests.swift b/Tests/KitchenSyncAppTests/SetupViewModelTests.swift new file mode 100644 index 0000000..fae5fd9 --- /dev/null +++ b/Tests/KitchenSyncAppTests/SetupViewModelTests.swift @@ -0,0 +1,66 @@ +import XCTest +@testable import KitchenSyncApp + +/// T-026: the first-run setup flow's decision logic — confirm a device is on its setup AP, +/// then hand it the user's WiFi. Driven through the real client against `StubURLProtocol`. +@MainActor +final class SetupViewModelTests: XCTestCase { + override func setUp() { StubURLProtocol.reset() } + + private static let configJSON = """ + {"clock_out":true, + "wifi":[{"ssid":"","pass_set":false}], + "clock":[{"en":true,"cable":0,"ppqn":24,"phase":0,"swing":0,"follow":true}]} + """.data(using: .utf8)! + + private func vm() -> SetupViewModel { + SetupViewModel(session: StubURLProtocol.session()) + } + + // A device on its setup AP answers /config.json — that's how the app confirms it's ours + // before offering setup (rather than showing a setup screen for an empty AP). + func test_search_confirms_a_device_when_config_answers() async { + StubURLProtocol.routes["GET /config.json"] = .init(body: Self.configJSON) + let vm = vm() + await vm.search() + XCTAssertEqual(vm.phase, .foundDevice) + } + + // Nothing at 192.168.4.1 (or not ours) → say so, don't pretend a device is there. + func test_search_reports_no_device_when_nothing_answers() async { + StubURLProtocol.routes["GET /config.json"] = .init(status: 404) + let vm = vm() + await vm.search() + XCTAssertEqual(vm.phase, .noDevice) + } + + // Provisioning hands the device the creds and lands in `provisioned` — the device is now + // rebooting to join; the list view takes over the reconnect + rediscovery. + func test_provision_success_posts_wifi_and_advances() async throws { + StubURLProtocol.routes["POST /save"] = .init() + let vm = vm() + await vm.provision(ssid: "StudioNet", password: "downbeat99") + XCTAssertEqual(vm.phase, .provisioned) + + let req = try XCTUnwrap(StubURLProtocol.requests.first) + XCTAssertEqual(req.url.path, "/save") + } + + // A failed /save must surface — the user needs to know it didn't take, not sit on a spinner. + func test_provision_failure_reports_it() async { + StubURLProtocol.routes["POST /save"] = .init(status: 500) + let vm = vm() + await vm.provision(ssid: "X", password: "Y") + guard case .failed = vm.phase else { + return XCTFail("expected .failed, got \(vm.phase)") + } + } + + // A blank SSID is not a provisioning attempt — don't fire a request or lie about success. + func test_blank_ssid_does_not_provision() async { + let vm = vm() + await vm.provision(ssid: "", password: "pw") + XCTAssertTrue(StubURLProtocol.requests.isEmpty) + XCTAssertNotEqual(vm.phase, .provisioned) + } +} diff --git a/tasks/T-027.md b/tasks/T-027.md new file mode 100644 index 0000000..b23c6e9 --- /dev/null +++ b/tasks/T-027.md @@ -0,0 +1,65 @@ +--- +id: T-027 +title: BLE Wi-Fi provisioning — the smooth first-run path (no AP juggling) +status: todo +assignee: null +priority: medium +tags: + - onboarding + - bluetooth + - firmware-dependency +depends_on: + - T-026 +--- + +## Why (the SoftAP flow's worst step, removed) + +[T-026](T-026.md) provisions a new device over its SoftAP: the user leaves their network, +joins the device's open AP (no internet — iOS nags), types their SSID + password, then must +**remember to switch back** before Bonjour can rediscover the now-joined device. That WiFi +juggle is the fiddliest, most-abandoned step in onboarding. + +**BLE provisioning removes it entirely.** The phone stays on its normal WiFi the whole time; +credentials travel over a separate radio. The device can **scan and return the nearby SSIDs**, +so the user picks from a list instead of typing. And it's more secure — the SoftAP posts the +WiFi password over an unencrypted local link; BLE provisioning is encrypted with a +proof-of-possession. This is the HomeKit/AirPods-class first-run experience. + +Keep T-026 as the **baseline + fallback** — it needs zero firmware change and covers any device +or moment where BLE isn't available. Most IoT products ship BLE-primary *with* a SoftAP +fallback. This ticket is the premium path layered on top, not a replacement. + +## Firmware dependency (gating) + +- BLE provisioning is an **ESP-IDF** capability (`wifi_provisioning` + `scheme_ble`). It lands + naturally **with the ESP-026 convergence** (the Touch moving off Arduino to ESP-IDF), not + before — track the firmware half there or in its own `ESP-*` ticket when scheduled. +- **Native on the S3** (Touch / Super Mini have BLE 5). The **P4 is harder**: its WiFi/BLE come + through the C6 co-processor, so provisioning there is more involved — scope the S3 first. +- Costs RAM (BLE+WiFi coexistence) and flash; fine on the S3. Needs a proof-of-possession / + QR-code security scheme decided with the firmware. + +## App side + +- Use Espressif's **`ESPProvision`** Swift SDK (SPM/CocoaPods) or CoreBluetooth by hand against + the ESP provisioning protocol (`prov-session` → `prov-scan` → `prov-config`). +- Requires the **Bluetooth entitlement** + `NSBluetoothAlwaysUsageDescription` in the Info.plist + (mirror how T-011 added the Local Network keys via `project.yml`). +- Reuse T-026's transport-agnostic guts: the device is still confirmed and shown the same way; + only the *credential transport* changes (BLE vs `POST /save`). Factor the setup flow so the + view model takes a provisioning transport, not a hard-coded HTTP call. + +## Acceptance criteria + +- [ ] From a factory-fresh device, the app provisions WiFi over BLE **without the phone ever + leaving its own network**. +- [ ] The device scans and the app shows nearby SSIDs; the user picks one and enters a password. +- [ ] Provisioning is encrypted (PoP), not plaintext. +- [ ] SoftAP (T-026) remains the automatic fallback when BLE isn't available. +- [ ] Firmware BLE provisioning is scoped to the S3 first; the P4 (C6) is a follow-on. + +## Notes + +Gated on ESP-026 (ESP-IDF convergence) for the firmware half — do not start the firmware work +before it. The app-side SDK spike can happen independently against Espressif's reference +firmware to de-risk `ESPProvision`. From 12823d5cc70dd24bb15319d9be5c0cf521a5febc Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Thu, 16 Jul 2026 11:49:13 -0400 Subject: [PATCH 2/2] feat(setup): first-run setup screen + "Set up a new device" entry (T-026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI over SetupViewModel: a sheet that searches for a device on its SoftAP, takes the user's WiFi (SSID + password), hands it over, and closes the loop by pointing them back to their own network where discovery picks the device up. Phases mirror the tested view model (searching / no-device-with-join-instructions / form / provisioned). The empty discovery state now leads with "Set up a new device" (the first-run affordance) with "Add by IP" demoted to secondary — a fresh device is on its own AP, invisible to Bonjour, so "add by IP" was never the answer. On-brand with the existing KS design tokens + KSField/KSPowerLED. Full app test suite green. Claude-Session: https://claude.ai/code/session_01F7YsQgso3KNpeXbP5vNVj6 --- .../KitchenSyncApp/Views/DeviceListView.swift | 13 +- .../Views/SetupDeviceView.swift | 137 ++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 Sources/KitchenSyncApp/Views/SetupDeviceView.swift diff --git a/Sources/KitchenSyncApp/Views/DeviceListView.swift b/Sources/KitchenSyncApp/Views/DeviceListView.swift index 299f16d..1012203 100644 --- a/Sources/KitchenSyncApp/Views/DeviceListView.swift +++ b/Sources/KitchenSyncApp/Views/DeviceListView.swift @@ -6,6 +6,7 @@ import SwiftUI struct DeviceListView: View { @StateObject private var vm = DeviceListViewModel() @State private var addingDevice = false + @State private var settingUpDevice = false /// The manual devices, as their own collection. The two sections are not just /// cosmetic: `removeManualDevices(at:)` takes offsets into the FILTERED manual @@ -42,6 +43,9 @@ struct DeviceListView: View { .sheet(isPresented: $addingDevice) { AddDeviceView { vm.addManualDevice(host: $0) } } + .sheet(isPresented: $settingUpDevice) { + SetupDeviceView() + } } .task { vm.start() } .onDisappear { vm.stop() } @@ -143,7 +147,10 @@ struct DeviceListView: View { .foregroundStyle(KS.mut) .multilineTextAlignment(.center) - Button("Add by IP") { addingDevice = true } + // First-run: a fresh device isn't on the LAN yet (it's on its own setup AP), so + // discovery can't find it — the user needs a path to set it up, not just "add by + // IP". This is the affordance that meets "the app sets up my device" (T-026). + Button("Set up a new device") { settingUpDevice = true } .font(.ksDisplay(14)) .tracking(1.4) .foregroundStyle(KS.onInk) @@ -151,6 +158,10 @@ struct DeviceListView: View { .padding(.vertical, 12) .background(KS.ledFill, in: Capsule()) .padding(.top, 6) + + Button("Add by IP") { addingDevice = true } + .font(.ksMono(12)) + .foregroundStyle(KS.mut) } .padding(32) .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Sources/KitchenSyncApp/Views/SetupDeviceView.swift b/Sources/KitchenSyncApp/Views/SetupDeviceView.swift new file mode 100644 index 0000000..6612201 --- /dev/null +++ b/Sources/KitchenSyncApp/Views/SetupDeviceView.swift @@ -0,0 +1,137 @@ +import SwiftUI + +/// T-026: first-run setup. Shown when the user joins a fresh device's SoftAP — the device isn't +/// on the LAN yet, so it never appears in discovery, and the app has to meet the "set up my +/// device" expectation here. Confirms a device is on the AP, takes the user's WiFi, hands it +/// over, then points them back to their own network where the device will reappear. +/// +/// The decisions live in `SetupViewModel` (tested); this is the thin surface over its phases. +struct SetupDeviceView: View { + @StateObject private var vm = SetupViewModel() + @Environment(\.dismiss) private var dismiss + @State private var ssid = "" + @State private var password = "" + + var body: some View { + NavigationStack { + VStack(spacing: 18) { + switch vm.phase { + case .idle, .searching: + searching + case .noDevice: + noDevice + case .foundDevice, .provisioning, .failed: + form + case .provisioned: + done + } + Spacer() + } + .padding() + .frame(maxWidth: .infinity, alignment: .top) + .background(KS.bg.ignoresSafeArea()) + .navigationTitle("Set up a device") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + } + .task { await vm.search() } + } + + // Confirming a device is actually answering on 192.168.4.1 before we offer a WiFi form. + private var searching: some View { + VStack(spacing: 14) { + ProgressView().tint(KS.ledDim) + Text("Looking for a device in setup mode…") + .font(.ksMono(13)).foregroundStyle(KS.mut) + .multilineTextAlignment(.center) + } + .padding(.top, 40) + } + + // The phone isn't on a device AP (or nothing answered). Tell them exactly what to join. + private var noDevice: some View { + VStack(spacing: 14) { + KSPowerLED(alive: false) + Text("No device found in setup mode") + .font(.ksMono(14)).foregroundStyle(KS.ink) + .multilineTextAlignment(.center) + Text("A new device broadcasts its own WiFi. In iOS Settings, join **KitchenSync-Setup** or **KSTouch-Config**, then come back.") + .font(.ksMono(12)).foregroundStyle(KS.mut) + .multilineTextAlignment(.center) + Button("Check again") { Task { await vm.search() } } + .font(.ksDisplay(14)).tracking(1.4) + .foregroundStyle(KS.onInk) + .padding(.horizontal, 20).padding(.vertical, 12) + .background(KS.ledFill, in: Capsule()) + .padding(.top, 6) + } + .padding(.top, 30) + } + + // The device is there — collect the network to put it on. + private var form: some View { + VStack(spacing: 16) { + Text("Enter the WiFi this device should join. It'll remember it and reconnect on its own from now on.") + .font(.ksMono(12)).foregroundStyle(KS.mut) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + + KSField(prefix: "SSID") { + TextField("Your WiFi network", text: $ssid) + .font(.ksMono(14)).foregroundStyle(KS.ink) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + KSField(prefix: "PASS") { + SecureField("Network password", text: $password) + .font(.ksMono(14)).foregroundStyle(KS.ink) + } + + if case .failed(let message) = vm.phase { + Text(message) + .font(.ksMono(11)).foregroundStyle(KS.ember) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Button { + Task { await vm.provision(ssid: ssid, password: password) } + } label: { + HStack(spacing: 8) { + if vm.phase == .provisioning { ProgressView().tint(KS.onInk) } + Text(vm.phase == .provisioning ? "Sending…" : "Join network") + } + .font(.ksDisplay(14)).tracking(1.4) + .foregroundStyle(KS.onInk) + .frame(maxWidth: .infinity) + .padding(.vertical, 13) + .background(KS.ledFill, in: Capsule()) + } + .disabled(ssid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.phase == .provisioning) + .padding(.top, 4) + } + } + + // Creds sent; the device is rebooting to join. Close the loop: send them back to their + // network, where discovery will pick the device up. + private var done: some View { + VStack(spacing: 14) { + KSPowerLED(alive: true) + Text("Sent to the device") + .font(.ksMono(14)).foregroundStyle(KS.ink) + Text("It's restarting to join **\(ssid)**. Reconnect this phone to that same WiFi, then it'll appear on the device list.") + .font(.ksMono(12)).foregroundStyle(KS.mut) + .multilineTextAlignment(.center) + Button("Done") { dismiss() } + .font(.ksDisplay(14)).tracking(1.4) + .foregroundStyle(KS.onInk) + .padding(.horizontal, 20).padding(.vertical, 12) + .background(KS.ledFill, in: Capsule()) + .padding(.top, 6) + } + .padding(.top, 30) + } +}