Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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
Expand Down Expand Up @@ -63,7 +74,7 @@ struct BlurtiOSApp: App {
}

private var home: some View {
MainView(coordinator: coordinator)
RootView(coordinator: coordinator)
.task { await start() }
}

Expand Down
12 changes: 3 additions & 9 deletions App/BlurtiOS/BlurtiOS/Sources/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ 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.
let showSettings: () -> Void
@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
Expand All @@ -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()
Expand All @@ -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() }
}
Expand Down
23 changes: 23 additions & 0 deletions App/BlurtiOS/BlurtiOS/Sources/MainView.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down
46 changes: 46 additions & 0 deletions App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AVFoundation
import BlurtDesign
import BlurtEngine
import BlurtiOSCore
Expand All @@ -21,13 +22,18 @@ 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

var body: some View {
NavigationStack {
Form {
setupSection
keyboardSection
listeningSection
transcriptionSection
Expand All @@ -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: {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<KeyStep: View>: 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 {
Expand All @@ -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)
}
Expand Down
121 changes: 121 additions & 0 deletions App/BlurtiOS/BlurtiOS/Sources/SetupView.swift
Original file line number Diff line number Diff line change
@@ -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)"
}
}
}
14 changes: 14 additions & 0 deletions App/BlurtiOS/BlurtiOSCore/SetupProgress.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading