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
3 changes: 3 additions & 0 deletions App/BlurtiOS/BlurtDesign/DesignTokens.swift
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ package enum DesignTokens {
package nonisolated static let appTabGap: CGFloat = 4
/// A tab in the brand tab bar: its icon over its mono label.
package nonisolated static let appTabHeight: CGFloat = 52
/// The least height of the home screen's "Try it here" box: two lines.
package nonisolated static let appTryBoxHeight: CGFloat = 64
/// The card's hairline.
package nonisolated static let cardBorder: CGFloat = 1
/// The app's cards (radius/card).
Expand Down Expand Up @@ -689,6 +691,7 @@ package enum DesignTokens {
"metrics.app/stat-gap": "16",
"metrics.app/tab-gap": "4",
"metrics.app/tab-height": "52",
"metrics.app/try-box-height": "64",
"metrics.card/border": "1",
"metrics.card/radius": "12",
"metrics.caret/height": "20",
Expand Down
74 changes: 60 additions & 14 deletions App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import BlurtiOSCore
import Foundation
import Observation
import Synchronization
import UIKit

/// The one place the engine is composed for the iPhone app — the role
/// `AppCoordinator` plays in the Mac shell. It owns the listening window (the
Expand All @@ -24,6 +25,13 @@ final class DictationCoordinator {
private(set) var recent = RecentDictations()
private(set) var level: Float = 0
private(set) var microphoneDenied = false
/// A window is opening: a second ask waits for it rather than opening another.
@ObservationIgnored private var opening = false
/// The keyboard brought the app forward from another app to start a
/// session (`blurt://start`): the home screen points at iOS's "◀ App" back
/// link until the user leaves. Not when the keyboard was already in Blurt's
/// own box: iOS shows no back link then.
var startedFromKeyboard = false
/// How many contact names the keyboard last read off the phone — the key
/// terms that come for free, so the home screen can say so.
private(set) var lexiconNameCount = 0
Expand All @@ -32,7 +40,7 @@ final class DictationCoordinator {
var pendingTermPack: TermPack?
/// A file or link that claimed to be a key-term list and wasn't.
var termPackUnreadable = false
/// The window can't open without a key: the last "Start listening" said so.
/// The window can't open without a key: the last try to enable Blurt said so.
var needsKey = false
/// The window can't open until the user agrees to AssemblyAI processing their
/// voice (`AIConsent`); `ConsentView` asks while this is set.
Expand Down Expand Up @@ -125,19 +133,51 @@ final class DictationCoordinator {
apiKey.refreshStatus()
needsKey = !apiKey.hasAPIKey
guard !needsKey else { return }
// Every way to the microphone comes through here — the button, the
// keyboard's `blurt://start`, and in-app dictation, which needs the window
// open — so this one gate covers them all.
// Every way to the microphone comes through here — the switch, the
// keyboard's `blurt://start`, and the app coming to the front — so this
// one gate covers them all.
needsConsent = !AIConsent.isGranted()
guard !needsConsent else { return }
guard await AVAudioApplication.requestRecordPermission() else {
microphoneDenied = true
return
}
microphoneDenied = false
// The keyboard's link and the app coming forward can both ask at once;
// the capture session is opened once.
guard !window.isOpen, !opening else { return }
opening = true
defer { opening = false }
await window.open()
}

/// Turns a session on by itself as the app comes to the front, so using the
/// Blurt keyboard needs no switch: once Blurt has been opened, the keyboard
/// works in place for the session's length. Never when it would ask the
/// user anything, and never against the switch (`SessionAutoStart`).
func startSessionIfReady() async {
guard
SessionAutoStart.shouldStart(
windowOpen: window.isOpen, hasKey: apiKey.hasAPIKey, consented: AIConsent.isGranted(),
micGranted: AVAudioApplication.shared.recordPermission == .granted,
turnedOff: SessionAutoStart.isTurnedOff())
else { return }
await startListening()
}

/// The switch turned on: the user asked for a session, so Blurt goes back to
/// turning one on by itself.
func turnSessionOn() async {
SessionAutoStart.setTurnedOff(false)
await startListening()
}

/// The switch turned off: closed now, and left off until the user asks again.
func turnSessionOff() async {
SessionAutoStart.setTurnedOff(true)
await stopListening()
}

/// The user agreed in `ConsentView`: record it and carry on opening the
/// window they asked for.
func grantConsent() async {
Expand Down Expand Up @@ -179,18 +219,12 @@ final class DictationCoordinator {
return
}
guard url.scheme == BlurtShared.urlScheme, url.host() == BlurtShared.startHost else { return }
startedFromKeyboard = UIApplication.shared.applicationState != .active
// The keyboard's mic asked for a session: that undoes an earlier off.
SessionAutoStart.setTurnedOff(false)
Task { await startListening() }
}

/// A dictation started from the app itself, with no keyboard to land in —
/// the words go to the clipboard. The way to try Blurt before the keyboard is
/// set up, and the way to test the pipeline without one. No field, so no
/// prior text primes it.
func toggleDictation() {
Self.pendingPress.withLock { $0 = Press() }
session.submit(phase.isCapturing ? .release : .press)
}

// MARK: - Commands from the keyboard

private func handleCommand() {
Expand Down Expand Up @@ -289,7 +323,7 @@ final class DictationCoordinator {
publish(.error(message: "Blurt needs an API key. Open Blurt to add one."))
return
}
publish(phase.overlayState)
publish(overlay)
}

/// Starts the clock when a recording starts and, when it stops, keeps how
Expand Down Expand Up @@ -333,6 +367,18 @@ final class DictationCoordinator {
}
}

/// The phase as the home screen and the keyboard show it: the engine's own
/// wording, except a press that found the session off, which says so plainly
/// rather than as "Audio capture failed: …".
var overlay: OverlayUIState {
if case .failed(.audioCaptureFailed(let underlying)) = phase,
underlying as? ListeningSourceFailure == .windowClosed
{
return .error(message: HomeStatus.sessionOffError)
}
return phase.overlayState
}

private func publish(_ state: OverlayUIState) {
let snapshot = PhaseSnapshot(
state: PhaseSnapshot.State(state), message: PhaseSnapshot.message(for: state), level: Double(level),
Expand Down
105 changes: 58 additions & 47 deletions App/BlurtiOS/BlurtiOS/Sources/HomeHero.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,72 +2,83 @@ import BlurtDesign
import BlurtiOSCore
import SwiftUI

/// The hero: the status as an eyebrow, a serif headline and a line of body
/// text, the voice element at hero size, and the one green button that opens
/// the mic (or the hairline one that closes it).
/// The session card: "Session on" or "Session
/// off" with the switch that turns it on and off, one line about it, and
/// "Try it here", a box to try the Blurt keyboard in without leaving the app
/// (`TryItBox`). The phases of a dictation stay on the keyboard, where the user
/// is typing; only a failure is said here.
struct HomeHero: View {
var coordinator: DictationCoordinator
let status: HomeStatus
/// When the last dictation's words landed — the hero's glint.
let landedAt: Date?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorScheme) private var colorScheme

private var state: VoiceState {
VoiceState(overlay: coordinator.phase.overlayState, windowOpen: coordinator.window.isOpen, level: coordinator.level)
}

var body: some View {
VStack(alignment: .leading, spacing: DesignTokens.Metrics.appHeroGap) {
Eyebrow("Status")
Text(status.title)
.font(BlurtType.heading(DesignTokens.Typography.sizeTitle))
.foregroundStyle(BlurtBrand.text)
Text(status.subtitle)
.font(BlurtType.body(DesignTokens.Typography.sizeBody))
.foregroundStyle(BlurtBrand.muted)
VoiceElementView(
inputs: VoiceElementInputs(
state: state, landedAt: landedAt, animated: !reduceMotion,
palette: colorScheme == .dark ? .brandDark : .brandLight, slot: .home)
)
.frame(width: VoiceSlot.home.box.width, height: VoiceSlot.home.box.height)
.frame(maxWidth: .infinity)
if coordinator.window.isOpen {
Button("Stop listening") {
Task { await coordinator.stopListening() }
}
.buttonStyle(BrandButtonStyle(role: .secondary))
} else {
Button("Start listening") {
Task { await coordinator.startListening() }
}
.buttonStyle(BrandButtonStyle(role: .primary))
.disabled(!coordinator.apiKey.hasAPIKey)
if coordinator.startedFromKeyboard, status.isOn {
ReturnHint()
}
Button(coordinator.phase.isCapturing ? "Stop and transcribe" : "Dictate here, to the clipboard") {
coordinator.toggleDictation()
// The session and its line read as one thing, so they sit close.
VStack(alignment: .leading, spacing: DesignTokens.Metrics.appLineGap) {
HStack {
Eyebrow(status.label, color: status.isOn ? BlurtBrand.accent : BlurtBrand.muted)
Spacer()
if coordinator.microphoneDenied {
Button("Open Settings") { SystemSettings.open() }
.buttonStyle(BrandButtonStyle())
.fixedSize()
} else {
Toggle("Session", isOn: session)
.labelsHidden()
.tint(BlurtBrand.accent)
.disabled(!coordinator.apiKey.hasAPIKey)
}
}
Text(status.message)
.font(BlurtType.body(DesignTokens.Typography.sizeBody))
.foregroundStyle(BlurtBrand.text)
.fixedSize(horizontal: false, vertical: true)
}
.font(BlurtType.body(DesignTokens.Typography.sizeCaption))
.foregroundStyle(BlurtBrand.muted)
.frame(maxWidth: .infinity)
.disabled(!coordinator.window.isOpen)
if let error = coordinator.window.lastError {
TryItBox()
if let error = status.error ?? coordinator.window.lastError {
note(error)
}
if coordinator.microphoneDenied {
note("Blurt needs the microphone. Allow it in Settings.")
}
if coordinator.needsKey {
note("Add your API key first (Settings → Account).")
}
}
.padding(DesignTokens.Metrics.appHeroPad)
.padding(DesignTokens.Metrics.appCardPad)
.frame(maxWidth: .infinity, alignment: .leading)
.card(radius: DesignTokens.Metrics.radiusHero)
}

/// The switch is the session, which is the listening window: on opens it
/// (with its key, consent and microphone gates), off closes it and keeps it
/// closed until the user asks again (`SessionAutoStart`).
private var session: Binding<Bool> {
Binding(
get: { coordinator.window.isOpen },
set: { on in
Task {
if on { await coordinator.turnSessionOn() } else { await coordinator.turnSessionOff() }
}
})
}

private func note(_ text: String) -> some View {
Text(text).font(BlurtType.body(DesignTokens.Typography.sizeCaption)).foregroundStyle(BlurtBrand.errorOrange)
}
}

/// After the keyboard brought Blurt forward to start a session: points at
/// iOS's own back link, "◀ Notes" or whichever app it was, in place of the
/// clock at the top left — the one way back to the app the user was typing in
/// (an app can't return there itself).
private struct ReturnHint: View {
var body: some View {
Label("To keep typing, tap “◀” with the app's name in the top-left corner.", systemImage: "arrow.up.left")
.font(BlurtType.body(DesignTokens.Typography.sizeCaption, bold: true))
.foregroundStyle(BlurtBrand.ctaText)
.padding(DesignTokens.Metrics.appChipPadX)
.frame(maxWidth: .infinity, alignment: .leading)
.background(BlurtBrand.cta, in: RoundedRectangle(cornerRadius: DesignTokens.Metrics.radiusInput))
}
}
23 changes: 8 additions & 15 deletions App/BlurtiOS/BlurtiOS/Sources/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import BlurtiOSCore
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 comes before
/// the gear across the top; the lifetime stats; the session card (on or off,
/// its switch, a box to try the keyboard in); then the 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 {
Expand All @@ -17,16 +17,11 @@ struct HomeView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var microphoneGranted = AVAudioApplication.shared.recordPermission == .granted
@State private var keyboardSeen = SharedStore.keyboardEverSeen
/// 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
/// the keyboard will. Goes with the losers.
@AppStorage(BlurtShared.Key.voiceElement, store: SharedStore.defaults) private var voiceRaw =
VoiceElementKind.shipped.rawValue

private var status: HomeStatus {
HomeStatus(
overlay: coordinator.phase.overlayState, windowOpen: coordinator.window.isOpen, until: coordinator.window.until,
overlay: coordinator.overlay, windowOpen: coordinator.window.isOpen,
windowMinutes: SharedStore.windowMinutes, micDenied: coordinator.microphoneDenied,
hasKey: coordinator.apiKey.hasAPIKey, micGranted: microphoneGranted, keyboardSeen: keyboardSeen)
}

Expand All @@ -36,7 +31,7 @@ struct HomeView: View {
VStack(alignment: .leading, spacing: DesignTokens.Metrics.appSectionGap) {
header
StatsCard()
HomeHero(coordinator: coordinator, status: status, landedAt: landedAt)
HomeHero(coordinator: coordinator, status: status)
StyleChips()
RecentSection(coordinator: coordinator)
Eyebrow("Powered by AssemblyAI")
Expand All @@ -45,15 +40,13 @@ struct HomeView: View {
.padding(.horizontal, DesignTokens.Metrics.appPagePad)
.padding(.bottom, DesignTokens.Metrics.appSectionGap)
}
// "Try it here" brings a keyboard up on this page; a drag puts it away.
.scrollDismissesKeyboard(.interactively)
.page()
.environment(\.voiceElementKind, VoiceElementKind(rawValue: voiceRaw) ?? .shipped)
.toolbar(.hidden, for: .navigationBar)
.onChange(of: scenePhase) { _, phase in
if phase == .active { refreshStatus() }
}
.onChange(of: coordinator.phase) { _, _ in
if status.landed { landedAt = Date() }
}
}
.tint(BlurtBrand.accent)
}
Expand Down
2 changes: 1 addition & 1 deletion App/BlurtiOS/BlurtiOS/Sources/ListeningSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ nonisolated enum ListeningSourceFailure: Error, LocalizedError {

var errorDescription: String? {
switch self {
case .windowClosed: "Blurt isn't listening. Open Blurt to start."
case .windowClosed: "The session is off."
case .noInputDevice: "No microphone is available."
}
}
Expand Down
5 changes: 4 additions & 1 deletion App/BlurtiOS/BlurtiOS/Sources/ListeningWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ final class ListeningWindow {
/// the feed still holds its audio, not left recording into a closed one.
@ObservationIgnored var onClosingOnItsOwn: (() async -> Void)?

var isOpen: Bool { source.isOpen && (until.map { $0 > Date() } ?? false) }
/// `until` is read first, always: it is what a view reading this observes
/// (the source's flag isn't observable), so a view that last saw the window
/// closed still redraws when it opens.
var isOpen: Bool { (until.map { $0 > Date() } ?? false) && source.isOpen }

/// Opens the microphone for `SharedStore.windowMinutes`. Foreground only.
func open() async {
Expand Down
11 changes: 11 additions & 0 deletions App/BlurtiOS/BlurtiOS/Sources/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ struct MainView: View {
}

var coordinator: DictationCoordinator
@Environment(\.scenePhase) private var scenePhase
@State private var tab = Tab.dictate
@State private var showsSettings = false

Expand Down Expand Up @@ -85,6 +86,16 @@ struct MainView: View {
tab = TermPack.looksLikePack(url) ? .words : .dictate
coordinator.handle(url)
}
// A session by default: opening Blurt turns one on (when nothing needs
// asking first, and the user hasn't turned it off), so the keyboard is
// ready without a trip to the switch.
.task { await coordinator.startSessionIfReady() }
.onChange(of: scenePhase) { _, phase in
if phase == .active { Task { await coordinator.startSessionIfReady() } }
// Back in the app the user was typing in: the ◀ hint has done its job.
// Here rather than on the Dictate tab, which isn't there while Words is.
if phase == .background { coordinator.startedFromKeyboard = false }
}
.tint(BlurtBrand.accent)
}
}
4 changes: 2 additions & 2 deletions App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ 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
/// The first-run steps, kept here once the app is set up: 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
Expand Down
Loading
Loading