diff --git a/App/BlurtiOS/BlurtDesign/DesignTokens.swift b/App/BlurtiOS/BlurtDesign/DesignTokens.swift index faebbb9a..b4311b7f 100644 --- a/App/BlurtiOS/BlurtDesign/DesignTokens.swift +++ b/App/BlurtiOS/BlurtDesign/DesignTokens.swift @@ -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). @@ -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", diff --git a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift index 1ba0c04d..951a1575 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift @@ -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 @@ -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 @@ -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. @@ -125,9 +133,9 @@ 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 { @@ -135,9 +143,41 @@ final class DictationCoordinator { 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 { @@ -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() { @@ -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 @@ -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), diff --git a/App/BlurtiOS/BlurtiOS/Sources/HomeHero.swift b/App/BlurtiOS/BlurtiOS/Sources/HomeHero.swift index bad9c87c..5acf77fb 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/HomeHero.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/HomeHero.swift @@ -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 { + 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)) + } +} diff --git a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift index 55cb1ccf..819883e7 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift @@ -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 { @@ -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) } @@ -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") @@ -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) } diff --git a/App/BlurtiOS/BlurtiOS/Sources/ListeningSource.swift b/App/BlurtiOS/BlurtiOS/Sources/ListeningSource.swift index 30688d1e..0305415d 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/ListeningSource.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/ListeningSource.swift @@ -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." } } diff --git a/App/BlurtiOS/BlurtiOS/Sources/ListeningWindow.swift b/App/BlurtiOS/BlurtiOS/Sources/ListeningWindow.swift index 9a1e43a0..3217357d 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/ListeningWindow.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/ListeningWindow.swift @@ -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 { diff --git a/App/BlurtiOS/BlurtiOS/Sources/MainView.swift b/App/BlurtiOS/BlurtiOS/Sources/MainView.swift index cdb4c0c3..6a8f9140 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/MainView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/MainView.swift @@ -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 @@ -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) } } diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index 3b4d639e..0da90b0a 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -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 diff --git a/App/BlurtiOS/BlurtiOS/Sources/SetupView.swift b/App/BlurtiOS/BlurtiOS/Sources/SetupView.swift index 3ff21e18..e891fdd3 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SetupView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SetupView.swift @@ -4,8 +4,8 @@ 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 +/// The first-run screen, in place of the tabs until Blurt is set up: 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. @@ -66,8 +66,8 @@ struct SetupView: View { } /// 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. +/// owns that rule): 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 diff --git a/App/BlurtiOS/BlurtiOS/Sources/TryItBox.swift b/App/BlurtiOS/BlurtiOS/Sources/TryItBox.swift new file mode 100644 index 00000000..f16de169 --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/TryItBox.swift @@ -0,0 +1,75 @@ +import BlurtDesign +import SwiftUI +import UIKit + +/// "Try it here": a text box on the home screen for trying the Blurt keyboard +/// without leaving the app. iOS offers no public way for an app to choose a +/// third-party keyboard for its own field, but it remembers the keyboard last +/// used in a field that names itself (`textInputContextIdentifier`), so after +/// the first switch with the globe key the box opens on Blurt every time. The +/// keyboard inserts at the cursor, so dictating mid-sentence lands there. +struct TryItBox: View { + @State private var text = "" + + var body: some View { + RememberingTextView(text: $text) + .frame(minHeight: DesignTokens.Metrics.appTryBoxHeight, alignment: .topLeading) + .overlay(alignment: .topLeading) { + if text.isEmpty { + Text("Try it here") + .font(BlurtType.body(DesignTokens.Typography.sizeBody)) + .foregroundStyle(BlurtBrand.muted) + .allowsHitTesting(false) + } + } + .brandInput() + } +} + +/// The box's text view, in UIKit because only a `UIResponder` can name its +/// input context, which is what makes iOS remember the keyboard chosen in it. +private struct RememberingTextView: UIViewRepresentable { + @Binding var text: String + + func makeUIView(context: Context) -> ContextTextView { + let view = ContextTextView() + view.delegate = context.coordinator + view.font = .systemFont(ofSize: DesignTokens.Typography.sizeBody) + view.textColor = UIColor(BlurtBrand.text) + view.backgroundColor = .clear + // Growing with its text, as the brand's inputs do, rather than scrolling. + view.isScrollEnabled = false + view.textContainerInset = .zero + view.textContainer.lineFragmentPadding = 0 + view.accessibilityLabel = "Try it here" + return view + } + + func updateUIView(_ view: ContextTextView, context: Context) { + if view.text != text { view.text = text } + } + + func sizeThatFits(_ proposal: ProposedViewSize, uiView: ContextTextView, context: Context) -> CGSize? { + guard let width = proposal.width else { return nil } + let fitted = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)) + return CGSize(width: width, height: fitted.height) + } + + func makeCoordinator() -> Coordinator { Coordinator(text: $text) } + + final class Coordinator: NSObject, UITextViewDelegate { + private let text: Binding + + init(text: Binding) { self.text = text } + + func textViewDidChange(_ textView: UITextView) { + text.wrappedValue = textView.text + } + } +} + +/// A text view with a fixed input context, so iOS restores the keyboard last +/// used in it — the Blurt keyboard, once the user has switched to it here. +private final class ContextTextView: UITextView { + override var textInputContextIdentifier: String? { "com.assemblyai.blurt.iphone.try-it" } +} diff --git a/App/BlurtiOS/BlurtiOSCore/HomeStatus.swift b/App/BlurtiOS/BlurtiOSCore/HomeStatus.swift index fb72202d..23042e56 100644 --- a/App/BlurtiOS/BlurtiOSCore/HomeStatus.swift +++ b/App/BlurtiOS/BlurtiOSCore/HomeStatus.swift @@ -1,44 +1,55 @@ import BlurtEngine -import Foundation -/// What the home screen says about the app's state, in words: the headline, -/// the line under it, whether setup is complete, whether the last dictation -/// just landed. Pure, so the wording is tested and the view only lays it out. +/// What the home screen says: the session, on +/// or off, and one line about it, rather than every phase of every dictation +/// (the keyboard shows those where the user is typing). Plus whether setup is +/// complete. Pure, so the wording is tested and the view only lays it out. package nonisolated struct HomeStatus: Equatable { - package let title: String - package let subtitle: String + /// The card's eyebrow: "Session on" or "Session off". + package let label: String + /// The listening window is open, so the Blurt keyboard can dictate. + package let isOn: Bool + /// The line under it: what to do now, and how long the session lasts. + package let message: String + /// A dictation that failed, said plainly; nil when nothing did. + package let error: String? /// A key, the microphone and the keyboard, all in place. package let isSetUp: Bool - /// The words just went in (or to the clipboard): the hero's moment. - package let landed: Bool + + /// A press that found the session off, as the keyboard and the card say it, + /// rather than as the engine's "Audio capture failed: …". + package static let sessionOffError = "The session is off. Turn it on to dictate." package init( - overlay: OverlayUIState, windowOpen: Bool, until: Date?, hasKey: Bool, micGranted: Bool, keyboardSeen: Bool + overlay: OverlayUIState, windowOpen: Bool, windowMinutes: Int, micDenied: Bool, hasKey: Bool, micGranted: Bool, + keyboardSeen: Bool ) { - title = Self.title(overlay, windowOpen: windowOpen) - if !windowOpen { - subtitle = "Open the mic so the Blurt keyboard can dictate anywhere you type." - } else if let until, until != .distantFuture { - subtitle = "Until \(until.formatted(date: .omitted, time: .shortened)) · tap the mic on the Blurt keyboard." + isOn = windowOpen + label = windowOpen ? "Session on" : "Session off" + if micDenied { + message = "Blurt needs microphone access to dictate. Turn it on in Settings." + } else if windowOpen { + message = "Use the Blurt keyboard's mic in any app. " + Self.endsAfter(minutes: windowMinutes) } else { - subtitle = "Until you stop it · tap the mic on the Blurt keyboard." + message = "Start a session to dictate with the Blurt keyboard in any app." } - isSetUp = hasKey && micGranted && keyboardSeen - switch overlay { - case .pasted, .noTarget: landed = true - default: landed = false + // A session that has come on since makes "the session is off" untrue: it + // goes, while any other failure stays until the next dictation. + if case .error(let text, _) = overlay, !(windowOpen && text == Self.sessionOffError) { + error = text + } else { + error = nil } + isSetUp = hasKey && micGranted && keyboardSeen } - private static func title(_ overlay: OverlayUIState, windowOpen: Bool) -> String { - switch overlay { - case .connecting: "Connecting…" - case .recording: "Listening…" - case .processing: "Transcribing…" - case .pasted: "Pasted" - case .noTarget: "Copied" - case .error(let message, _): message - case .idle: windowOpen ? "Ready to dictate" : "Not listening" + /// How long an idle session stays on: "Ends after 15 idle minutes."; 0 is + /// the window's "until you stop it". + static func endsAfter(minutes: Int) -> String { + switch minutes { + case ...0: "Stays on until you end it." + case 1: "Ends after 1 idle minute." + default: "Ends after \(minutes) idle minutes." } } } diff --git a/App/BlurtiOS/BlurtiOSCore/SessionAutoStart.swift b/App/BlurtiOS/BlurtiOSCore/SessionAutoStart.swift new file mode 100644 index 00000000..3c17a18c --- /dev/null +++ b/App/BlurtiOS/BlurtiOSCore/SessionAutoStart.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Whether Blurt turns a session on by itself as it comes to the front. On by +/// default, so the keyboard works in place without a trip to the switch — but +/// a session the user turned off stays off, across launches, until they turn +/// one on again (the switch, or the keyboard's mic, which asks for one). The +/// window ending on its own (its time ran out, a call took the microphone) is +/// not the user turning it off. +package nonisolated enum SessionAutoStart { + /// Set while the user's last word on the session was the switch turning it off. + package static let turnedOffKey = "SessionTurnedOff" + + /// Only when nothing would ask the user anything — a key saved, consent + /// given, the microphone already allowed — so it never puts up a sheet or a + /// permission prompt unasked, and never against the switch. + package static func shouldStart( + windowOpen: Bool, hasKey: Bool, consented: Bool, micGranted: Bool, turnedOff: Bool + ) -> Bool { + !windowOpen && hasKey && consented && micGranted && !turnedOff + } + + package static func isTurnedOff(in defaults: UserDefaults = .standard) -> Bool { + defaults.bool(forKey: turnedOffKey) + } + + /// The user turned the session off (true) or asked for one (false). + package static func setTurnedOff(_ turnedOff: Bool, in defaults: UserDefaults = .standard) { + defaults.set(turnedOff, forKey: turnedOffKey) + } +} diff --git a/App/BlurtiOS/BlurtiOSCore/SetupProgress.swift b/App/BlurtiOS/BlurtiOSCore/SetupProgress.swift index 6ccc8be6..795ae091 100644 --- a/App/BlurtiOS/BlurtiOSCore/SetupProgress.swift +++ b/App/BlurtiOS/BlurtiOSCore/SetupProgress.swift @@ -1,5 +1,5 @@ -/// 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 +/// Whether the app opens on its first-run setup screen or on its tabs: 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 diff --git a/App/BlurtiOS/BlurtiOSCore/SharedState.swift b/App/BlurtiOS/BlurtiOSCore/SharedState.swift index 3d5b046f..1d910c4a 100644 --- a/App/BlurtiOS/BlurtiOSCore/SharedState.swift +++ b/App/BlurtiOS/BlurtiOSCore/SharedState.swift @@ -279,11 +279,11 @@ package nonisolated enum SharedStore { set { defaults.set(newValue.rawValue, forKey: BlurtShared.Key.micAlignment) } } - /// Hands-free: the keyboard starts a dictation the moment it appears in a - /// text field, so there is nothing to tap before talking. On by default. + /// Hands-free: a dictation starts the moment the keyboard appears in a text field. Off by default: sessions + /// come on whenever Blurt opens, so it would record whenever a keyboard came up after, unasked (DESIGN.md). package static var autoDictate: Bool { get { - guard defaults.object(forKey: BlurtShared.Key.autoDictate) != nil else { return true } + guard defaults.object(forKey: BlurtShared.Key.autoDictate) != nil else { return false } return defaults.bool(forKey: BlurtShared.Key.autoDictate) } set { defaults.set(newValue, forKey: BlurtShared.Key.autoDictate) } diff --git a/App/BlurtiOS/BlurtiOSTests/HomeStatusTests.swift b/App/BlurtiOS/BlurtiOSTests/HomeStatusTests.swift index 8792e23f..0e8a89c5 100644 --- a/App/BlurtiOS/BlurtiOSTests/HomeStatusTests.swift +++ b/App/BlurtiOS/BlurtiOSTests/HomeStatusTests.swift @@ -1,5 +1,4 @@ import BlurtEngine -import Foundation import Testing @testable import BlurtiOSCore @@ -7,39 +6,49 @@ import Testing @Suite("Home status") struct HomeStatusTests { private func status( - _ overlay: OverlayUIState, open: Bool = true, until: Date? = nil, key: Bool = true, mic: Bool = true, - keyboard: Bool = true + _ overlay: OverlayUIState = .idle, open: Bool = true, minutes: Int = 15, denied: Bool = false, key: Bool = true, + mic: Bool = true, keyboard: Bool = true ) -> HomeStatus { - HomeStatus(overlay: overlay, windowOpen: open, until: until, hasKey: key, micGranted: mic, keyboardSeen: keyboard) + HomeStatus( + overlay: overlay, windowOpen: open, windowMinutes: minutes, micDenied: denied, hasKey: key, micGranted: mic, + keyboardSeen: keyboard) } - @Test("the headline follows the phase, and says whether the mic is open at rest") - func titles() { - #expect(status(.idle).title == "Ready to dictate") - #expect(status(.idle, open: false).title == "Not listening") - #expect(status(.connecting).title == "Connecting…") - #expect(status(.recording).title == "Listening…") - #expect(status(.processing).title == "Transcribing…") - #expect(status(.pasted).title == "Pasted") - #expect(status(.noTarget).title == "Copied") - #expect(status(.error(message: "No network.")).title == "No network.") + @Test("the session is on while the window is open, off when it isn't, whatever the dictation is doing") + func session() { + #expect(status().label == "Session on" && status().isOn) + #expect(status(open: false).label == "Session off" && !status(open: false).isOn) + #expect(status(.recording).label == "Session on") } - @Test("the line under it says how long the mic stays open") - func subtitles() { - #expect(status(.idle, open: false).subtitle.hasPrefix("Open the mic")) - #expect(status(.idle, until: .distantFuture).subtitle.hasPrefix("Until you stop it")) - #expect(status(.idle, until: Date()).subtitle.hasPrefix("Until ")) - #expect(!status(.idle, until: Date()).subtitle.hasPrefix("Until you")) + @Test("the line says what to do, and how long an idle session lasts") + func message() { + #expect(status(open: false).message.hasPrefix("Start a session")) + #expect(status().message.hasSuffix("Ends after 15 idle minutes.")) + #expect(status(minutes: 1).message.hasSuffix("Ends after 1 idle minute.")) + #expect(status(minutes: 0).message.hasSuffix("Stays on until you end it.")) + #expect(status(denied: true).message.hasPrefix("Blurt needs microphone access")) } - @Test("setup needs the key, the microphone and the keyboard; landed means pasted or copied") - func setupAndLanded() { - #expect(status(.idle).isSetUp) - #expect(!status(.idle, key: false).isSetUp) - #expect(!status(.idle, mic: false).isSetUp) - #expect(!status(.idle, keyboard: false).isSetUp) - #expect(status(.pasted).landed && status(.noTarget).landed) - #expect(!status(.idle).landed && !status(.error(message: "x")).landed) + @Test("a failed dictation says why; nothing else is an error") + func error() { + #expect(status(.error(message: "No network.")).error == "No network.") + #expect(status(.pasted).error == nil && status(.recording).error == nil) + } + + @Test("\"the session is off\" shows while it is, and goes once a session is on") + func sessionOffError() { + let sessionOff = OverlayUIState.error(message: HomeStatus.sessionOffError) + #expect(status(sessionOff, open: false).error == HomeStatus.sessionOffError) + #expect(status(sessionOff, open: true).error == nil) + #expect(status(.error(message: "No network."), open: true).error == "No network.") + } + + @Test("setup needs the key, the microphone and the keyboard") + func setup() { + #expect(status().isSetUp) + #expect(!status(key: false).isSetUp) + #expect(!status(mic: false).isSetUp) + #expect(!status(keyboard: false).isSetUp) } } diff --git a/App/BlurtiOS/BlurtiOSTests/SessionAutoStartTests.swift b/App/BlurtiOS/BlurtiOSTests/SessionAutoStartTests.swift new file mode 100644 index 00000000..c79508af --- /dev/null +++ b/App/BlurtiOS/BlurtiOSTests/SessionAutoStartTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing + +@testable import BlurtiOSCore + +/// The session's on-by-default rule, over its own defaults suite, never the app's. +@Suite("Session auto-start") +struct SessionAutoStartTests { + private func freshDefaults() throws -> UserDefaults { + let name = "SessionAutoStartTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defaults.removePersistentDomain(forName: name) + return defaults + } + + private func starts( + open: Bool = false, key: Bool = true, consent: Bool = true, mic: Bool = true, off: Bool = false + ) -> Bool { + SessionAutoStart.shouldStart(windowOpen: open, hasKey: key, consented: consent, micGranted: mic, turnedOff: off) + } + + @Test("on by itself only when nothing needs asking and the user hasn't turned it off") + func shouldStart() { + #expect(starts()) + #expect(!starts(open: true)) + #expect(!starts(key: false)) + #expect(!starts(consent: false)) + #expect(!starts(mic: false)) + #expect(!starts(off: true)) + } + + @Test("off by the switch sticks until the user asks for a session again") + func turnedOff() throws { + let defaults = try freshDefaults() + #expect(!SessionAutoStart.isTurnedOff(in: defaults)) + SessionAutoStart.setTurnedOff(true, in: defaults) + #expect(SessionAutoStart.isTurnedOff(in: defaults)) + SessionAutoStart.setTurnedOff(false, in: defaults) + #expect(!SessionAutoStart.isTurnedOff(in: defaults)) + } +} diff --git a/App/BlurtiOS/BlurtiOSTests/SharedStateTests.swift b/App/BlurtiOS/BlurtiOSTests/SharedStateTests.swift index 2d7d7c38..9dd17ede 100644 --- a/App/BlurtiOS/BlurtiOSTests/SharedStateTests.swift +++ b/App/BlurtiOS/BlurtiOSTests/SharedStateTests.swift @@ -117,9 +117,9 @@ struct SharedDefaultsTests { #expect(SharedStore.themeID == "system") SharedStore.themeID = "blurt" #expect(SharedStore.themeID == "blurt") - #expect(SharedStore.autoDictate) - SharedStore.autoDictate = false #expect(!SharedStore.autoDictate) + SharedStore.autoDictate = true + #expect(SharedStore.autoDictate) #expect(SharedStore.windowMinutes == 15) // 0 is a real choice (until closed), not the unset default. SharedStore.windowMinutes = 0 diff --git a/App/BlurtiOS/DESIGN.md b/App/BlurtiOS/DESIGN.md index 7da9f358..e587e116 100644 --- a/App/BlurtiOS/DESIGN.md +++ b/App/BlurtiOS/DESIGN.md @@ -406,7 +406,9 @@ the field's `keyboardAppearance`. ## Hands-free -On by default, with a toggle in Settings: a dictation starts the moment the +Off by default — a session comes on whenever Blurt is opened, so on by +default would record whenever a Blurt keyboard came up after, without a tap — +with a toggle in Settings: a dictation starts the moment the keyboard comes up in a text field — the same synthetic tap through the engine's gate, so it latches and the next tap of the element stops it. Only when the app is listening and nothing is in flight; otherwise the element @@ -559,6 +561,7 @@ pins the derivation. | `app/stat-gap` | `16` | `Metrics.appStatGap` | between the stats in the card, and between its rows | | `app/tab-gap` | `4` | `Metrics.appTabGap` | between a tab's icon and its label | | `app/tab-height` | `52` | `Metrics.appTabHeight` | a tab in the brand tab bar: its icon over its mono label | +| `app/try-box-height` | `64` | `Metrics.appTryBoxHeight` | the least height of the home screen's "Try it here" box: two lines | | `card/border` | `1` | `Metrics.cardBorder` | the card's hairline | | `card/radius` | `12` | `Metrics.cardRadius` | the app's cards (radius/card) | | `caret/height` | `20` | `Metrics.caretHeight` | — | @@ -740,12 +743,22 @@ Whoever was already set up skips it. `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 -saying so) or the hairline `STOP LISTENING`, and "Dictate here, to the -clipboard" as a text button; `STYLE` and the chips, the active one filled; +Data); the session card — on by itself whenever Blurt comes to the front with nothing +to ask (a key, consent and the microphone all given), so the keyboard is ready +without the switch, unless the user turned it off: off with the switch holds, +across launches, until the switch or the keyboard's mic asks for a session +(`SessionAutoStart`) — `SESSION ON` in the +accent or `SESSION OFF` muted, with the switch beside it that opens and closes +the listening window (off without a key; `OPEN SETTINGS` instead when the +microphone was refused), one line (what to do, and "Ends after 15 idle +minutes."), and "Try it here" (`TryItBox`), a box to try the Blurt keyboard in: +iOS remembers the keyboard last used in it, so after the first switch with the +globe key it opens on Blurt, and the keyboard inserts at the cursor; a drag +down the page puts the keyboard away. A dictation's phases stay on the +keyboard; only a failure shows, in orange, and "The session is off. Turn it on +to dictate." goes once a session is on. After +the keyboard opened the app to start a session, a green banner points at iOS's +◀ back link until the user leaves. `STYLE` and the chips, the active one filled; `RECENT` and the cards (three lines, the style as a green mono tag, the time, a copy button); `POWERED BY ASSEMBLYAI`. diff --git a/App/BlurtiOS/Design/tokens.json b/App/BlurtiOS/Design/tokens.json index 9e677fe8..0e9fd000 100644 --- a/App/BlurtiOS/Design/tokens.json +++ b/App/BlurtiOS/Design/tokens.json @@ -816,6 +816,10 @@ "typing/result-gap": { "value": 20, "use": "between the result's blocks" + }, + "app/try-box-height": { + "value": 64, + "use": "the least height of the home screen's \"Try it here\" box: two lines" } }, "type": { diff --git a/App/BlurtiOS/README.md b/App/BlurtiOS/README.md index 0ff411fb..98f10985 100644 --- a/App/BlurtiOS/README.md +++ b/App/BlurtiOS/README.md @@ -107,7 +107,7 @@ Xcode 27 replaced Simulator.app with Device Hub (`Xcode.app/Contents/Application which the script opens; turn off its **Always simulate hardware keyboard** setting (Device Hub → Settings…) or no on-screen keyboard ever appears. Then: Blurt → Open Settings, and Apps → Blurt → Keyboards → Blurt on, Allow Full Access (the simulator's Settings opens at its top level, not on -Blurt's page); Start listening; in Notes, hold the globe key, pick Blurt, tap the +Blurt's page); turn the session on; in Notes, hold the globe key, pick Blurt, tap the mic. The keyboard's crash logs, if any, land in `~/Library/Logs/DiagnosticReports/BlurtKeyboard-*`, and `xcrun simctl spawn booted log show --last 5m --predicate 'process == "BlurtKeyboard"'` shows its console. @@ -137,7 +137,7 @@ shows its console. destination, run the `BlurtiOS` scheme. On the phone, trust the developer app (Settings → General → VPN & Device Management). In the app: **Paste your key**, **Allow** sending your voice to AssemblyAI, **Allow** the microphone, then **Open Settings** and go to **Apps › Blurt › Keyboards** (a phone may open straight on Blurt's page): turn on Blurt and **Allow Full Access**. -5. Back in the app, **Start listening**, then go to Messages, hold the globe key, pick Blurt, +5. Back in the app, turn the **session** on, then go to Messages, hold the globe key, pick Blurt, and tap the mic. Tap again to stop, or hold it while you talk. The words land in the field. 6. Try the other two layouts from the app's Keyboard picker; the keyboard reads the choice the next time it comes up.