diff --git a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift index a1035458..3d65085b 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift @@ -26,7 +26,7 @@ struct BlurtiOSApp: App { // touches neither the Keychain nor the App Group (the Mac's UITestSupport // does the same for its harness), and `start()` below never runs. let coordinator = - Self.isTestHost + Self.isTestHost || Self.isKeyless ? DictationCoordinator(apiKey: APIKeyModel(keyStore: InMemoryAPIKeyStore())) : DictationCoordinator() _coordinator = State(initialValue: coordinator) } @@ -35,6 +35,17 @@ struct BlurtiOSApp: App { ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil } + /// `-BlurtNoKey`: a fresh install's view of the key, in memory, so the + /// first-run screen can be screenshotted without touching the Keychain's; + /// debug builds only. + private static var isKeyless: Bool { + #if DEBUG + CommandLine.arguments.contains("-BlurtNoKey") + #else + false + #endif + } + var body: some Scene { WindowGroup { #if DEBUG @@ -63,7 +74,7 @@ struct BlurtiOSApp: App { } private var home: some View { - MainView(coordinator: coordinator) + RootView(coordinator: coordinator) .task { await start() } } diff --git a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift index 59f2477f..55cb1ccf 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift @@ -7,8 +7,9 @@ import SwiftUI /// The Dictate tab: the Blurt landing page on a phone. The wordmark and /// the gear across the top; the lifetime stats; the status as an eyebrow, a serif headline and a /// line of body text over the voice element; the one green button; then the -/// styles and the recent dictations under their eyebrows. Setup sits on top -/// while anything is missing; everything adjustable lives behind the gear. +/// styles and the recent dictations under their eyebrows. Setup comes before +/// the tabs (`SetupView`) and lives in Settings after; everything adjustable +/// is behind the gear. struct HomeView: View { var coordinator: DictationCoordinator /// Opens Settings, which `MainView` presents over either tab. @@ -16,7 +17,6 @@ struct HomeView: View { @Environment(\.scenePhase) private var scenePhase @State private var microphoneGranted = AVAudioApplication.shared.recordPermission == .granted @State private var keyboardSeen = SharedStore.keyboardEverSeen - @State private var showsKeyEntry = false /// When the last dictation's words landed — the hero's glint. @State private var landedAt: Date? /// TEMPORARY: the mic concept from Settings, so the hero shows the one @@ -35,11 +35,6 @@ struct HomeView: View { ScrollView { VStack(alignment: .leading, spacing: DesignTokens.Metrics.appSectionGap) { header - if !status.isSetUp { - SetupCard( - hasKey: coordinator.apiKey.hasAPIKey, microphoneGranted: microphoneGranted, keyboardSeen: keyboardSeen, - enterKey: { showsKeyEntry = true }, refresh: refreshStatus) - } StatsCard() HomeHero(coordinator: coordinator, status: status, landedAt: landedAt) StyleChips() @@ -53,7 +48,6 @@ struct HomeView: View { .page() .environment(\.voiceElementKind, VoiceElementKind(rawValue: voiceRaw) ?? .shipped) .toolbar(.hidden, for: .navigationBar) - .sheet(isPresented: $showsKeyEntry) { KeyEntryView(apiKey: coordinator.apiKey) } .onChange(of: scenePhase) { _, phase in if phase == .active { refreshStatus() } } diff --git a/App/BlurtiOS/BlurtiOS/Sources/MainView.swift b/App/BlurtiOS/BlurtiOS/Sources/MainView.swift index ac615bf4..cdb4c0c3 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/MainView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/MainView.swift @@ -1,7 +1,30 @@ +import AVFoundation import BlurtDesign import BlurtiOSCore import SwiftUI +/// The first-run setup screen until Blurt is set up, then the tabs +/// (`SetupProgress`). Whoever was already set up when this arrived goes +/// straight to the tabs: the check is taken once, at launch, so finishing the +/// last step on the setup screen doesn't yank it away mid-read. +struct RootView: View { + var coordinator: DictationCoordinator + @AppStorage(SetupProgress.finishedKey) private var finished = false + @State private var wasSetUpAtLaunch = + AVAudioApplication.shared.recordPermission == .granted && SharedStore.keyboardEverSeen + + var body: some View { + if SetupProgress.needsSetup( + hasKey: coordinator.apiKey.hasAPIKey, isSetUp: wasSetUpAtLaunch, finished: finished) + { + SetupView(coordinator: coordinator) + .onOpenURL { coordinator.handle($0) } + } else { + MainView(coordinator: coordinator) + } + } +} + /// The app: the Dictate and Words tabs under the brand tab bar, plus what has /// to work whichever tab is showing — Settings from either tab's gear, the /// consent prompt the keyboard's `blurt://start` can raise, and a shared diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index 78c9ff40..a4b89913 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -1,3 +1,4 @@ +import AVFoundation import BlurtDesign import BlurtEngine import BlurtiOSCore @@ -21,6 +22,10 @@ struct SettingsView: View { @State private var showsKeyEntry = false @State private var confirmsStatsReset = false @State private var showsTypingTest = false + @State private var showsSetupSteps = false + @State private var microphoneGranted = AVAudioApplication.shared.recordPermission == .granted + @State private var keyboardSeen = SharedStore.keyboardEverSeen + @Environment(\.scenePhase) private var scenePhase @AppStorage(TypingSpeed.defaultsKey) private var typingSpeed: Double = 0 @AppStorage(EnhancedTranscriptsStore.defaultsKey) private var enhancedTranscripts = EnhancedTranscriptsStore.defaultValue @@ -28,6 +33,7 @@ struct SettingsView: View { var body: some View { NavigationStack { Form { + setupSection keyboardSection listeningSection transcriptionSection @@ -45,6 +51,9 @@ struct SettingsView: View { // On the form, not on a section: a section's modifiers apply to each of // its rows, so a cover there was presented once per row at the same time. .fullScreenCover(isPresented: $showsTypingTest) { TypingTestView() } + .onChange(of: scenePhase) { _, phase in + if phase == .active { refreshSetup() } + } .confirmationDialog("Reset statistics?", isPresented: $confirmsStatsReset, titleVisibility: .visible) { Button("Reset", role: .destructive) { DictationStats.reset() } } message: { @@ -54,6 +63,43 @@ struct SettingsView: View { .tint(BlurtBrand.accent) } + /// The first-run steps, kept here once the app is set up, as in the + /// blurt-ios prototype: folded to a line while everything is done, open while + /// something (the microphone, the keyboard) is still missing. + private var setupSection: some View { + let isSetUp = coordinator.apiKey.hasAPIKey && microphoneGranted && keyboardSeen + return Section { + if isSetUp { + HStack { + Text("Blurt is set up") + Image(systemName: "checkmark").foregroundStyle(BlurtBrand.accent) + Spacer() + Button(showsSetupSteps ? "Hide steps" : "Show steps") { showsSetupSteps.toggle() } + .font(BlurtType.body(DesignTokens.Typography.sizeCaption, bold: true)) + .foregroundStyle(BlurtBrand.accent) + .buttonStyle(.plain) + } + } + if !isSetUp || showsSetupSteps { + SetupSteps( + hasKey: coordinator.apiKey.hasAPIKey, microphoneGranted: microphoneGranted, keyboardSeen: keyboardSeen, + refresh: refreshSetup + ) { + Button("Add an API key") { showsKeyEntry = true }.buttonStyle(BrandButtonStyle()) + } + .padding(.vertical, DesignTokens.Metrics.appRowPadY) + } + } header: { + Eyebrow("Setup") + } + } + + private func refreshSetup() { + microphoneGranted = AVAudioApplication.shared.recordPermission == .granted + keyboardSeen = SharedStore.keyboardEverSeen + coordinator.apiKey.refreshStatus() + } + private var keyboardSection: some View { Section { Picker("Layout", selection: $layout) { diff --git a/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift b/App/BlurtiOS/BlurtiOS/Sources/SetupSteps.swift similarity index 68% rename from App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift rename to App/BlurtiOS/BlurtiOS/Sources/SetupSteps.swift index f64ed81c..d53bd720 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SetupSteps.swift @@ -1,38 +1,29 @@ import AVFoundation import BlurtDesign -import BlurtEngine import SwiftUI import UIKit -/// The setup checklist, on top of the home screen while anything is missing: -/// three numbered steps — an AssemblyAI API key, the microphone, the keyboard — -/// each with its control until it is done. -struct SetupCard: View { +/// The setup steps, numbered: an AssemblyAI API key, the microphone, the +/// keyboard, each with its control until it is done and a check after. The +/// first-run screen shows them in a card; Settings keeps them once the app is +/// set up, folded away until asked for. +struct SetupSteps: View { let hasKey: Bool let microphoneGranted: Bool let keyboardSeen: Bool - /// Every build takes an API key, as the Mac app does, until Sign in with - /// AssemblyAI exists. - let enterKey: () -> Void /// Re-read the permissions after one was asked for. let refresh: () -> Void + /// What the key step offers while there's no key: the inline field on the + /// first-run screen. + @ViewBuilder let keyStep: () -> KeyStep var body: some View { VStack(alignment: .leading, spacing: DesignTokens.Metrics.appStackGap) { - Eyebrow("Set up Blurt") - SetupRow(number: "01", done: hasKey, title: "Add your AssemblyAI API key") { - VStack(alignment: .leading, spacing: DesignTokens.Metrics.appLineGap) { - Text("Blurt uses your own AssemblyAI account. New accounts come with free credits.") - .font(BlurtType.body(DesignTokens.Typography.sizeCaption)).foregroundStyle(BlurtBrand.muted) - Button("Paste your key") { enterKey() }.buttonStyle(BrandButtonStyle(role: .primary)) - Link("Get a free key", destination: APIKeyStore.dashboardURL) - .font(BlurtType.body(DesignTokens.Typography.sizeCaption)) - } - } + SetupRow(number: "01", done: hasKey, title: "Add your AssemblyAI API key", action: keyStep) SetupRow(number: "02", done: microphoneGranted, title: "Allow the microphone") { if AVAudioApplication.shared.recordPermission == .denied { // iOS asks once; after a refusal only Settings can change it. - Button("Allow in Settings") { Self.openAppSettings() }.buttonStyle(BrandButtonStyle(role: .primary)) + Button("Allow in Settings") { SystemSettings.open() }.buttonStyle(BrandButtonStyle(role: .primary)) } else { Button("Allow") { Task { @@ -56,16 +47,17 @@ struct SetupCard: View { + "Full Access is what lets the keyboard send your words to Blurt." ) .font(BlurtType.body(DesignTokens.Typography.sizeCaption)).foregroundStyle(BlurtBrand.muted) - Button("Open Settings") { Self.openAppSettings() }.buttonStyle(BrandButtonStyle(role: .secondary)) + Button("Open Settings") { SystemSettings.open() }.buttonStyle(BrandButtonStyle(role: .secondary)) } } } - .padding(DesignTokens.Metrics.appCardPad) - .frame(maxWidth: .infinity, alignment: .leading) - .card() } - static func openAppSettings() { +} + +/// Blurt's page in the Settings app. +enum SystemSettings { + static func open() { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) } diff --git a/App/BlurtiOS/BlurtiOS/Sources/SetupView.swift b/App/BlurtiOS/BlurtiOS/Sources/SetupView.swift new file mode 100644 index 00000000..541a2db7 --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/SetupView.swift @@ -0,0 +1,121 @@ +import AVFoundation +import BlurtDesign +import BlurtEngine +import BlurtiOSCore +import SwiftUI + +/// The first-run screen, in place of the tabs until Blurt is set up, as in the +/// blurt-ios prototype: the wordmark, a headline, and the three steps in a +/// card. The key comes first and can't be skipped, since nothing works without +/// it; once it is in, the user can start right away and finish the microphone +/// and the keyboard later from Settings, where the steps move. +struct SetupView: View { + var coordinator: DictationCoordinator + @Environment(\.scenePhase) private var scenePhase + @AppStorage(SetupProgress.finishedKey) private var finished = false + @State private var microphoneGranted = AVAudioApplication.shared.recordPermission == .granted + @State private var keyboardSeen = SharedStore.keyboardEverSeen + + private var hasKey: Bool { coordinator.apiKey.hasAPIKey } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appSectionGap) { + Wordmark() + .frame(height: DesignTokens.Metrics.appHeaderHeight) + Text( + hasKey ? "Two more steps and you can dictate anywhere." : "Paste an AssemblyAI API key to start dictating." + ) + .font(BlurtType.heading(DesignTokens.Typography.sizeTitle)) + .foregroundStyle(BlurtBrand.text) + .fixedSize(horizontal: false, vertical: true) + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appStackGap) { + Eyebrow("Set up Blurt") + SetupSteps( + hasKey: hasKey, microphoneGranted: microphoneGranted, keyboardSeen: keyboardSeen, refresh: refresh + ) { + KeyStep(apiKey: coordinator.apiKey) + } + } + .padding(DesignTokens.Metrics.appCardPad) + .frame(maxWidth: .infinity, alignment: .leading) + .card() + if hasKey { + Button("Start dictating") { finished = true } + .buttonStyle(BrandButtonStyle()) + Text("You can finish these later in Settings.") + .brandFootnote() + } + } + .padding(.horizontal, DesignTokens.Metrics.appPagePad) + .padding(.bottom, DesignTokens.Metrics.appSectionGap) + } + .scrollDismissesKeyboard(.interactively) + .page() + .tint(BlurtBrand.accent) + .onChange(of: scenePhase) { _, phase in + if phase == .active { refresh() } + } + } + + private func refresh() { + microphoneGranted = AVAudioApplication.shared.recordPermission == .granted + keyboardSeen = SharedStore.keyboardEverSeen + coordinator.apiKey.refreshStatus() + } +} + +/// Paste a key, checked with AssemblyAI before it is saved (`APIKeySubmission` +/// owns that rule), as on the prototype's first screen: the field, Continue, +/// Get a free key, and what happens to the key and the audio. +private struct KeyStep: View { + var apiKey: APIKeyModel + @Environment(\.openURL) private var openURL + @State private var draft = "" + @State private var isChecking = false + @State private var errorMessage: String? + + var body: some View { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appStackGap) { + SecureField("Paste your API key", text: $draft) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.go) + .onSubmit { Task { await submit() } } + .brandInput() + if let errorMessage { + Text(errorMessage) + .font(BlurtType.body(DesignTokens.Typography.sizeCaption)) + .foregroundStyle(BlurtBrand.errorOrange) + .fixedSize(horizontal: false, vertical: true) + } + // Stacked, not side by side: at half the card's width the mono labels + // wrap ("GET A / FREE KEY"). + VStack(spacing: DesignTokens.Metrics.appChipGap) { + Button(isChecking ? "Checking…" : "Continue") { Task { await submit() } } + .buttonStyle(BrandButtonStyle()) + .disabled(isChecking || draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + Button("Get a free key") { openURL(APIKeyStore.dashboardURL) } + .buttonStyle(BrandButtonStyle(role: .secondary)) + } + Text( + "Blurt sends what you dictate to AssemblyAI to transcribe it, using this key. " + + "New accounts come with free credits. The key is stored in this iPhone's Keychain." + ) + .brandFootnote() + } + } + + private func submit() async { + guard !isChecking else { return } + isChecking = true + defer { isChecking = false } + switch await apiKey.submit(draft).failureReport { + case .none: + errorMessage = nil + draft = "" + case .some(.inline(let message)): errorMessage = message + case .some(.alert(let title, let message)): errorMessage = "\(title) \(message)" + } + } +} diff --git a/App/BlurtiOS/BlurtiOSCore/SetupProgress.swift b/App/BlurtiOS/BlurtiOSCore/SetupProgress.swift new file mode 100644 index 00000000..6ccc8be6 --- /dev/null +++ b/App/BlurtiOS/BlurtiOSCore/SetupProgress.swift @@ -0,0 +1,14 @@ +/// Whether the app opens on its first-run setup screen or on its tabs, as in +/// the blurt-ios prototype: setup first, then the app, with the steps moving +/// into Settings once they are done. +package nonisolated enum SetupProgress { + /// Set when the user leaves the setup screen with a key in place, so the + /// steps they skipped (the microphone, the keyboard) don't hold the app back. + package static let finishedKey = "SetupFinished" + + /// The setup screen shows without a key, which nothing works without, and + /// until the user has either finished every step or chosen to start anyway. + package static func needsSetup(hasKey: Bool, isSetUp: Bool, finished: Bool) -> Bool { + !hasKey || (!finished && !isSetUp) + } +} diff --git a/App/BlurtiOS/BlurtiOSTests/SetupProgressTests.swift b/App/BlurtiOS/BlurtiOSTests/SetupProgressTests.swift new file mode 100644 index 00000000..110ee641 --- /dev/null +++ b/App/BlurtiOS/BlurtiOSTests/SetupProgressTests.swift @@ -0,0 +1,15 @@ +import Testing + +@testable import BlurtiOSCore + +@Suite("First-run setup") +struct SetupProgressTests { + @Test("no key always means the setup screen; a key plus finishing or every step done means the tabs") + func routing() { + #expect(SetupProgress.needsSetup(hasKey: false, isSetUp: false, finished: true)) + #expect(SetupProgress.needsSetup(hasKey: false, isSetUp: true, finished: true)) + #expect(SetupProgress.needsSetup(hasKey: true, isSetUp: false, finished: false)) + #expect(!SetupProgress.needsSetup(hasKey: true, isSetUp: false, finished: true)) + #expect(!SetupProgress.needsSetup(hasKey: true, isSetUp: true, finished: false)) + } +} diff --git a/App/BlurtiOS/DESIGN.md b/App/BlurtiOS/DESIGN.md index b45c49b6..7da9f358 100644 --- a/App/BlurtiOS/DESIGN.md +++ b/App/BlurtiOS/DESIGN.md @@ -725,13 +725,22 @@ accent over its replacement, × to remove, between hairlines; two inputs and `ADD SHORTCUT` (re-adding a phrase replaces it). Brand buttons fade while disabled (`opacity/disabled`). -**Home** (`HomeView`, `HomeHero`, `SetupCard`, `StyleChips`, `RecentSection`, -`HomeStatus` for the words; `StatsCard`): the setup card only while something is missing, -then the lifetime stats — `DICTATIONS`, `WORDS`, `TIME SAVED` (against 36 wpm, -the phone average) as serif numbers with mono units, one row until time saved -reaches an hour and then two, at the largest of four sizes that fits (reset -in Settings › Data); -its three steps numbered `01 02 03` in mono; the hero — `STATUS`, the +**First run** (`RootView`, `SetupView`, `SetupSteps`, `SetupProgress`): in +place of the tabs until Blurt is set up — the wordmark, a serif headline, and +`SET UP BLURT` in a card, its three steps numbered `01 02 03` in mono (a check +once done): the API key inline (a secure field, `CONTINUE`, `GET A FREE KEY`, +and where the key and the audio go), the microphone, the keyboard. Without a +key it can't be left; with one, `START DICTATING` goes to the tabs and the rest +can wait. Afterwards the steps live in Settings › Setup, folded to "Blurt is +set up ✓ · Show steps" while everything is done and open while anything isn't. +Whoever was already set up skips it. + +**Home** (`HomeView`, `HomeHero`, `StyleChips`, `RecentSection`, +`HomeStatus` for the words; `StatsCard`): the lifetime stats — `DICTATIONS`, +`WORDS`, `TIME SAVED` (against 36 wpm, the phone average, or the easter egg's +result) as serif numbers with mono units, one row until time saved reaches an +hour and then two, at the largest of four sizes that fits (reset in Settings › +Data); the hero — `STATUS`, the headline (Not listening · Ready to dictate · Connecting… · Listening… · Transcribing… · Pasted · Copied · the error), the line under it, the voice element at home size, `START LISTENING` (refused without a key, with a line