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
72 changes: 72 additions & 0 deletions App/BlurtiOS/BlurtDesign/DesignTokens.swift
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ package enum DesignTokens {
package nonisolated static let appSetupNumberWidth: CGFloat = 24
/// Between a section's eyebrow and its content, and between cards.
package nonisolated static let appStackGap: CGFloat = 12
/// Between the stats in the card, and between its rows.
package nonisolated static let appStatGap: CGFloat = 16
/// Between a tab's icon and its label.
package nonisolated static let appTabGap: CGFloat = 4
/// A tab in the brand tab bar: its icon over its mono label.
Expand Down Expand Up @@ -381,6 +383,14 @@ package enum DesignTokens {
package nonisolated static let ribsRestWidth: CGFloat = 40
/// Between rows, the iPhone's.
package nonisolated static let rowGap: CGFloat = 11
/// Between a stat's number-and-unit parts, as a fraction of its size.
package nonisolated static let statPartGapRatio: CGFloat = 0.38
/// A stat number's tracking, as a fraction of its size.
package nonisolated static let statTrackingRatio: CGFloat = -0.03
/// Between a number and its unit, as a fraction of its size.
package nonisolated static let statUnitGapRatio: CGFloat = 0.15
/// A stat's mono unit (hr, min, sec) as a fraction of its number's size.
package nonisolated static let statUnitRatio: CGFloat = 0.46
/// The landing flash's vertical arm, as a fraction of the box's height.
package nonisolated static let streakArm: CGFloat = 1.4
/// The light streak while recording.
Expand All @@ -407,6 +417,18 @@ package enum DesignTokens {
package nonisolated static let termPad: CGFloat = 14
/// The key-term field: the brand's input corners (radius/input).
package nonisolated static let termRadius: CGFloat = 8
/// The typing field's border, orange once a word goes wrong.
package nonisolated static let typingFieldBorder: CGFloat = 2
/// Inside the typing field.
package nonisolated static let typingFieldPad: CGFloat = 12
/// Between the lines of words to type.
package nonisolated static let typingLineSpacing: CGFloat = 8
/// The countdown's thump in the last five seconds.
package nonisolated static let typingPulse: CGFloat = 1.06
/// Between the result's blocks.
package nonisolated static let typingResultGap: CGFloat = 20
/// The least room the words take, so the field below stays put.
package nonisolated static let typingWordsHeight: CGFloat = 180
package nonisolated static let voiceBarHeight: CGFloat = 32
/// The voice element's box in the voice bar — the most it takes (the ribs awake, the streak); the + sits beside what shows.
package nonisolated static let voiceBarWidth: CGFloat = 160
Expand Down Expand Up @@ -459,6 +481,14 @@ package enum DesignTokens {
package nonisolated static let sizePopup: CGFloat = 32
/// The × on a text-shortcut row.
package nonisolated static let sizeRemoveGlyph: CGFloat = 12
/// The next size down when the stats would not fit.
package nonisolated static let sizeStatL: CGFloat = 23
/// And the next.
package nonisolated static let sizeStatM: CGFloat = 20
/// The smallest a stat is drawn.
package nonisolated static let sizeStatS: CGFloat = 18
/// A stat's serif number, at the largest size that fits.
package nonisolated static let sizeStatXl: CGFloat = 26
/// A tab's icon.
package nonisolated static let sizeTabIcon: CGFloat = 18
/// A tab's mono label.
Expand All @@ -467,6 +497,20 @@ package enum DesignTokens {
package nonisolated static let sizeTerm: CGFloat = 17
/// The app's serif headline.
package nonisolated static let sizeTitle: CGFloat = 34
/// The typing test's countdown.
package nonisolated static let sizeTypingClock: CGFloat = 64
/// What you've typed.
package nonisolated static let sizeTypingField: CGFloat = 22
/// Its live words per minute.
package nonisolated static let sizeTypingLive: CGFloat = 28
/// The result's rank, in the serif.
package nonisolated static let sizeTypingRank: CGFloat = 28
/// The result's words per minute.
package nonisolated static let sizeTypingResult: CGFloat = 96
/// The result's WPM.
package nonisolated static let sizeTypingUnit: CGFloat = 16
/// The words to type.
package nonisolated static let sizeTypingWords: CGFloat = 24
/// Uppercase mono at 14: the brand's E2 tracking.
package nonisolated static let trackingCta: CGFloat = 1.4
/// Uppercase mono at 12: the brand's E1 tracking.
Expand Down Expand Up @@ -512,6 +556,10 @@ package enum DesignTokens {
package nonisolated static let stateFade: Double = 0.5
/// The voice bar becoming the field.
package nonisolated static let termSwap: Double = 0.4
/// The typing field's border turning, and the countdown's thump.
package nonisolated static let typingFlash: Double = 0.15
/// How often the typing test's clock and live speed redraw.
package nonisolated static let typingTick: Double = 0.1
/// The orb and the wave crossing, either way.
package nonisolated static let waveFade: Double = 0.7
}
Expand Down Expand Up @@ -638,6 +686,7 @@ package enum DesignTokens {
"metrics.app/section-gap": "24",
"metrics.app/setup-number-width": "24",
"metrics.app/stack-gap": "12",
"metrics.app/stat-gap": "16",
"metrics.app/tab-gap": "4",
"metrics.app/tab-height": "52",
"metrics.card/border": "1",
Expand Down Expand Up @@ -709,6 +758,10 @@ package enum DesignTokens {
"metrics.ribs/rest-height": "16",
"metrics.ribs/rest-width": "40",
"metrics.row/gap": "11",
"metrics.stat/part-gap-ratio": "0.38",
"metrics.stat/tracking-ratio": "-0.03",
"metrics.stat/unit-gap-ratio": "0.15",
"metrics.stat/unit-ratio": "0.46",
"metrics.streak/arm": "1.4",
"metrics.streak/height": "2",
"metrics.streak/line": "1",
Expand All @@ -722,6 +775,12 @@ package enum DesignTokens {
"metrics.term/inset": "2",
"metrics.term/pad": "14",
"metrics.term/radius": "8",
"metrics.typing/field-border": "2",
"metrics.typing/field-pad": "12",
"metrics.typing/line-spacing": "8",
"metrics.typing/pulse": "1.06",
"metrics.typing/result-gap": "20",
"metrics.typing/words-height": "180",
"metrics.voice/bar-height": "32",
"metrics.voice/bar-width": "160",
"metrics.voice/home-height": "112",
Expand Down Expand Up @@ -750,10 +809,21 @@ package enum DesignTokens {
"type.size/pill-glyph": "10",
"type.size/popup": "32",
"type.size/remove-glyph": "12",
"type.size/stat-l": "23",
"type.size/stat-m": "20",
"type.size/stat-s": "18",
"type.size/stat-xl": "26",
"type.size/tab-icon": "18",
"type.size/tab-label": "11",
"type.size/term": "17",
"type.size/title": "34",
"type.size/typing-clock": "64",
"type.size/typing-field": "22",
"type.size/typing-live": "28",
"type.size/typing-rank": "28",
"type.size/typing-result": "96",
"type.size/typing-unit": "16",
"type.size/typing-words": "24",
"type.tracking/cta": "1.4",
"type.tracking/eyebrow": "1.2",
"type.weight/glyph": "medium",
Expand All @@ -778,6 +848,8 @@ package enum DesignTokens {
"motion.sheen-working": "1.6",
"motion.state-fade": "0.5",
"motion.term-swap": "0.4",
"motion.typing-flash": "0.15",
"motion.typing-tick": "0.1",
"motion.wave-fade": "0.7",
]
}
Expand Down
18 changes: 16 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 All @@ -45,6 +56,9 @@ struct BlurtiOSApp: App {
probe.task { await start() }
} else if let voice = KeyboardGalleryView.VoiceStillView.parse(CommandLine.arguments) {
voice
} else if CommandLine.arguments.contains("-BlurtTypingTest") {
// The easter egg as the root, for a screenshot without a tap.
TypingTestView()
} else if CommandLine.arguments.contains("-BlurtSettings") {
// The settings sheet as the root, for a screenshot without a tap.
SettingsView(coordinator: coordinator)
Expand All @@ -60,7 +74,7 @@ struct BlurtiOSApp: App {
}

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

Expand Down
36 changes: 35 additions & 1 deletion App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ final class DictationCoordinator {
nonisolated private static let pressKeyboard = Mutex<String?>(nil)
/// Contact names, parsed once per lexicon refresh rather than on the press.
nonisolated private static let lexiconNames = Mutex<[String]>([])
/// The words the session just delivered, waiting to be counted. The stats are
/// recorded on the main actor when the dictation's terminal phase renders —
/// after its recording has been timed, and in the same place a reset runs —
/// rather than on the session's actor, where neither ordering holds.
nonisolated private static let deliveredText = Mutex<String?>(nil)
/// When the recording in progress started, and how long the last one ran.
@ObservationIgnored private var recordingSince: ContinuousClock.Instant?
@ObservationIgnored private var lastSpokenSeconds: Double = 0

init(apiKey: APIKeyModel = APIKeyModel()) {
let window = ListeningWindow()
Expand All @@ -66,7 +74,10 @@ final class DictationCoordinator {
injector: KeyboardRelayInjector(presser: { Self.pressKeyboard.withLock { $0 } }),
keyTermsProvider: { Self.keyTerms() },
readinessCheck: apiKey.readinessCheck(),
onTranscriptDelivered: { _, ring in continuation.yield(ring) },
onTranscriptDelivered: { text, ring in
Self.deliveredText.withLock { $0 = text }
continuation.yield(ring)
},
// The keyboard is the only thing that can see the field: the press
// command carries the text before the cursor, and that is what primes
// the request — the same signal the Mac reads through Accessibility.
Expand Down Expand Up @@ -243,6 +254,17 @@ final class DictationCoordinator {
private func render(_ phase: PipelinePhase) {
self.phase = phase
if phase == .recording { window.extend() }
timeRecording(phase)
// Delivery always comes before the paste and its terminal phase, and the
// phases render in order, so the dictation's words are waiting by now.
if phase.isTerminal,
let text = Self.deliveredText.withLock({ text in
defer { text = nil }
return text
})
{
DictationStats.record(text, spokenFor: lastSpokenSeconds)
}
// The Mac shows a setup blocker as calm idle beside a settings button;
// the keyboard has no such button, so it hears an error and the orb says so.
if phase.setupBlocker != nil {
Expand All @@ -252,6 +274,18 @@ final class DictationCoordinator {
publish(phase.overlayState)
}

/// Starts the clock when a recording starts and, when it stops, keeps how
/// long it ran for the stats.
private func timeRecording(_ phase: PipelinePhase) {
if phase == .recording {
if recordingSince == nil { recordingSince = .now }
} else if let since = recordingSince {
let elapsed = since.duration(to: .now)
lastSpokenSeconds = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18
recordingSince = nil
}
}

private func observeLevels() -> Task<Void, Never> {
let levels = window.source.levels
return Task { [weak self] in
Expand Down
15 changes: 5 additions & 10 deletions App/BlurtiOS/BlurtiOS/Sources/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@ import BlurtiOSCore
import SwiftUI

/// The Dictate tab: the Blurt landing page on a phone. The wordmark and
/// the gear across the top; the status as an eyebrow, a serif headline and a
/// 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,7 @@ 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()
RecentSection(coordinator: coordinator)
Expand All @@ -52,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
Loading