From 44a2676ef9721ec7c115cb8c707bfa0172376835 Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 18:15:57 -0500 Subject: [PATCH 1/3] feat(ios): add the easter egg A quiet "How fast can you type?" link at the foot of Settings opens a 30-second sprint over common words, ported from the blurt-ios prototype: the clock starts on the first key, turns orange and thumps through the last five seconds, and the result shows WPM, a rank, accuracy, the best, and how talking at 150 wpm compares. The latest result becomes the typing speed time saved is measured against, in place of the 36 wpm phone average, with a "Reset to average" link beside it. Scoring and the speed live in BlurtiOSCore (TypingTest, TypingSpeed) and are tested. Co-Authored-By: Claude Opus 5.5 --- App/BlurtiOS/BlurtDesign/DesignTokens.swift | 45 +++ .../BlurtiOS/Sources/BlurtiOSApp.swift | 3 + .../BlurtiOS/Sources/SettingsView.swift | 16 ++ App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift | 5 +- .../BlurtiOS/Sources/TypingTestView.swift | 261 ++++++++++++++++++ App/BlurtiOS/BlurtiOSCore/TypingTest.swift | 134 +++++++++ .../BlurtiOSTests/TypingTestTests.swift | 54 ++++ App/BlurtiOS/DESIGN.md | 24 ++ App/BlurtiOS/Design/tokens.json | 60 ++++ 9 files changed, 601 insertions(+), 1 deletion(-) create mode 100644 App/BlurtiOS/BlurtiOS/Sources/TypingTestView.swift create mode 100644 App/BlurtiOS/BlurtiOSCore/TypingTest.swift create mode 100644 App/BlurtiOS/BlurtiOSTests/TypingTestTests.swift diff --git a/App/BlurtiOS/BlurtDesign/DesignTokens.swift b/App/BlurtiOS/BlurtDesign/DesignTokens.swift index bb240fa7..faebbb9a 100644 --- a/App/BlurtiOS/BlurtDesign/DesignTokens.swift +++ b/App/BlurtiOS/BlurtDesign/DesignTokens.swift @@ -417,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 @@ -485,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. @@ -530,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 } @@ -745,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", @@ -781,6 +817,13 @@ package enum DesignTokens { "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", @@ -805,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..a1035458 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift @@ -45,6 +45,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) diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index b342eb13..da562a01 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -20,6 +20,8 @@ struct SettingsView: View { VoiceElementKind.shipped.rawValue @State private var showsKeyEntry = false @State private var confirmsStatsReset = false + @State private var showsTypingTest = false + @AppStorage(TypingSpeed.defaultsKey) private var typingSpeed: Double = 0 @AppStorage(EnhancedTranscriptsStore.defaultsKey) private var enhancedTranscripts = EnhancedTranscriptsStore.defaultValue @@ -143,9 +145,23 @@ struct SettingsView: View { 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") } + .fullScreenCover(isPresented: $showsTypingTest) { TypingTestView() } } private static var version: String { diff --git a/App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift b/App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift index c8bbe241..4c4bdd86 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift @@ -7,10 +7,13 @@ import SwiftUI /// 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() + 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)) } 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/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/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 3673857a..b45c49b6 100644 --- a/App/BlurtiOS/DESIGN.md +++ b/App/BlurtiOS/DESIGN.md @@ -190,6 +190,13 @@ point sizes throughout, as the system keyboard's; no Dynamic Type. | `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` | — | @@ -638,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` | — | @@ -680,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 | @@ -732,6 +747,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 87cd602a..9e677fe8 100644 --- a/App/BlurtiOS/Design/tokens.json +++ b/App/BlurtiOS/Design/tokens.json @@ -792,6 +792,30 @@ "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": { @@ -903,6 +927,34 @@ "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": { @@ -969,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" } } } From da9969d8d5a762e2004e4ae6397318fde4496930 Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 18:19:41 -0500 Subject: [PATCH 2/3] fix(ios): present the Easter Egg once from Settings The cover and the reset confirmation were attached to their sections, and a section's modifiers apply to each of its rows, so the About section's five rows each tried to present the Easter Egg at once. Both now hang off the form. Co-Authored-By: Claude Opus 5.5 --- App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index da562a01..1ad7c5aa 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -42,6 +42,14 @@ 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() } + .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) } @@ -132,11 +140,6 @@ struct SettingsView: View { } footer: { Text("Dictations, words and time saved are counted on this phone and never sent.") } - .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.") - } } private var aboutSection: some View { @@ -161,7 +164,6 @@ struct SettingsView: View { } header: { Eyebrow("About") } - .fullScreenCover(isPresented: $showsTypingTest) { TypingTestView() } } private static var version: String { From 80f0046355927ef638df4b3a646262254a9b7153 Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 18:29:46 -0500 Subject: [PATCH 3/3] feat(ios): open on a setup screen, then keep setup in Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As in the blurt-ios prototype, a first-run screen replaces the tabs until Blurt is set up: the wordmark, a headline, and the numbered steps in a card, with the API key pasted inline (Continue, Get a free key, where the key and the audio go). Without a key it can't be left; with one, Start dictating goes to the tabs and the microphone and keyboard can wait. Whoever was already set up skips it. The setup card leaves the Dictate tab. Settings › Setup keeps the steps, hidden behind "Blurt is set up ✓ · Show steps" once everything is done and open while anything isn't. The rule is SetupProgress (BlurtiOSCore, tested); -BlurtNoKey shows a fresh install's screen in debug builds without touching the Keychain. Co-Authored-By: Claude Opus 5.5 --- .../BlurtiOS/Sources/BlurtiOSApp.swift | 15 ++- App/BlurtiOS/BlurtiOS/Sources/HomeView.swift | 12 +- App/BlurtiOS/BlurtiOS/Sources/MainView.swift | 23 ++++ .../BlurtiOS/Sources/SettingsView.swift | 46 +++++++ .../{SetupCard.swift => SetupSteps.swift} | 40 +++--- App/BlurtiOS/BlurtiOS/Sources/SetupView.swift | 121 ++++++++++++++++++ App/BlurtiOS/BlurtiOSCore/SetupProgress.swift | 14 ++ .../BlurtiOSTests/SetupProgressTests.swift | 15 +++ App/BlurtiOS/DESIGN.md | 23 +++- 9 files changed, 267 insertions(+), 42 deletions(-) rename App/BlurtiOS/BlurtiOS/Sources/{SetupCard.swift => SetupSteps.swift} (68%) create mode 100644 App/BlurtiOS/BlurtiOS/Sources/SetupView.swift create mode 100644 App/BlurtiOS/BlurtiOSCore/SetupProgress.swift create mode 100644 App/BlurtiOS/BlurtiOSTests/SetupProgressTests.swift diff --git a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift index a1035458..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 @@ -63,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/HomeView.swift b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift index 59f2477f..55cb1ccf 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift @@ -7,8 +7,9 @@ import SwiftUI /// The Dictate tab: the Blurt landing page on a phone. The wordmark and /// the gear across the top; the lifetime stats; the status as an eyebrow, a serif headline and a /// line of body text over the voice element; the one green button; then the -/// styles and the recent dictations under their eyebrows. Setup sits on top -/// while anything is missing; everything adjustable lives behind the gear. +/// styles and the recent dictations under their eyebrows. Setup comes before +/// the tabs (`SetupView`) and lives in Settings after; everything adjustable +/// is behind the gear. struct HomeView: View { var coordinator: DictationCoordinator /// Opens Settings, which `MainView` presents over either tab. @@ -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,6 @@ struct HomeView: View { ScrollView { VStack(alignment: .leading, spacing: DesignTokens.Metrics.appSectionGap) { header - if !status.isSetUp { - SetupCard( - hasKey: coordinator.apiKey.hasAPIKey, microphoneGranted: microphoneGranted, keyboardSeen: keyboardSeen, - enterKey: { showsKeyEntry = true }, refresh: refreshStatus) - } StatsCard() HomeHero(coordinator: coordinator, status: status, landedAt: landedAt) StyleChips() @@ -53,7 +48,6 @@ struct HomeView: View { .page() .environment(\.voiceElementKind, VoiceElementKind(rawValue: voiceRaw) ?? .shipped) .toolbar(.hidden, for: .navigationBar) - .sheet(isPresented: $showsKeyEntry) { KeyEntryView(apiKey: coordinator.apiKey) } .onChange(of: scenePhase) { _, phase in if phase == .active { refreshStatus() } } 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 1ad7c5aa..7311c0fb 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 @@ -21,6 +22,10 @@ struct SettingsView: View { @State private var showsKeyEntry = false @State private var confirmsStatsReset = false @State private var showsTypingTest = false + @State private var showsSetupSteps = false + @State private var microphoneGranted = AVAudioApplication.shared.recordPermission == .granted + @State private var keyboardSeen = SharedStore.keyboardEverSeen + @Environment(\.scenePhase) private var scenePhase @AppStorage(TypingSpeed.defaultsKey) private var typingSpeed: Double = 0 @AppStorage(EnhancedTranscriptsStore.defaultsKey) private var enhancedTranscripts = EnhancedTranscriptsStore.defaultValue @@ -28,6 +33,7 @@ struct SettingsView: View { var body: some View { NavigationStack { Form { + setupSection keyboardSection listeningSection transcriptionSection @@ -45,6 +51,9 @@ struct SettingsView: View { // On the form, not on a section: a section's modifiers apply to each of // its rows, so a cover there was presented once per row at the same time. .fullScreenCover(isPresented: $showsTypingTest) { TypingTestView() } + .onChange(of: scenePhase) { _, phase in + if phase == .active { refreshSetup() } + } .confirmationDialog("Reset statistics?", isPresented: $confirmsStatsReset, titleVisibility: .visible) { Button("Reset", role: .destructive) { DictationStats.reset() } } message: { @@ -54,6 +63,43 @@ struct SettingsView: View { .tint(BlurtBrand.accent) } + /// The first-run steps, kept here once the app is set up, as in the + /// blurt-ios prototype: folded to a line while everything is done, open while + /// something (the microphone, the keyboard) is still missing. + private var setupSection: some View { + let isSetUp = coordinator.apiKey.hasAPIKey && microphoneGranted && keyboardSeen + return Section { + if isSetUp { + HStack { + Text("Blurt is set up") + Image(systemName: "checkmark").foregroundStyle(BlurtBrand.accent) + Spacer() + Button(showsSetupSteps ? "Hide steps" : "Show steps") { showsSetupSteps.toggle() } + .font(BlurtType.body(DesignTokens.Typography.sizeCaption, bold: true)) + .foregroundStyle(BlurtBrand.accent) + .buttonStyle(.plain) + } + } + if !isSetUp || showsSetupSteps { + SetupSteps( + hasKey: coordinator.apiKey.hasAPIKey, microphoneGranted: microphoneGranted, keyboardSeen: keyboardSeen, + refresh: refreshSetup + ) { + Button("Add an API key") { showsKeyEntry = true }.buttonStyle(BrandButtonStyle()) + } + .padding(.vertical, DesignTokens.Metrics.appRowPadY) + } + } header: { + Eyebrow("Setup") + } + } + + private func refreshSetup() { + microphoneGranted = AVAudioApplication.shared.recordPermission == .granted + keyboardSeen = SharedStore.keyboardEverSeen + coordinator.apiKey.refreshStatus() + } + private var keyboardSection: some View { Section { Picker("Layout", selection: $layout) { 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/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/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/DESIGN.md b/App/BlurtiOS/DESIGN.md index b45c49b6..7da9f358 100644 --- a/App/BlurtiOS/DESIGN.md +++ b/App/BlurtiOS/DESIGN.md @@ -725,13 +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; `StatsCard`): the setup card only while something is missing, -then the lifetime stats — `DICTATIONS`, `WORDS`, `TIME SAVED` (against 36 wpm, -the phone average) 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); -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