diff --git a/App/BlurtiOS/BlurtDesign/DesignTokens.swift b/App/BlurtiOS/BlurtDesign/DesignTokens.swift index bae82a97..faebbb9a 100644 --- a/App/BlurtiOS/BlurtDesign/DesignTokens.swift +++ b/App/BlurtiOS/BlurtDesign/DesignTokens.swift @@ -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. @@ -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. @@ -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 @@ -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. @@ -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. @@ -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 } @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", ] } diff --git a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift index 9eb84874..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 @@ -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) @@ -60,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/DictationCoordinator.swift b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift index bf0598d8..fd24ecda 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift @@ -52,6 +52,14 @@ final class DictationCoordinator { nonisolated private static let pressKeyboard = Mutex(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(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() @@ -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. @@ -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 { @@ -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 { let levels = window.source.levels return Task { [weak self] in diff --git a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift index d6abf538..55cb1ccf 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift @@ -5,10 +5,11 @@ 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. @@ -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,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) @@ -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() } } 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 3e19377e..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 @@ -19,16 +20,25 @@ struct SettingsView: View { @AppStorage(BlurtShared.Key.voiceElement, store: SharedStore.defaults) private var voiceRaw = VoiceElementKind.shipped.rawValue @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 accountSection + dataSection aboutSection } .brandForm() @@ -38,10 +48,58 @@ struct SettingsView: View { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } .sheet(isPresented: $showsKeyEntry) { KeyEntryView(apiKey: coordinator.apiKey) } + // 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: { + Text("Your dictation, word and time-saved totals go back to zero.") + } } .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) { @@ -119,12 +177,36 @@ struct SettingsView: View { } } + /// The stats card's totals, which live only on this phone. + private var dataSection: some View { + Section { + Button("Reset statistics", role: .destructive) { confirmsStatsReset = true } + } header: { + Eyebrow("Data") + } footer: { + Text("Dictations, words and time saved are counted on this phone and never sent.") + } + } + private var aboutSection: some View { Section { LabeledContent("Version", value: Self.version) Link("Blurt on GitHub", destination: URL(string: "https://github.com/AssemblyAI/blurt") ?? URL(filePath: "/")) Link("AssemblyAI's privacy policy", destination: AIConsent.privacyPolicyURL) Text("Powered by AssemblyAI").foregroundStyle(BlurtBrand.muted) + // The easter egg: a link, not a button, so it stays a find rather than + // another call to action. Its result becomes time saved's typing speed. + Button { + showsTypingTest = true + } label: { + Label("How fast can you type?", systemImage: "keyboard") + } + .foregroundStyle(BlurtBrand.accent) + if typingSpeed > 0 { + Button("Using your \(Int(typingSpeed)) wpm · Reset to average") { TypingSpeed.resetToAverage() } + .font(BlurtType.body(DesignTokens.Typography.sizeCaption)) + .foregroundStyle(BlurtBrand.muted) + } } header: { Eyebrow("About") } 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/BlurtiOS/Sources/StatsCard.swift b/App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift new file mode 100644 index 00000000..4c4bdd86 --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift @@ -0,0 +1,101 @@ +import BlurtDesign +import BlurtiOSCore +import SwiftUI + +/// Lifetime dictations, words, and time saved over typing them, at the top of +/// the Dictate tab. Every dictation counts, from the app or the keyboard; the +/// totals stay on the phone (`DictationStats`). +struct StatsCard: View { + @AppStorage(DictationStats.defaultsKey) private var raw = Data() + /// The speed time saved is measured against: the phone average, or the + /// easter egg's latest result (`TypingSpeed`). + @AppStorage(TypingSpeed.defaultsKey) private var typingSpeed: Double = 0 + + var body: some View { + let stats = DictationStats.decode(raw) + let saved = stats.timeSaved(typingWordsPerMinute: TypingSpeed.resolved(typingSpeed)) + let dictations = [(StatFormat.count(stats.dictations), String?.none)] + let words = [(StatFormat.count(stats.words), String?.none)] + let timeSaved = StatFormat.durationParts(saved).map { ($0.value, Optional($0.unit)) } + let sizes = DesignTokens.Typography.self + // Under an hour, all three share one row. Once time saved reaches hours + // ("1 hr 19 min 50 sec") it moves to a full-width second row. Either way + // every value is the same size: the largest that fits. + // + // The candidates are listed out, not generated with ForEach: ViewThatFits + // traps on generated children whose identities repeat across branches. + ViewThatFits(in: .horizontal) { + if saved < .seconds(3_600) { + oneRow(dictations, words, timeSaved, size: sizes.sizeStatXl) + oneRow(dictations, words, timeSaved, size: sizes.sizeStatL) + oneRow(dictations, words, timeSaved, size: sizes.sizeStatM) + oneRow(dictations, words, timeSaved, size: sizes.sizeStatS) + } + twoRows(dictations, words, timeSaved, size: sizes.sizeStatXl) + twoRows(dictations, words, timeSaved, size: sizes.sizeStatL) + twoRows(dictations, words, timeSaved, size: sizes.sizeStatM) + twoRows(dictations, words, timeSaved, size: sizes.sizeStatS) + } + .padding(DesignTokens.Metrics.appCardPad) + .frame(maxWidth: .infinity, alignment: .leading) + .card() + } + + private typealias Parts = [(String, String?)] + + private func oneRow(_ dictations: Parts, _ words: Parts, _ timeSaved: Parts, size: CGFloat) -> some View { + HStack(alignment: .top, spacing: 0) { + Stat(label: "Dictations", parts: dictations, size: size) + Spacer(minLength: DesignTokens.Metrics.appStatGap) + Stat(label: "Words", parts: words, size: size) + Spacer(minLength: DesignTokens.Metrics.appStatGap) + Stat(label: "Time saved", parts: timeSaved, size: size) + } + } + + private func twoRows(_ dictations: Parts, _ words: Parts, _ timeSaved: Parts, size: CGFloat) -> some View { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appStatGap) { + HStack(alignment: .top, spacing: 0) { + Stat(label: "Dictations", parts: dictations, size: size) + .frame(maxWidth: .infinity, alignment: .leading) + Stat(label: "Words", parts: words, size: size) + .frame(maxWidth: .infinity, alignment: .leading) + } + Stat(label: "Time saved", parts: timeSaved, size: size) + } + } +} + +/// One stat: a mono eyebrow over its value, drawn as number-and-unit pairs +/// (serif numbers, mono units) so "1 hr 25 min" reads as a measurement. Units +/// scale with the number, so every size keeps the same proportions. +private struct Stat: View { + let label: String + let parts: [(value: String, unit: String?)] + let size: CGFloat + + var body: some View { + let metrics = DesignTokens.Metrics.self + VStack(alignment: .leading, spacing: metrics.appLineGap) { + Eyebrow(label) + HStack(alignment: .firstTextBaseline, spacing: size * metrics.statPartGapRatio) { + ForEach(Array(parts.enumerated()), id: \.offset) { _, part in + HStack(alignment: .firstTextBaseline, spacing: size * metrics.statUnitGapRatio) { + Text(part.value) + .font(BlurtType.heading(size)) + .tracking(size * metrics.statTrackingRatio) + .foregroundStyle(BlurtBrand.text) + if let unit = part.unit { + Text(unit.uppercased()) + .font(BlurtType.mono((size * metrics.statUnitRatio).rounded(), weight: .medium)) + .foregroundStyle(BlurtBrand.muted) + } + } + } + } + } + .lineLimit(1) + .fixedSize() + .accessibilityElement(children: .combine) + } +} diff --git a/App/BlurtiOS/BlurtiOS/Sources/TypingTestView.swift b/App/BlurtiOS/BlurtiOS/Sources/TypingTestView.swift new file mode 100644 index 00000000..b9a98e29 --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/TypingTestView.swift @@ -0,0 +1,261 @@ +import BlurtDesign +import BlurtiOSCore +import SwiftUI +import UIKit + +/// The easter egg: a 30-second typing sprint, full screen, from Settings. The +/// clock starts on the first keystroke, the last five seconds turn orange and +/// thump, and the result becomes the typing speed time saved is measured +/// against, in place of the 36 wpm phone average. +struct TypingTestView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private enum Phase: Equatable { + case ready + case running(since: Date) + case finished(wordsPerMinute: Double, accuracy: Double, result: TypingSpeed.Result) + + var startDate: Date? { + if case .running(let since) = self { return since } + return nil + } + } + + @State private var test = TypingTest() + @State private var phase = Phase.ready + @State private var input = "" + @State private var best = TypingSpeed.best() + @FocusState private var isTyping: Bool + + private static let seconds = Double(TypingTest.duration.components.seconds) + + var body: some View { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appSectionGap) { + HStack { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: DesignTokens.Metrics.appIcon, weight: DesignTokens.Typography.weightGlyph)) + .foregroundStyle(BlurtBrand.muted) + .frame(width: DesignTokens.Metrics.appHeaderHeight, height: DesignTokens.Metrics.appHeaderHeight) + } + .accessibilityLabel("Close") + Spacer() + Eyebrow("How fast can you type?") + Spacer() + Color.clear.frame(width: DesignTokens.Metrics.appHeaderHeight, height: DesignTokens.Metrics.appHeaderHeight) + } + + switch phase { + case .ready, .running: + sprint + case .finished(let wordsPerMinute, let accuracy, let result): + results(wordsPerMinute: wordsPerMinute, accuracy: accuracy, result: result) + } + } + .padding(.horizontal, DesignTokens.Metrics.appPagePad) + .page() + .onAppear { isTyping = true } + .task(id: phase.startDate) { await runClock() } + } + + // MARK: - The sprint + + private var sprint: some View { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appSectionGap) { + TimelineView(.periodic(from: .now, by: DesignTokens.Motion.typingTick)) { context in + let remaining = remainingSeconds(at: context.date) + let isFinalStretch = + phase.startDate != nil && remaining <= Double(TypingTest.finalStretch.components.seconds) + HStack(alignment: .firstTextBaseline) { + Text(String(format: "%.1f", remaining)) + .font(BlurtType.mono(DesignTokens.Typography.sizeTypingClock, weight: .bold)) + .monospacedDigit() + .foregroundStyle(isFinalStretch ? BlurtBrand.errorOrange : BlurtBrand.text) + .scaleEffect( + isFinalStretch && !reduceMotion && pulse(at: context.date) ? DesignTokens.Metrics.typingPulse : 1, + anchor: .leading + ) + .animation(.easeOut(duration: DesignTokens.Motion.typingFlash), value: Int(remaining)) + Spacer() + VStack(alignment: .trailing, spacing: DesignTokens.Metrics.appLineGap) { + Eyebrow("WPM") + Text(liveWordsPerMinute(at: context.date)) + .font(BlurtType.mono(DesignTokens.Typography.sizeTypingLive, weight: .bold)) + .monospacedDigit() + .foregroundStyle(BlurtBrand.accent) + } + } + .accessibilityElement(children: .combine) + } + + wordsText + .font(BlurtType.mono(DesignTokens.Typography.sizeTypingWords, weight: .medium)) + .lineSpacing(DesignTokens.Metrics.typingLineSpacing) + .frame(maxWidth: .infinity, minHeight: DesignTokens.Metrics.typingWordsHeight, alignment: .topLeading) + .accessibilityLabel("Next word: \(test.currentWord)") + + TextField("Start typing to begin", text: $input) + .font(BlurtType.mono(DesignTokens.Typography.sizeTypingField)) + .foregroundStyle(BlurtBrand.text) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) + .focused($isTyping) + .padding(DesignTokens.Metrics.typingFieldPad) + .background(BlurtBrand.cardFill, in: RoundedRectangle(cornerRadius: DesignTokens.Metrics.radiusInput)) + .overlay( + RoundedRectangle(cornerRadius: DesignTokens.Metrics.radiusInput) + .strokeBorder( + input.isEmpty || test.isOnTrack(input) ? BlurtBrand.cardBorder : BlurtBrand.errorOrange, + lineWidth: DesignTokens.Metrics.typingFieldBorder) + ) + .animation(.easeInOut(duration: DesignTokens.Motion.typingFlash), value: test.isOnTrack(input)) + .onChange(of: input) { _, typed in handle(typed) } + + Text(phase == .ready ? "30 seconds. The clock starts on your first key." : "Space after each word.") + .brandFootnote() + Spacer(minLength: 0) + } + } + + /// A stable block of words: it moves on in chunks of eight, so the words + /// under your eyes don't jump while you type. + private var wordsText: Text { + let start = (test.index / 8) * 8 + var text = Text("") + for position in start..<(start + 20) { + var styled = Text(test.words[position % test.words.count]) + if position < test.index { + styled = + test.outcomes[position] + ? styled.foregroundStyle(BlurtBrand.muted) + : styled.foregroundStyle(BlurtBrand.errorOrange).strikethrough() + } else if position == test.index { + styled = styled.foregroundStyle(BlurtBrand.accent).bold().underline() + } else { + styled = styled.foregroundStyle(BlurtBrand.text) + } + text = text + styled + Text(" ") + } + return text + } + + private func handle(_ typed: String) { + if phase == .ready, !typed.isEmpty { phase = .running(since: .now) } + guard case .running = phase, let last = typed.last, last.isWhitespace else { return } + let word = typed.trimmingCharacters(in: .whitespaces) + input = "" + guard !word.isEmpty else { return } + if test.submit(word) { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } else { + UINotificationFeedbackGenerator().notificationOccurred(.error) + } + } + + /// Runs once per sprint: waits out the clock, thumps through the final + /// stretch, then scores. + private func runClock() async { + guard let start = phase.startDate else { return } + let stretch = Int(TypingTest.finalStretch.components.seconds) + let untilStretch = start.addingTimeInterval(Self.seconds - Double(stretch)).timeIntervalSinceNow + try? await Task.sleep(for: .seconds(max(0, untilStretch))) + for _ in 0.. Double { + guard let start = phase.startDate else { return Self.seconds } + return max(0, Self.seconds - date.timeIntervalSince(start)) + } + + private func liveWordsPerMinute(at date: Date) -> String { + guard let start = phase.startDate else { return "--" } + let elapsed = date.timeIntervalSince(start) + // The first couple of seconds swing wildly, so hold off. + guard elapsed >= 2 else { return "--" } + return "\(Int(test.wordsPerMinute(after: .seconds(min(elapsed, Self.seconds))).rounded()))" + } + + private func pulse(at date: Date) -> Bool { + date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 1) < DesignTokens.Motion.typingFlash + } + + // MARK: - The result + + private func results(wordsPerMinute: Double, accuracy: Double, result: TypingSpeed.Result) -> some View { + let rank = TypingTest.rank(for: wordsPerMinute) + let rounded = Int(wordsPerMinute.rounded()) + let speedup = TypingTest.speakingWordsPerMinute / max(wordsPerMinute, 1) + return VStack(alignment: .leading, spacing: DesignTokens.Metrics.typingResultGap) { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appChipGap) { + Eyebrow( + result.isBest ? "New personal best" : "Best \(Int(best.rounded())) wpm", + color: result.isBest ? BlurtBrand.accent : BlurtBrand.muted) + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Metrics.appChipGap) { + Text("\(rounded)") + .font(BlurtType.mono(DesignTokens.Typography.sizeTypingResult, weight: .bold)) + .foregroundStyle(BlurtBrand.text) + Text("WPM") + .font(BlurtType.mono(DesignTokens.Typography.sizeTypingUnit, weight: .medium)) + .foregroundStyle(BlurtBrand.muted) + } + Text(rank.title) + .font(BlurtType.heading(DesignTokens.Typography.sizeTypingRank)) + .foregroundStyle(BlurtBrand.text) + Text(rank.line) + .font(BlurtType.body(DesignTokens.Typography.sizeBody)) + .foregroundStyle(BlurtBrand.text) + } + .accessibilityElement(children: .combine) + + Eyebrow("\(Int((accuracy * 100).rounded()))% accuracy") + + Text( + speedup >= 1.5 + ? "Talking runs about 150 words a minute. That's \(String(format: "%.1f", speedup))× you." + : "Talking runs about 150 words a minute. Honestly, you might not need us." + ) + .font(BlurtType.body(DesignTokens.Typography.sizeBody)) + .foregroundStyle(BlurtBrand.text) + .fixedSize(horizontal: false, vertical: true) + .padding(DesignTokens.Metrics.appCardPad) + .frame(maxWidth: .infinity, alignment: .leading) + .card() + + Eyebrow( + result.setsSpeed + ? "Time saved now uses your \(rounded) wpm" : "Too few words to measure. Time saved is unchanged", + color: result.setsSpeed ? BlurtBrand.accent : BlurtBrand.muted) + + Button("Try again", action: restart) + .buttonStyle(BrandButtonStyle()) + Spacer(minLength: 0) + } + } +} diff --git a/App/BlurtiOS/BlurtiOSCore/DictationStats.swift b/App/BlurtiOS/BlurtiOSCore/DictationStats.swift new file mode 100644 index 00000000..cb5a12b5 --- /dev/null +++ b/App/BlurtiOS/BlurtiOSCore/DictationStats.swift @@ -0,0 +1,105 @@ +import Foundation + +/// Lifetime totals for the Dictate tab's stats card: dictations, words, and +/// time spent talking. On the phone only, in the app's own defaults — from +/// the app and the keyboard alike, since every dictation runs in the app. +/// +/// Time saved is derived from the totals rather than stored, so it follows +/// whichever typing speed it's measured against. +package nonisolated struct DictationStats: Codable, Equatable, Sendable { + /// Average phone typing speed, the baseline for time saved: 36 words per + /// minute (Palin et al., "How do people type on mobile devices?", MobileHCI + /// 2019). + package static let averageTypingWordsPerMinute: Double = 36 + + package private(set) var dictations = 0 + package private(set) var words = 0 + package private(set) var secondsSpoken: Double = 0 + + package init() {} + + /// How long typing every dictated word would have taken at `wordsPerMinute`, + /// minus the time actually spent talking. Never negative. + package func timeSaved(typingWordsPerMinute wordsPerMinute: Double = averageTypingWordsPerMinute) -> Duration { + guard wordsPerMinute > 0 else { return .zero } + let typingSeconds = Double(words) / wordsPerMinute * 60 + return .seconds(max(0, typingSeconds - secondsSpoken)) + } + + /// One delivered dictation: the words as inserted (text shortcuts expanded, + /// since that's what wasn't typed) and how long the user spoke. Empty text + /// isn't a dictation. + package mutating func record(_ text: String, spokenFor seconds: Double) { + let count = Self.wordCount(of: text) + guard count > 0 else { return } + dictations += 1 + words += count + secondsSpoken += max(0, seconds) + } + + /// Linguistic words, not whitespace runs, so punctuation and languages + /// written without spaces count sensibly. + package static func wordCount(of text: String) -> Int { + var count = 0 + text.enumerateSubstrings(in: text.startIndex..., options: [.byWords, .substringNotRequired]) { _, _, _, _ in + count += 1 + } + return count + } + + // MARK: - Persistence + + /// The JSON-encoded totals, a `Data` slot so a view can observe it with + /// `@AppStorage` and redraw as dictations land. + package static let defaultsKey = "DictationStats" + + package static func load(from defaults: UserDefaults = .standard) -> DictationStats { + decode(defaults.data(forKey: defaultsKey)) + } + + /// The totals behind a slot value a view already observed; blank or + /// unreadable is no dictations yet. + package static func decode(_ data: Data?) -> DictationStats { + data.flatMap { try? JSONDecoder().decode(DictationStats.self, from: $0) } ?? DictationStats() + } + + package func save(to defaults: UserDefaults = .standard) { + if let data = try? JSONEncoder().encode(self) { defaults.set(data, forKey: Self.defaultsKey) } + } + + /// Records one dictation straight into `defaults`. The app calls this and + /// `reset` from the main actor only, so a reset can't land between a + /// record's read and its write. + package static func record(_ text: String, spokenFor seconds: Double, in defaults: UserDefaults = .standard) { + var stats = load(from: defaults) + stats.record(text, spokenFor: seconds) + stats.save(to: defaults) + } + + package static func reset(in defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: defaultsKey) + } +} + +/// The stats card's renderings. Counts stay short; time saved is shown in full +/// ("1 hr 25 min 45 sec"), because watching every second add up is the point. +package nonisolated enum StatFormat { + /// A count, compact past four digits: `9,999`, then `12K`. + package static func count(_ value: Int) -> String { + value < 10_000 ? value.formatted() : value.formatted(.number.notation(.compactName)) + } + + /// A duration as number-and-unit parts, largest first, skipping zero parts: + /// `[("1", "hr"), ("25", "min"), ("45", "sec")]`. Under a second is `0 sec`. + package static func durationParts(_ duration: Duration) -> [(value: String, unit: String)] { + var remaining = Int(duration.components.seconds) + guard remaining > 0 else { return [("0", "sec")] } + var parts: [(String, String)] = [] + for (unit, size) in [("day", 86_400), ("hr", 3_600), ("min", 60), ("sec", 1)] { + let amount = remaining / size + remaining %= size + if amount > 0 { parts.append((amount.formatted(), unit)) } + } + return parts + } +} 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/BlurtiOSCore/TypingTest.swift b/App/BlurtiOS/BlurtiOSCore/TypingTest.swift new file mode 100644 index 00000000..5bd9e3fc --- /dev/null +++ b/App/BlurtiOS/BlurtiOSCore/TypingTest.swift @@ -0,0 +1,134 @@ +import Foundation + +/// The easter egg behind Settings: a 30-second sprint over random common words +/// that measures how fast the user types. Pure state, so the scoring is +/// tested; `TypingTestView` owns the clock and the theatrics. +/// +/// Scored the standard way: a "word" is five characters, and only correctly +/// typed words count, each with its trailing space. The word being typed when +/// time runs out doesn't count. +package nonisolated struct TypingTest: Sendable { + package static let duration: Duration = .seconds(30) + /// Seconds left when the clock turns and starts to thump. + package static let finalStretch: Duration = .seconds(5) + /// Conversational speech runs about 150 words a minute, the comparison the + /// results end on. + package static let speakingWordsPerMinute: Double = 150 + + package let words: [String] + package private(set) var index = 0 + package private(set) var outcomes: [Bool] = [] + private var correctCharacters = 0 + + package init(words: [String] = TypingTest.randomWords()) { + self.words = words.isEmpty ? ["blurt"] : words + } + + package var currentWord: String { words[index % words.count] } + + /// Scores `typed` against the current word and moves on. Returns whether it + /// was right. + @discardableResult + package mutating func submit(_ typed: String) -> Bool { + let isCorrect = typed == currentWord + outcomes.append(isCorrect) + if isCorrect { correctCharacters += typed.count + 1 } + index += 1 + return isCorrect + } + + /// Whether what's typed so far could still become the current word: the + /// field turns orange mid-word when it can't. + package func isOnTrack(_ partial: String) -> Bool { currentWord.hasPrefix(partial) } + + package func wordsPerMinute(after elapsed: Duration) -> Double { + let minutes = Double(elapsed.components.seconds) / 60 + Double(elapsed.components.attoseconds) / 6e19 + guard minutes > 0 else { return 0 } + return Double(correctCharacters) / 5 / minutes + } + + package var accuracy: Double { + guard !outcomes.isEmpty else { return 0 } + return Double(outcomes.filter { $0 }.count) / Double(outcomes.count) + } + + package struct Rank: Equatable, Sendable { + package let title: String + package let line: String + } + + package static func rank(for wordsPerMinute: Double) -> Rank { + switch wordsPerMinute { + case ..<25: Rank(title: "Thumb wrestler", line: "Your thumbs are doing their best.") + case ..<40: Rank(title: "Steady thumbs", line: "Right around the phone average.") + case ..<55: Rank(title: "Speed thumbs", line: "Faster than most people on a phone.") + case ..<75: Rank(title: "Thumb demon", line: "Seriously quick.") + default: Rank(title: "Are you even human?", line: "We'd like to study your thumbs.") + } + } + + /// Plenty for a 30-second sprint even at 200 wpm. + package static func randomWords(count: Int = 120) -> [String] { + (0.. Double { + stored > 0 ? stored : DictationStats.averageTypingWordsPerMinute + } + + package static func current(in defaults: UserDefaults = .standard) -> Double { + resolved(defaults.double(forKey: defaultsKey)) + } + + package static func best(in defaults: UserDefaults = .standard) -> Double { + defaults.double(forKey: bestKey) + } + + /// Records a finished sprint: a new best is kept, and a meaningful result + /// becomes the time-saved speed, rounded as it's shown. + package static func record(_ wordsPerMinute: Double, in defaults: UserDefaults = .standard) -> Result { + let isBest = wordsPerMinute > best(in: defaults) + if isBest { defaults.set(wordsPerMinute, forKey: bestKey) } + let setsSpeed = wordsPerMinute >= minimumMeaningful + if setsSpeed { defaults.set(wordsPerMinute.rounded(), forKey: defaultsKey) } + return Result(isBest: isBest, setsSpeed: setsSpeed) + } + + package static func resetToAverage(in defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: defaultsKey) + } + + package struct Result: Equatable, Sendable { + package let isBest: Bool + package let setsSpeed: Bool + } +} diff --git a/App/BlurtiOS/BlurtiOSTests/DictationStatsTests.swift b/App/BlurtiOS/BlurtiOSTests/DictationStatsTests.swift new file mode 100644 index 00000000..6c591a50 --- /dev/null +++ b/App/BlurtiOS/BlurtiOSTests/DictationStatsTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing + +@testable import BlurtiOSCore + +@Suite("Dictation stats") +struct DictationStatsTests { + private func scratchDefaults() throws -> UserDefaults { + let name = "DictationStatsTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defaults.removePersistentDomain(forName: name) + return defaults + } + + @Test("a dictation adds one, its words, and the time spoken; empty text adds nothing") + func records() { + var stats = DictationStats() + stats.record("Hello there, world.", spokenFor: 2) + stats.record(" ", spokenFor: 5) + #expect(stats.dictations == 1) + #expect(stats.words == 3) + #expect(stats.secondsSpoken == 2) + } + + @Test("words are linguistic words, not whitespace runs") + func wordCount() { + #expect(DictationStats.wordCount(of: "Well — don't stop, okay?") == 4) + #expect(DictationStats.wordCount(of: "") == 0) + } + + @Test("time saved is the typing time at the speed, less the time spent talking, never negative") + func timeSaved() { + var stats = DictationStats() + stats.record(Array(repeating: "word", count: 72).joined(separator: " "), spokenFor: 30) + // 72 words at 36 wpm is two minutes of typing; half a minute was spent talking. + #expect(stats.timeSaved() == .seconds(90)) + #expect(stats.timeSaved(typingWordsPerMinute: 1_000) == .zero) + #expect(stats.timeSaved(typingWordsPerMinute: 0) == .zero) + } + + @Test("totals persist, accumulate across dictations, and reset to nothing") + func persists() throws { + let defaults = try scratchDefaults() + #expect(DictationStats.load(from: defaults) == DictationStats()) + DictationStats.record("one two", spokenFor: 1, in: defaults) + DictationStats.record("three", spokenFor: 1, in: defaults) + let loaded = DictationStats.load(from: defaults) + #expect(loaded.dictations == 2) + #expect(loaded.words == 3) + DictationStats.reset(in: defaults) + #expect(DictationStats.load(from: defaults) == DictationStats()) + #expect(DictationStats.decode(Data("not json".utf8)) == DictationStats()) + } + + @Test("counts compact past four digits; durations read largest unit first, skipping zeros") + func formats() { + #expect(StatFormat.count(9_999) == 9_999.formatted()) + #expect(StatFormat.count(12_000) == 12_000.formatted(.number.notation(.compactName))) + #expect(StatFormat.durationParts(.zero).map(\.unit) == ["sec"]) + let parts = StatFormat.durationParts(.seconds(3_600 + 25 * 60 + 45)) + #expect(parts.map(\.value) == ["1", "25", "45"]) + #expect(parts.map(\.unit) == ["hr", "min", "sec"]) + #expect(StatFormat.durationParts(.seconds(86_400 + 5)).map(\.unit) == ["day", "sec"]) + } +} 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/BlurtiOSTests/TypingTestTests.swift b/App/BlurtiOS/BlurtiOSTests/TypingTestTests.swift new file mode 100644 index 00000000..65a0e1ba --- /dev/null +++ b/App/BlurtiOS/BlurtiOSTests/TypingTestTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing + +@testable import BlurtiOSCore + +@Suite("Easter egg typing sprint") +struct TypingTestTests { + private func scratchDefaults() throws -> UserDefaults { + let name = "TypingTestTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defaults.removePersistentDomain(forName: name) + return defaults + } + + @Test("only right words score, five characters to a word with its space; accuracy counts every word") + func scoring() { + var test = TypingTest(words: ["hello", "there", "world"]) + #expect(test.isOnTrack("hel")) + #expect(!test.isOnTrack("hex")) + let first = test.submit("hello") + let second = test.submit("thare") + #expect(first) + #expect(!second) + #expect(test.currentWord == "world") + // "hello" plus its space is six characters: 1.2 words in half a minute. + #expect(test.wordsPerMinute(after: .seconds(30)) == 2.4) + #expect(test.accuracy == 0.5) + #expect(test.wordsPerMinute(after: .zero) == 0) + #expect(TypingTest(words: []).currentWord == "blurt") + } + + @Test("the ranks climb with speed") + func ranks() { + #expect(TypingTest.rank(for: 10).title == "Thumb wrestler") + #expect(TypingTest.rank(for: 36).title == "Steady thumbs") + #expect(TypingTest.rank(for: 200).title == "Are you even human?") + #expect(TypingTest.randomWords(count: 7).count == 7) + } + + @Test("a result sets time saved's speed and the best; too few words changes nothing; reset goes back to average") + func speed() throws { + let defaults = try scratchDefaults() + #expect(TypingSpeed.current(in: defaults) == DictationStats.averageTypingWordsPerMinute) + #expect(TypingSpeed.record(52.4, in: defaults) == .init(isBest: true, setsSpeed: true)) + #expect(TypingSpeed.current(in: defaults) == 52) + #expect(TypingSpeed.record(40, in: defaults) == .init(isBest: false, setsSpeed: true)) + #expect(TypingSpeed.current(in: defaults) == 40) + #expect(TypingSpeed.best(in: defaults) == 52.4) + #expect(TypingSpeed.record(2, in: defaults) == .init(isBest: false, setsSpeed: false)) + #expect(TypingSpeed.current(in: defaults) == 40) + TypingSpeed.resetToAverage(in: defaults) + #expect(TypingSpeed.current(in: defaults) == DictationStats.averageTypingWordsPerMinute) + } +} diff --git a/App/BlurtiOS/DESIGN.md b/App/BlurtiOS/DESIGN.md index 65ad9778..7da9f358 100644 --- a/App/BlurtiOS/DESIGN.md +++ b/App/BlurtiOS/DESIGN.md @@ -182,10 +182,21 @@ point sizes throughout, as the system keyboard's; no Dynamic Type. | `size/pill-glyph` | `10` | `Typography.sizePillGlyph` | the × on a key-term pill | | `size/popup` | `32` | `Typography.sizePopup` | the letter pop-up | | `size/remove-glyph` | `12` | `Typography.sizeRemoveGlyph` | the × on a text-shortcut row | +| `size/stat-l` | `23` | `Typography.sizeStatL` | the next size down when the stats would not fit | +| `size/stat-m` | `20` | `Typography.sizeStatM` | and the next | +| `size/stat-s` | `18` | `Typography.sizeStatS` | the smallest a stat is drawn | +| `size/stat-xl` | `26` | `Typography.sizeStatXl` | a stat's serif number, at the largest size that fits | | `size/tab-icon` | `18` | `Typography.sizeTabIcon` | a tab's icon | | `size/tab-label` | `11` | `Typography.sizeTabLabel` | a tab's mono label | | `size/term` | `17` | `Typography.sizeTerm` | the key-term field | | `size/title` | `34` | `Typography.sizeTitle` | the app's serif headline | +| `size/typing-clock` | `64` | `Typography.sizeTypingClock` | the typing test's countdown | +| `size/typing-field` | `22` | `Typography.sizeTypingField` | what you've typed | +| `size/typing-live` | `28` | `Typography.sizeTypingLive` | its live words per minute | +| `size/typing-rank` | `28` | `Typography.sizeTypingRank` | the result's rank, in the serif | +| `size/typing-result` | `96` | `Typography.sizeTypingResult` | the result's words per minute | +| `size/typing-unit` | `16` | `Typography.sizeTypingUnit` | the result's WPM | +| `size/typing-words` | `24` | `Typography.sizeTypingWords` | the words to type | | `tracking/cta` | `1.4` | `Typography.trackingCta` | uppercase mono at 14: the brand's E2 tracking | | `tracking/eyebrow` | `1.2` | `Typography.trackingEyebrow` | uppercase mono at 12: the brand's E1 tracking | | `weight/glyph` | `medium` | `Typography.weightGlyph` | — | @@ -545,6 +556,7 @@ pins the derivation. | `app/section-gap` | `24` | `Metrics.appSectionGap` | between the home screen's sections | | `app/setup-number-width` | `24` | `Metrics.appSetupNumberWidth` | the setup steps' mono numerals | | `app/stack-gap` | `12` | `Metrics.appStackGap` | between a section's eyebrow and its content, and between cards | +| `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 | | `card/border` | `1` | `Metrics.cardBorder` | the card's hairline | @@ -616,6 +628,10 @@ pins the derivation. | `ribs/rest-height` | `16` | `Metrics.ribsRestHeight` | — | | `ribs/rest-width` | `40` | `Metrics.ribsRestWidth` | the ribs asleep: the wave's own bars at rest | | `row/gap` | `11` | `Metrics.rowGap` | between rows, the iPhone's | +| `stat/part-gap-ratio` | `0.38` | `Metrics.statPartGapRatio` | between a stat's number-and-unit parts, as a fraction of its size | +| `stat/tracking-ratio` | `-0.03` | `Metrics.statTrackingRatio` | a stat number's tracking, as a fraction of its size | +| `stat/unit-gap-ratio` | `0.15` | `Metrics.statUnitGapRatio` | between a number and its unit, as a fraction of its size | +| `stat/unit-ratio` | `0.46` | `Metrics.statUnitRatio` | a stat's mono unit (hr, min, sec) as a fraction of its number's size | | `streak/arm` | `1.4` | `Metrics.streakArm` | the landing flash's vertical arm, as a fraction of the box's height | | `streak/height` | `2` | `Metrics.streakHeight` | the light streak while recording | | `streak/line` | `1` | `Metrics.streakLine` | the hairline at rest | @@ -629,6 +645,12 @@ pins the derivation. | `term/inset` | `2` | `Metrics.termInset` | the field row's side inset | | `term/pad` | `14` | `Metrics.termPad` | the field's side padding | | `term/radius` | `8` | `Metrics.termRadius` | the key-term field: the brand's input corners (radius/input) | +| `typing/field-border` | `2` | `Metrics.typingFieldBorder` | the typing field's border, orange once a word goes wrong | +| `typing/field-pad` | `12` | `Metrics.typingFieldPad` | inside the typing field | +| `typing/line-spacing` | `8` | `Metrics.typingLineSpacing` | between the lines of words to type | +| `typing/pulse` | `1.06` | `Metrics.typingPulse` | the countdown's thump in the last five seconds | +| `typing/result-gap` | `20` | `Metrics.typingResultGap` | between the result's blocks | +| `typing/words-height` | `180` | `Metrics.typingWordsHeight` | the least room the words take, so the field below stays put | | `voice/bar-height` | `32` | `Metrics.voiceBarHeight` | — | | `voice/bar-width` | `160` | `Metrics.voiceBarWidth` | the voice element's box in the voice bar — the most it takes (the ribs awake, the streak); the + sits beside what shows | | `voice/home-height` | `112` | `Metrics.voiceHomeHeight` | — | @@ -671,6 +693,8 @@ house curve (`ease/signature`, `cubic-bezier(0.22, 1, 0.36, 1)`). | `sheen-working` | `1.6` | `Motion.sheenWorking` | and while something is happening: the ring's cadence | | `state-fade` | `0.5` | `Motion.stateFade` | every other change on the key: the ring, a glyph, the dimming | | `term-swap` | `0.4` | `Motion.termSwap` | the voice bar becoming the field | +| `typing-flash` | `0.15` | `Motion.typingFlash` | the typing field's border turning, and the countdown's thump | +| `typing-tick` | `0.1` | `Motion.typingTick` | how often the typing test's clock and live speed redraw | | `wave-fade` | `0.7` | `Motion.waveFade` | the orb and the wave crossing, either way | @@ -701,9 +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): the setup card only while something is missing, -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 @@ -719,6 +756,15 @@ About (with AssemblyAI's privacy policy). The theme picker shows each theme's two faces side by side. Styles, the import sheet, the key entry and the consent sheet share the form's chrome (`brandForm()`). +**The easter egg** (`TypingTestView`, `TypingTest`, `TypingSpeed`): a quiet +"How fast can you type?" link at the foot of Settings opens a 30-second sprint +over common words, full screen — a mono countdown that turns orange and thumps +through the last five seconds (no thump under Reduce Motion), live WPM in the +accent, the current word underlined, wrong words struck through in orange, +the field's border orange once a word goes wrong. The result: WPM, a rank, +accuracy, the best, and talking's 150 wpm against yours; the latest result +becomes time saved's typing speed ("Using your N wpm · Reset to average"). + **Consent** (`ConsentView`, `AIConsent`): before the listening window first opens, a sheet names AssemblyAI, lists what is sent (the voice while dictating, the text before the cursor, recent dictations, key terms with diff --git a/App/BlurtiOS/Design/tokens.json b/App/BlurtiOS/Design/tokens.json index d4a90598..9e677fe8 100644 --- a/App/BlurtiOS/Design/tokens.json +++ b/App/BlurtiOS/Design/tokens.json @@ -772,6 +772,50 @@ "app/row-pad-y": { "value": 8, "use": "a list row's top and bottom inside a card" + }, + "stat/unit-ratio": { + "value": 0.46, + "use": "a stat's mono unit (hr, min, sec) as a fraction of its number's size" + }, + "stat/part-gap-ratio": { + "value": 0.38, + "use": "between a stat's number-and-unit parts, as a fraction of its size" + }, + "stat/unit-gap-ratio": { + "value": 0.15, + "use": "between a number and its unit, as a fraction of its size" + }, + "stat/tracking-ratio": { + "value": -0.03, + "use": "a stat number's tracking, as a fraction of its size" + }, + "app/stat-gap": { + "value": 16, + "use": "between the stats in the card, and between its rows" + }, + "typing/line-spacing": { + "value": 8, + "use": "between the lines of words to type" + }, + "typing/words-height": { + "value": 180, + "use": "the least room the words take, so the field below stays put" + }, + "typing/field-pad": { + "value": 12, + "use": "inside the typing field" + }, + "typing/field-border": { + "value": 2, + "use": "the typing field's border, orange once a word goes wrong" + }, + "typing/pulse": { + "value": 1.06, + "use": "the countdown's thump in the last five seconds" + }, + "typing/result-gap": { + "value": 20, + "use": "between the result's blocks" } }, "type": { @@ -867,6 +911,50 @@ "size/remove-glyph": { "value": 12, "use": "the × on a text-shortcut row" + }, + "size/stat-xl": { + "value": 26, + "use": "a stat's serif number, at the largest size that fits" + }, + "size/stat-l": { + "value": 23, + "use": "the next size down when the stats would not fit" + }, + "size/stat-m": { + "value": 20, + "use": "and the next" + }, + "size/stat-s": { + "value": 18, + "use": "the smallest a stat is drawn" + }, + "size/typing-clock": { + "value": 64, + "use": "the typing test's countdown" + }, + "size/typing-live": { + "value": 28, + "use": "its live words per minute" + }, + "size/typing-words": { + "value": 24, + "use": "the words to type" + }, + "size/typing-field": { + "value": 22, + "use": "what you've typed" + }, + "size/typing-result": { + "value": 96, + "use": "the result's words per minute" + }, + "size/typing-unit": { + "value": 16, + "use": "the result's WPM" + }, + "size/typing-rank": { + "value": 28, + "use": "the result's rank, in the serif" } }, "motion": { @@ -933,6 +1021,14 @@ "glint-life": { "value": 0.5, "use": "a facet's flash while recording" + }, + "typing-tick": { + "value": 0.1, + "use": "how often the typing test's clock and live speed redraw" + }, + "typing-flash": { + "value": 0.15, + "use": "the typing field's border turning, and the countdown's thump" } } }