From 7a1e6bffc954ba89a04552b0949d43cda5cba4eb Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 18:11:27 -0500 Subject: [PATCH 1/8] refactor(sdk): move key terms and text shortcuts into the AssemblyAI SDK KeyTerms (parse a comma-separated list, fit it to keyterms_prompt's caps; was KeytermsBoost plus KeyTermsStore.parse) and TextShortcut with its on-device TextShortcutExpander now live in Sources/AssemblyAI, so any app on the SDK gets them. BlurtEngine keeps Blurt's storage: KeyTermsStore and TextShortcutStore, their UserDefaults keys and limits. The iPhone's duplicate KeyTermList is replaced by KeyTerms. The Mac app only gains an import; its behaviour and stored data are unchanged. Co-Authored-By: Claude Opus 5.5 --- .claude/agents/cleanup-reviewer.md | 2 +- .claude/skills/project-guardrails/SKILL.md | 2 +- AGENTS.md | 28 +++--- .../Blurt/Wizard/Steps/KeyTermsStepView.swift | 2 +- .../Blurt/Wizard/TextShortcutsSection.swift | 7 +- .../Sources/DictationCoordinator.swift | 2 +- .../BlurtiOS/Sources/SettingsView.swift | 7 +- App/BlurtiOS/BlurtiOSCore/KeyTermList.swift | 21 ----- App/BlurtiOS/BlurtiOSCore/SharedState.swift | 5 +- App/BlurtiOS/BlurtiOSCore/TermPack.swift | 7 +- .../BlurtiOSTests/SharedStateTests.swift | 20 ----- App/BlurtiOS/DESIGN.md | 2 +- App/BlurtiOS/README.md | 2 +- README.md | 3 +- .../Dictation/DictationWireTypes.swift | 2 +- .../Dictation/KeyTerms.swift} | 86 +++++++++++-------- .../Dictation/TextShortcuts.swift} | 52 +++++++++-- Sources/AssemblyAI/README.md | 6 +- .../BlurtEngine/Audio/SoundPackCatalog.swift | 2 +- .../BlurtEngine/Config/APIKeySubmission.swift | 1 + .../BlurtEngine/Config/KeyTermsStore.swift | 30 ++----- .../Config/TextShortcutStore.swift | 40 ++------- .../BlurtEngine/Pipeline/DictationLog.swift | 5 +- .../Pipeline/DictationSession+Pipeline.swift | 1 + .../Pipeline/DictationSession.swift | 6 +- Sources/BlurtEngine/README.md | 6 +- .../STT/AssemblyAITranscriber.swift | 2 +- Sources/BlurtEngine/STT/STTPrompt.swift | 8 +- .../STT/TranscriptionContext.swift | 4 +- .../BlurtEngineTests/DictationLogTests.swift | 3 +- .../BlurtEngineTests/KeyTermsStoreTests.swift | 30 ------- ...msBoostTests.swift => KeyTermsTests.swift} | 78 ++++++++++++----- .../BlurtEngineTests/KeytermsWireTests.swift | 2 +- .../PersistedSettingsTests.swift | 1 + Tests/BlurtEngineTests/STTPromptTests.swift | 2 +- .../Stubs/DictationSessionTestSupport.swift | 1 + .../BlurtEngineTests/TextShortcutTests.swift | 3 +- .../TranscriptionContextTests.swift | 3 +- scripts/mutate.sh | 3 +- 39 files changed, 236 insertions(+), 251 deletions(-) delete mode 100644 App/BlurtiOS/BlurtiOSCore/KeyTermList.swift rename Sources/{BlurtEngine/STT/KeytermsBoost.swift => AssemblyAI/Dictation/KeyTerms.swift} (52%) rename Sources/{BlurtEngine/Pipeline/TextShortcutExpander.swift => AssemblyAI/Dictation/TextShortcuts.swift} (75%) rename Tests/BlurtEngineTests/{KeytermsBoostTests.swift => KeyTermsTests.swift} (54%) diff --git a/.claude/agents/cleanup-reviewer.md b/.claude/agents/cleanup-reviewer.md index ca5a0440..371cbc23 100644 --- a/.claude/agents/cleanup-reviewer.md +++ b/.claude/agents/cleanup-reviewer.md @@ -39,7 +39,7 @@ spacing, the injector's window identity, the developer-mode log. `appName`, `windowTitle`, `fieldLabel` and `selectedText` are not unused just because `STTPrompt` ignores them. Nor is `targetIsSecure`, which no request carries: it is what stops a password dictated into a secure field being remembered as history. The -key-terms read is not unused either: it feeds `KeytermsBoost`, the request's separate +key-terms read is not unused either: it feeds `KeyTerms`, the request's separate `config.keyterms_prompt` list. Do not propose deleting any of them, folding the key terms back into the context as a `Keywords:` clause, or dropping the `context:` parameter. diff --git a/.claude/skills/project-guardrails/SKILL.md b/.claude/skills/project-guardrails/SKILL.md index b0a2d98b..35bc2d77 100644 --- a/.claude/skills/project-guardrails/SKILL.md +++ b/.claude/skills/project-guardrails/SKILL.md @@ -85,7 +85,7 @@ only one of stt_prompt or prompt; they are the same field`, before the audio is read. Adding it alongside is not a compatibility shim, it is every dictation failing. - **Key terms are keyterms prompting, not context text.** They ride - `config.keyterms_prompt` as a flat array of strings (`KeytermsBoost`), fitted to + `config.keyterms_prompt` as a flat array of strings (`KeyTerms`), fitted to that field's own 2048-character cap _and_ its 100-term `maxItems` — two caps on one field, and the count is reachable under the byte budget. Don't fold them back into the context as a `Keywords: a, b, c.` clause, and don't also send `keyterms` or `word_boost` — diff --git a/AGENTS.md b/AGENTS.md index 3ee88bbe..6c3f4c1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,8 @@ Four reflexes before you touch anything: The package's second library, **`AssemblyAI`** (`Sources/AssemblyAI/`), is the AssemblyAI API client the engine is built on: the `HTTPTransport` seam and its streamed upload body, the dictation wire types and -multipart framing, and `APIKeyValidator`. It holds no Blurt policy (no prompts, styles, key storage or +multipart framing, `APIKeyValidator`, and dictation vocabulary — `KeyTerms` (parsing a list and fitting +it to `keyterms_prompt`'s caps) and `TextShortcut` with its on-device `TextShortcutExpander`. It holds no Blurt policy (no prompts, styles, key storage or `User-Agent`; `APIKeyValidator.blurt()` is where Blurt's agent joins it), so it can become a standalone Swift SDK. It is part of the same local package, so the dependency-free rule is unchanged. @@ -58,8 +59,9 @@ workflow and the _why_ behind the design; the engine's README covers the _what_ ```text Sources/AssemblyAI/ the AssemblyAI API client library (README.md there): HTTPTransport + - ChunkedRequestBody, Dictation/ (wire types, multipart framing), - APIKeyValidator. No Blurt policy; BlurtEngine depends on it + ChunkedRequestBody, Dictation/ (wire types, multipart framing, + KeyTerms, TextShortcuts), APIKeyValidator. No Blurt policy; + BlurtEngine depends on it Sources/BlurtEngine/ the engine (dependency-free Swift package) README.md the engine's developer guide (quick start, seams, error table) Audio/ MicCapture (+meter) over CaptureSessionRecorder (the @@ -85,7 +87,7 @@ Sources/BlurtEngine/ the engine (dependency-free Swift package) PermissionsChecker (mic + Accessibility), PermissionsReset, SigningIdentity (macOS only) Pipeline/ DictationSession (actor) + phases, UI projections, geometry, log - STT/ AssemblyAITranscriber, Conversation/TranscriptionContext, KeytermsBoost, + STT/ AssemblyAITranscriber, Conversation/TranscriptionContext, CleanupInstruction, SyncSTTLimits App/Blurt/ project.yml XcodeGen source of truth — Blurt.xcodeproj is GENERATED @@ -416,7 +418,7 @@ rule along with the row. | Pin transcription to English, or set a language at all | Hurt non-English transcription; language is left to the model's own detection. **No `config.language_codes`** either — note the plural, which is the documented spelling, though the undocumented singular `language_code` is accepted too (a 200, not the unknown-key 400 a bogus name earns), so both are live and both are pinned absent. Detection was measured to work with neither it nor a prompt set (es/fr/de/ja clips each transcribed in their own language against the live endpoint, rewrite included), and re-measured 2026-09-11: a Spanish clip came back as Spanish with no field, with `language_codes: ["es"]`, **and** with `language_codes: ["en"]` — asking for English did not force English, so the reference's `["en"]` default does not describe this route and omission is not a bet on a default. Setting one would only take working detection away. `KeytermsWireTests` asserts the absence of both spellings. | | Send `config.prompt` alongside `config.stt_prompt` | They are the same field under two names — the docs give `stt_prompt` as the field and note it is "Also accepted as `prompt`" — and a request carrying both is rejected before the audio is read (`provide only one of stt_prompt or prompt; they are the same field`). So this is never a compatible addition: it is a 400 on _every_ dictation. `stt_prompt` is the documented spelling and the only one to send; `prompt` is the legacy one, and the route's own inner validator still answers under it (see `STTPrompt.characterCap`), which is a reason to treat it as deprecated rather than interchangeable. | | Bring back `config.conversation_context` | Replaced by `config.stt_prompt` (`STTPrompt`), which carries the same two signals as one string. Both fields still work and can ride the same request, so re-adding the turn list sends the prior text twice rather than failing. `prompt` is `stt_prompt`'s own alias — a request carrying both is a 400 (`they are the same field`) — and a custom prompt replaces the service's managed default, which is the trade this swap accepted. | -| Widen the request's context past history + the prior chunk | `STTPrompt.text` reads exactly two fields of `TranscriptionContext` — `recentTranscripts` and `priorText`. The app name, window title, field label and selected text are captured for the paste path and the developer-mode log and stay on the machine; the app/window/field hints and the `Selected text:` block were removed, not gated. Don't add one back, and don't route that context onto the request by another path. The user's key terms are the exception that proves the rule: they _are_ sent, as the request's own `keyterms_prompt` list (`KeytermsBoost`) — never folded back into the prompt. | +| Widen the request's context past history + the prior chunk | `STTPrompt.text` reads exactly two fields of `TranscriptionContext` — `recentTranscripts` and `priorText`. The app name, window title, field label and selected text are captured for the paste path and the developer-mode log and stay on the machine; the app/window/field hints and the `Selected text:` block were removed, not gated. Don't add one back, and don't route that context onto the request by another path. The user's key terms are the exception that proves the rule: they _are_ sent, as the request's own `keyterms_prompt` list (`KeyTerms`) — never folded back into the prompt. | | Add a "remove filler words (um, uh, like)" clause | Not in the STT model's trained instruction set — a no-op, deliberately dropped; disfluency removal is the server-side LLM rewrite's job. | | Add a keystroke-typing paste path or a length threshold | Injection is **always** clipboard paste (save → write → ⌘V → settle → restore), with the copied-to-clipboard degradation when the target is lost. | | Add `LSUIElement` or a menu-bar-**only** mode | Blurt is a Dock app first. The `MenuBarExtra` status item is convenience layered on the Dock icon; the notch can hide a status item, so nothing may depend on it. A menu-bar-only variant was reverted twice. | @@ -691,9 +693,9 @@ and the instruction cap was measured against the live endpoint before the refere (it now gives `llm_instruction` a `maxLength` of 2048, in characters, while the constant counts UTF-8 bytes — the conservative unit). There is a _third_ cap in the same request — `config.keyterms_prompt` accepts 2048 characters summed across all terms -(`KeytermsBoost.characterCap`), the same figure as the instruction cap but a limit on a different +(`KeyTerms.characterCap`), the same figure as the instruction cap but a limit on a different field — and that field carries a _fourth_, which is a **count** and not a size: -at most 100 terms (`KeytermsBoost.termCap`, the reference's `maxItems`). The count is the one the +at most 100 terms (`KeyTerms.termCap`, the reference's `maxItems`). The count is the one the byte budget does not imply, and it is reachable, since 150 short terms cost ~1500 bytes and clear the byte cap easily. Four caps, four constants: never reuse one field's figure for another, and never assume one cap on a field is the only cap on it. @@ -946,14 +948,14 @@ as trimming an over-cap list, which made our 4096 a bandwidth measure; `stt_prom what stands between a long history and every dictation failing. Its unit is **Unicode scalars**, measured rather than inferred from the word "characters": 4096 `é` (8192 UTF-8 bytes) is accepted, while 820 family emoji — 820 grapheme clusters but 4100 scalars — is rejected. So Swift's -`String.count` would under-count and let a 400 through, and the UTF-8 bytes `KeytermsBoost` and +`String.count` would under-count and let a 400 through, and the UTF-8 bytes `KeyTerms` and `CleanupInstruction` count (their own servers' units being unmeasured) would needlessly halve the budget for accented text. Two omissions are deliberate and regression-tested — no language directive and no filler-word clause; see [Settled decisions](#settled-decisions--dont-reintroduce-these). **The key terms are the request's other steering field.** -`Sources/BlurtEngine/STT/KeytermsBoost.swift` sends the user's Settings list as +`Sources/AssemblyAI/Dictation/KeyTerms.swift` sends the user's Settings list as `config.keyterms_prompt` — keyterms prompting: a flat array of strings biasing recognition toward those exact spellings. It is a sibling of `stt_prompt`, not an alternative; the API takes both on the same request and @@ -967,8 +969,8 @@ both halves of that, since `/v1/transcribe/live` rejects an unknown key outright (`Extra inputs are not permitted`) and `keyterms_prompt` returns 200. Send exactly one of the three, never two — they are mutually exclusive on this route (`provide only one of keyterms_prompt, keyterms, or word_boost`, 400 before the audio is read), so a rename here is a swap and never an -addition. `KeytermsBoost.fitted` takes whole terms in the user's order while -they fit the field's own 2048-character cap (`KeytermsBoost.characterCap`, measured in UTF-8 bytes — +addition. `KeyTerms.fitted` takes whole terms in the user's order while +they fit the field's own 2048-character cap (`KeyTerms.characterCap`, measured in UTF-8 bytes — the conservative reading, as with `CleanupInstruction`) **and** the list stays within `termCap` (100), the reference's `maxItems` on the same field — two independent limits, so both are checked; a comma-split list of 150 short terms clears the byte cap and 400s on the count. It returns a plain `[String]`, not an @@ -976,7 +978,7 @@ optional one — the repo bans optional collections, and "no terms" needs only o omit-vs-`[]` distinction that _does_ matter to the API lives in `DictationConfig.encode(to:)`, which drops the key for an empty list. Terms arrive already trimmed and deduped from `KeyTermsStore.parse`, so length is the only thing enforced here. Unit-tested in -`Tests/BlurtEngineTests/KeytermsBoostTests.swift`. +`Tests/BlurtEngineTests/KeyTermsTests.swift`. **The two fields are deliberately different JSON types, and neither tolerates the other's.** `stt_prompt` is a string — an array earns `400 stt_prompt: Input should be a valid string` — while @@ -1006,7 +1008,7 @@ Engine-side stores, all `UserDefaults`-backed value types with the same shape: - **`TriggerKeyStore`** (`BlurtTriggerKeyCode`), **`SoundPackStore`** (`BlurtSoundPack`), **`KeyTermsStore`** (`BlurtKeyTerms`, the user's domain vocabulary, re-read at every press via the session's `keyTermsProvider` and sent as the request's `keyterms_prompt` list — - see `KeytermsBoost`), + see `KeyTerms`), **`DeveloperModeStore`** (`BlurtDeveloperMode`, off by default), **`EnhancedTranscriptsStore`** (`BlurtEnhancedTranscripts`, **on** by default — unset reads as enabled; picks the cleanup rewrite over the verbatim transcript in the dictation _response_, diff --git a/App/Blurt/Blurt/Wizard/Steps/KeyTermsStepView.swift b/App/Blurt/Blurt/Wizard/Steps/KeyTermsStepView.swift index e11aa128..d9b65879 100644 --- a/App/Blurt/Blurt/Wizard/Steps/KeyTermsStepView.swift +++ b/App/Blurt/Blurt/Wizard/Steps/KeyTermsStepView.swift @@ -4,7 +4,7 @@ import SwiftUI /// The "Key Terms" section of the Settings window: a free-text /// area where the user lists comma-separated domain words (names, jargon, product /// names). These are sent as every dictation request's word-boost list -/// (see `KeyTermsStore` / `KeytermsBoost`), so the model favors those exact +/// (see `KeyTermsStore` / `KeyTerms`), so the model favors those exact /// spellings. Optional — it never gates setup; an empty list just sends no terms, /// which omits the field. struct KeyTermsStepView: View { diff --git a/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift b/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift index 8e799170..6a19e9c4 100644 --- a/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift +++ b/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift @@ -1,3 +1,4 @@ +import AssemblyAI import BlurtEngine import SwiftUI @@ -143,14 +144,14 @@ private struct TextShortcutEditorSheet: View { self.shortcut = shortcut isExisting = shortcuts.contains { $0.id == shortcut.id } otherKeys = Set( - shortcuts.filter { $0.id != shortcut.id }.map { TextShortcutStore.matchKey(for: $0.trigger) }) + shortcuts.filter { $0.id != shortcut.id }.map { TextShortcut.matchKey(for: $0.trigger) }) _trigger = State(initialValue: shortcut.trigger) _expansion = State(initialValue: shortcut.expansion) } - /// The phrase as the matcher sees it (`TextShortcutStore.matchKey`) — empty + /// The phrase as the matcher sees it (`TextShortcut.matchKey`) — empty /// when it has no letters or digits. - private var triggerKey: String { TextShortcutStore.matchKey(for: trigger) } + private var triggerKey: String { TextShortcut.matchKey(for: trigger) } /// A phrase the matcher can't tell from another shortcut's — "personal /// email" vs "Personal-Email" — would be dropped by the store's dedupe, so diff --git a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift index 7565afad..2b5715e4 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift @@ -204,7 +204,7 @@ final class DictationCoordinator { /// The user's own key terms (from the App Group, where the keyboard adds /// them on the spot), then the contact names the keyboard read off the - /// phone, deduplicated case-insensitively. `KeytermsBoost` fits the list + /// phone, deduplicated case-insensitively. `KeyTerms` fits the list /// to the request's caps (100 terms, 2048 bytes), first entries first, which /// is why the typed terms lead. nonisolated static func keyTerms() -> [String] { diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index 3b45b100..f6bcb265 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -1,3 +1,4 @@ +import AssemblyAI import BlurtDesign import BlurtEngine import BlurtiOSCore @@ -106,8 +107,8 @@ struct SettingsView: View { NavigationLink("Output styles") { StylesView() } TextField("Key terms, comma-separated", text: $keyTerms, axis: .vertical) .autocorrectionDisabled() - LabeledContent("Key terms", value: "\(KeyTermList.parse(keyTerms).count)") - if !KeyTermList.parse(keyTerms).isEmpty { + LabeledContent("Key terms", value: "\(KeyTerms.parse(keyTerms).count)") + if !KeyTerms.parse(keyTerms).isEmpty { ShareLink( item: termPack, subject: Text("Blurt key terms"), message: Text(termPack.plainText), preview: SharePreview(termPack.name, icon: Image(systemName: "text.badge.plus")) @@ -147,7 +148,7 @@ struct SettingsView: View { /// The whole list, for a friend: a `.blurtterms` file plus the words as text. private var termPack: TermPack { - TermPack(name: "Key terms", from: nil, terms: KeyTermList.parse(keyTerms)) + TermPack(name: "Key terms", from: nil, terms: KeyTerms.parse(keyTerms)) } private static var version: String { diff --git a/App/BlurtiOS/BlurtiOSCore/KeyTermList.swift b/App/BlurtiOS/BlurtiOSCore/KeyTermList.swift deleted file mode 100644 index cc212d1a..00000000 --- a/App/BlurtiOS/BlurtiOSCore/KeyTermList.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation - -/// The comma-separated key-term list, as the engine's `KeyTermsStore` reads -/// it: split, trimmed, emptied of blanks, deduplicated case-insensitively in -/// first-seen order. -package nonisolated enum KeyTermList { - package static func parse(_ raw: String) -> [String] { - var seen = Set() - var terms: [String] = [] - for piece in raw.split(separator: ",") { - let term = piece.trimmingCharacters(in: .whitespacesAndNewlines) - guard !term.isEmpty, seen.insert(term.lowercased()).inserted else { continue } - terms.append(term) - } - return terms - } - - package static func join(_ terms: [String]) -> String { - terms.joined(separator: ", ") - } -} diff --git a/App/BlurtiOS/BlurtiOSCore/SharedState.swift b/App/BlurtiOS/BlurtiOSCore/SharedState.swift index f6246837..667d94e7 100644 --- a/App/BlurtiOS/BlurtiOSCore/SharedState.swift +++ b/App/BlurtiOS/BlurtiOSCore/SharedState.swift @@ -1,3 +1,4 @@ +import AssemblyAI import Foundation // MARK: - The App Group contract @@ -336,8 +337,8 @@ package nonisolated enum SharedStore { /// `BlurtKeyTerms`, comma-separated) but in the App Group, so the keyboard /// can add one on the spot and the app reads it on the very next press. package static var keyTerms: [String] { - get { KeyTermList.parse(defaults.string(forKey: keyTermsKey) ?? "") } - set { defaults.set(KeyTermList.join(newValue), forKey: keyTermsKey) } + get { KeyTerms.parse(defaults.string(forKey: keyTermsKey) ?? "") } + set { defaults.set(KeyTerms.join(newValue), forKey: keyTermsKey) } } /// `BlurtKeyTerms` — spelled out here rather than imported from the engine so diff --git a/App/BlurtiOS/BlurtiOSCore/TermPack.swift b/App/BlurtiOS/BlurtiOSCore/TermPack.swift index a5633e2a..f5dc3b43 100644 --- a/App/BlurtiOS/BlurtiOSCore/TermPack.swift +++ b/App/BlurtiOS/BlurtiOSCore/TermPack.swift @@ -1,3 +1,4 @@ +import AssemblyAI import CoreTransferable import Foundation import UniformTypeIdentifiers @@ -56,7 +57,7 @@ package nonisolated struct TermPack: Codable, Identifiable, Transferable { /// without Blurt still gets the words. package var plainText: String { let lead = from.map { "\($0)'s Blurt key terms" } ?? "Blurt key terms" - return "\(lead) (\(name)): \(KeyTermList.join(terms))" + return "\(lead) (\(name)): \(KeyTerms.join(terms))" } /// Whether a URL is meant to be a pack at all — a `.blurtterms` file or a @@ -82,13 +83,13 @@ package nonisolated struct TermPack: Codable, Identifiable, Transferable { guard let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return nil } let value = { (name: String) in items.first { $0.name == name }?.value } return TermPack( - name: value("name") ?? "Shared key terms", from: value("from"), terms: KeyTermList.parse(value("add") ?? "") + name: value("name") ?? "Shared key terms", from: value("from"), terms: KeyTerms.parse(value("add") ?? "") ).capped() } /// The pack within the caps, or nil when nothing usable is left. private func capped() -> TermPack? { - let kept = KeyTermList.parse(KeyTermList.join(terms)).filter { $0.count <= Self.lengthCap } + let kept = KeyTerms.parse(KeyTerms.join(terms)).filter { $0.count <= Self.lengthCap } let terms = Array(kept.prefix(Self.termCap)) guard !terms.isEmpty else { return nil } return TermPack( diff --git a/App/BlurtiOS/BlurtiOSTests/SharedStateTests.swift b/App/BlurtiOS/BlurtiOSTests/SharedStateTests.swift index 8a68bc7a..2d7d7c38 100644 --- a/App/BlurtiOS/BlurtiOSTests/SharedStateTests.swift +++ b/App/BlurtiOS/BlurtiOSTests/SharedStateTests.swift @@ -40,26 +40,6 @@ struct MicAlignmentSettingTests { } } -@Suite("Key term list") -struct KeyTermListTests { - @Test("parse trims, drops blanks, and deduplicates case-insensitively in first-seen order") - func parse() { - #expect(KeyTermList.parse(" Rizz , skibidi,,RIZZ, Blurt ,") == ["Rizz", "skibidi", "Blurt"]) - } - - @Test("join is what parse reads back") - func roundTrip() { - let terms = ["Neil Bisht", "AssemblyAI", "rizz"] - #expect(KeyTermList.parse(KeyTermList.join(terms)) == terms) - } - - @Test("an empty or all-blank list is empty") - func empty() { - #expect(KeyTermList.parse("").isEmpty) - #expect(KeyTermList.parse(" , ,").isEmpty) - } -} - @Suite("Phase snapshot staleness") struct PhaseSnapshotTests { private func snapshot(_ state: PhaseSnapshot.State, ageSeconds: TimeInterval) -> PhaseSnapshot { diff --git a/App/BlurtiOS/DESIGN.md b/App/BlurtiOS/DESIGN.md index aaf3d615..c602f02b 100644 --- a/App/BlurtiOS/DESIGN.md +++ b/App/BlurtiOS/DESIGN.md @@ -349,7 +349,7 @@ the plain + a recording is released and the words still land; from a highlighted word it is cancelled, since a result replaces the selection and would land over the very word being fixed (tested). Shift is on for the first letter and after a space. Type the right spelling; return or ✓ saves. The term goes into Blurt's key terms -(`BlurtKeyTerms` in the App Group, `KeyTermList` rules: trimmed, deduplicated +(`BlurtKeyTerms` in the App Group, the AssemblyAI SDK's `KeyTerms` rules: trimmed, deduplicated case-insensitively) and rides `keyterms_prompt` on the very next dictation. If it began as a selection and you changed it, the misheard word in your text is replaced with what you typed — only while that highlight still stands where diff --git a/App/BlurtiOS/README.md b/App/BlurtiOS/README.md index 844697cd..b972a953 100644 --- a/App/BlurtiOS/README.md +++ b/App/BlurtiOS/README.md @@ -76,7 +76,7 @@ preview hands the model a `ThemeFace`, not a palette — the colours are the UI' ## Unit tests `scripts/ios-test.sh` runs `BlurtiOSTests` on a simulator (CI's `ios-build` job runs it after -the build): the core's logic — the App Group contract (`KeyTermList`, stale snapshots, layouts), +the build): the core's logic — the App Group contract (key terms, stale snapshots, layouts), the keyboard's rules (sentence start, the double space, the term field, return labels, letter rows) against a fake text field, the gate against the app's phases, the Darwin signals, term packs — and what the UI builds on it (palettes, the voice element, the gallery). The engine's diff --git a/README.md b/README.md index e8612d1c..67162fda 100644 --- a/README.md +++ b/README.md @@ -224,8 +224,7 @@ Sources/BlurtEngine/ Swift 6 package owning the pipeline — no external dep STT/ AssemblyAITranscriber: one POST to dictation.assemblyai.com/v1/transcribe/live (STT + LLM rewrite); STTPrompt (contextual priming: your recent dictations then the text before the cursor, and - nothing else about your screen); KeytermsBoost (key terms as - the keyterms-prompt list) + nothing else about your screen) Pipeline/ DictationSession actor: press/release/cancel commands, phase stream, auto-release before the API's recording cap Hotkey/ DictationKeyGate/Router: pure, unit-tested state machine for the diff --git a/Sources/AssemblyAI/Dictation/DictationWireTypes.swift b/Sources/AssemblyAI/Dictation/DictationWireTypes.swift index d4875504..9f26ec8a 100644 --- a/Sources/AssemblyAI/Dictation/DictationWireTypes.swift +++ b/Sources/AssemblyAI/Dictation/DictationWireTypes.swift @@ -46,7 +46,7 @@ public struct DictationConfig: Encodable, Sendable { /// biasing recognition toward those exact spellings. A sibling of /// `stt_prompt`, not an alternative — the API takes both, for /// different jobs (prior text versus a vocabulary list) — fitted by - /// `KeytermsBoost` to its own 2048-byte cap, which is a different number + /// `KeyTerms` to its own 2048-byte cap, which is a different number /// from the 4096 scalars on the prompt. Empty asks for no boosting, and /// `encode(to:)` then drops the key rather than sending `[]`. /// diff --git a/Sources/BlurtEngine/STT/KeytermsBoost.swift b/Sources/AssemblyAI/Dictation/KeyTerms.swift similarity index 52% rename from Sources/BlurtEngine/STT/KeytermsBoost.swift rename to Sources/AssemblyAI/Dictation/KeyTerms.swift index 1fa49481..9dbbc246 100644 --- a/Sources/BlurtEngine/STT/KeytermsBoost.swift +++ b/Sources/AssemblyAI/Dictation/KeyTerms.swift @@ -1,51 +1,61 @@ -/// The user's key terms as the dictation request's `config.keyterms_prompt` — -/// keyterms prompting, the other half of transcription steering next to -/// `STTPrompt`. +/// Key terms — names, product names, jargon — for the dictation request's +/// `config.keyterms_prompt`: keyterms prompting, the other half of +/// transcription steering next to the `stt_prompt` context. /// /// The two fields do different jobs and ride the same request: -/// `stt_prompt` is the prior text (the user's recent dictations, -/// then the text before the cursor) that tells the model what this utterance -/// continues; `keyterms_prompt` is a flat list of strings biasing recognition -/// toward those exact spellings. Boosting is the right tool for an explicit vocabulary -/// list — names, product names, jargon — which is exactly what the Settings -/// "Key Terms" field collects, and the wrong thing to pack into prose context (a -/// `Keywords: a, b, c.` clause is what this replaces). +/// `stt_prompt` is prior text (recent dictations, then the text before the +/// cursor) that tells the model what this utterance continues; +/// `keyterms_prompt` is a flat list of strings biasing recognition toward those +/// exact spellings. Boosting is the right tool for an explicit vocabulary list, +/// and the wrong thing to pack into prose context (a `Keywords: a, b, c.` +/// clause). /// /// **`keyterms_prompt`, not `word_boost`.** Three names are the same feature — /// `keyterms_prompt` is the one the Streaming and Pre-recorded surfaces use and -/// the one the dictation reference now documents, with `keyterms` and -/// `word_boost` kept as legacy aliases. This sent `word_boost` until 2026-09-10, -/// on the belief that it was the documented name and `keyterms_prompt` merely a -/// pass-through among the unknown fields the endpoint forwards as-is. Both halves -/// of that were wrong, measured against `/v1/transcribe/live`: the route +/// the one the dictation reference documents, with `keyterms` and `word_boost` +/// kept as legacy aliases. Measured against `/v1/transcribe/live`: the route /// validates its config strictly and rejects an unknown key outright -/// (`Extra inputs are not permitted`), so `keyterms_prompt` returning 200 is -/// proof it is a parameter the route knows — and the service's own error names it -/// first: `provide only one of keyterms_prompt, keyterms, or word_boost`. +/// (`Extra inputs are not permitted`), and the service's own error names it +/// first: `provide only one of keyterms_prompt, keyterms, or word_boost`. The +/// aliases are mutually exclusive on this route, so a request carrying two names +/// 400s before the audio is read. Send exactly one. /// -/// Send exactly one. The aliases are mutually exclusive on this route, not just -/// on the sibling one, so a belt-and-braces request carrying two names 400s -/// before the audio is read — which makes the migration a swap and never an -/// addition. -/// -/// The terms arrive already normalized — `KeyTermsStore.parse` splits on commas, -/// trims, drops blanks, and dedupes case-insensitively — so the only thing left -/// to enforce here is size: the field accepts at most `characterCap` across -/// all terms and at most `termCap` terms, and the user's list is the one -/// unbounded input in the request. -/// Exercised by `Tests/BlurtEngineTests/KeytermsBoostTests.swift`. -enum KeytermsBoost { +/// Two steps, both here: `parse` turns what a person types (a comma-separated +/// list) into clean terms, and `fitted` keeps a list within the field's two +/// caps, which the user's list is the one unbounded input able to breach. +public enum KeyTerms { + /// A comma-separated list as clean terms: split on commas (only commas, so + /// "San Francisco" stays one term), trimmed, blanks dropped, and duplicates + /// removed case-insensitively, keeping the first spelling and the user's + /// order. `fitted` assumes terms arrive this way. + public static func parse(_ text: String?) -> [String] { + guard let text else { return [] } + var seen = Set() + var result: [String] = [] + for piece in text.split(separator: ",") { + guard let term = String(piece).trimmedNonEmpty() else { continue } + guard seen.insert(term.lowercased()).inserted else { continue } + result.append(term) + } + return result + } + + /// Terms as the comma-separated text `parse` reads back unchanged. + // periphery:ignore - public for the iOS app (App/BlurtiOS), which the Mac scan doesn't index. + public static func join(_ terms: [String]) -> String { + terms.joined(separator: ", ") + } + /// Cap the API places on `config.keyterms_prompt`: 2048 characters summed /// across every term. Measured here in **UTF-8 bytes**, the conservative reading — the /// documented unit is "characters", which is unmeasured against the endpoint, - /// and bytes can only overestimate a multi-byte term's cost. Same conservative - /// choice, and the same reasoning, as `CleanupInstruction.characterCap`. + /// and bytes can only overestimate a multi-byte term's cost. /// /// Note this is the *boost* cap and a different number from the 4096 on - /// `config.stt_prompt` (`STTPrompt.characterCap`). Reusing + /// `config.stt_prompt`. Reusing /// one cap's figure for the other field is how a whole-request 400 shipped once /// before. - static let characterCap = 2048 + public static let characterCap = 2048 /// The *other* cap on the same field, and the one the byte budget above does /// not imply: at most **100 terms**, however short they are @@ -53,12 +63,12 @@ enum KeytermsBoost { /// 100 terms and 8000 characters in total"). Exceeding either is a 400 on the /// whole request, before the audio is read. /// - /// Reachable, which is why it is here: the terms are comma-split from one - /// free-text field, so 150 short ones cost ~1500 bytes and sail under + /// Reachable, which is why it is here: terms comma-split from one free-text + /// field (`parse`) are uncounted, so 150 short ones cost ~1500 bytes and sail under /// `characterCap` while being half again over this. That shape failed *every* /// dictation until the user shortened the list — the same outage a 3057-character /// cleanup instruction caused once, by the same mechanism. - static let termCap = 100 + public static let termCap = 100 /// The terms to send for `terms` — empty when there are none to send, which /// the encoders read as "omit the field", so the request asks for no boosting @@ -75,7 +85,7 @@ enum KeytermsBoost { /// nothing — and order is the user's, so the terms they typed first are the /// ones that survive a list too long to send. A term is never split and a /// blank one is never sent, so a stray entry can't cost the request. - static func fitted(_ terms: [String]) -> [String] { + public static func fitted(_ terms: [String]) -> [String] { var included: [String] = [] var remaining = characterCap for term in terms { diff --git a/Sources/BlurtEngine/Pipeline/TextShortcutExpander.swift b/Sources/AssemblyAI/Dictation/TextShortcuts.swift similarity index 75% rename from Sources/BlurtEngine/Pipeline/TextShortcutExpander.swift rename to Sources/AssemblyAI/Dictation/TextShortcuts.swift index 37ecf14f..4f2202fe 100644 --- a/Sources/BlurtEngine/Pipeline/TextShortcutExpander.swift +++ b/Sources/AssemblyAI/Dictation/TextShortcuts.swift @@ -1,9 +1,43 @@ import Foundation +/// One spoken phrase and the text it expands to — "personal email" → +/// `me@example.com`. Applied to the finished transcript on the device, after +/// the dictation response and before the text is inserted +/// (`TextShortcutExpander`); nothing about a shortcut goes on the wire. +/// +/// The `id` is minted once so a list and its editor can track a row across a +/// rename of its trigger. +public struct TextShortcut: Codable, Identifiable, Hashable, Sendable { + public let id: UUID + public var trigger: String + public var expansion: String + + public init(trigger: String, expansion: String) { + self.init(id: UUID(), trigger: trigger, expansion: expansion) + } + + public init(id: UUID, trigger: String, expansion: String) { + self.id = id + self.trigger = trigger + self.expansion = expansion + } + + /// The identity two triggers share when the expander can't tell them apart: + /// letters and digits only, lowercased, with the separators dropped — it + /// treats any separator run (or none) between words as equal, so "personal + /// email", "Personal-Email" and "personalemail" are one phrase. Empty for a + /// trigger with no letters or digits, which can never match anything. A list + /// should hold one shortcut per key: two expansions for one phrase could + /// only ever apply one of them. + public static func matchKey(for trigger: String) -> String { + TextShortcutExpander.words(in: trigger).joined().lowercased() + } +} + /// Replaces each spoken shortcut trigger in a finished transcript with its /// expansion — "my handle is personal GitHub" → "my handle is /// https://github.com/…". Pure and local: it runs on the text the dictation API -/// returned, between the response and the paste. +/// returned, between the response and the insertion. /// /// Matching is forgiving about what the transcription and the cleanup rewrite /// do to a phrase, and strict about where it starts and ends: @@ -14,7 +48,7 @@ import Foundation /// aren't letters or digits, or none at all — so "link tree" also matches /// "Linktree", "cal.com" matches "Cal com", and "mom's address" matches /// "Mom’s address". Only letters and digits in the trigger are significant, -/// which is exactly what `TextShortcutStore.matchKey` dedupes on. +/// which is exactly what `TextShortcut.matchKey` identifies a trigger by. /// - **Never across a sentence or clause break**: sentence punctuation /// (`.,!?;:`) followed by whitespace ends the run, so "work email" leaves "my /// work. Email me" alone while "cal.com" still matches. @@ -23,7 +57,7 @@ import Foundation /// /// One pass over the text, longest trigger first, so an expansion is never /// itself re-expanded and "personal email work" wins over "personal email". -enum TextShortcutExpander { +public enum TextShortcutExpander { /// What counts as part of a word — the regex spelling of /// `CharacterSet.alphanumerics` (letters, marks, digits), which `words(in:)` /// splits on. @@ -39,7 +73,7 @@ enum TextShortcutExpander { private static let separatorRun = "(?:[^\(wordClass)\(sentencePunctuation)]|[\(sentencePunctuation)](?!\\s))*" - static func expand(_ text: String, using shortcuts: [TextShortcut]) -> String { + public static func expand(_ text: String, using shortcuts: [TextShortcut]) -> String { // Longest first, so a trigger that extends another claims the text before // its prefix can. Group i+1 of `alternation` is `ordered[i]`. let ordered = shortcuts.compactMap { shortcut in pattern(for: shortcut.trigger).map { (shortcut, $0) } } @@ -50,7 +84,7 @@ enum TextShortcutExpander { // A trigger standing alone — surrounding whitespace and closing punctuation // aside — becomes exactly its expansion: a trailing period glued to an // address or URL is never what someone dictating a snippet on its own - // wanted. The whitespace is kept so the paste separator logic sees the same + // wanted. The whitespace is kept so an insertion's separator logic sees the same // shape. let source = text as NSString let whole = NSRange(location: 0, length: source.length) @@ -85,18 +119,18 @@ enum TextShortcutExpander { } /// `text` with every expansion in it put back to its trigger — the inverse of - /// `expand`, for the text before the caret that `STTPrompt` sends. After a - /// shortcut is pasted the field *holds* the saved replacement, and the next + /// `expand`, for the text before the caret sent as `stt_prompt`. After a + /// shortcut is inserted the field *holds* the saved replacement, and the next /// press reads it back; this keeps it local. /// - /// Literal and case-sensitive, since the field holds exactly what was pasted. + /// Literal and case-sensitive, since the field holds exactly what was inserted. /// Longest expansion first, so one that contains another is caught whole. The /// capture reads only the last few hundred characters, so a long expansion can /// arrive with its head cut off: a tail of one (at least /// `minimumClippedOverlap` long) at the very start of `text` counts too. /// Replaced with the trigger rather than nothing, so the prompt still reads as /// the continuous passage it is. - static func redactingExpansions(in text: String, using shortcuts: [TextShortcut]) -> String { + public static func redactingExpansions(in text: String, using shortcuts: [TextShortcut]) -> String { var result = text for shortcut in shortcuts.sorted(by: { $0.expansion.count > $1.expansion.count }) { let expansion = shortcut.expansion diff --git a/Sources/AssemblyAI/README.md b/Sources/AssemblyAI/README.md index 94d39105..881be173 100644 --- a/Sources/AssemblyAI/README.md +++ b/Sources/AssemblyAI/README.md @@ -10,9 +10,11 @@ a standalone Swift SDK. Foundation only; macOS 15 and iOS 18. | `ErrorResponse` | The dictation API's two documented error shapes. | | `DictationMultipart` | The upload's multipart framing: `config` first, then the PCM audio. | | `APIKeyValidator` | A cheap authenticated check that tells a rejected key from one it couldn't verify. | +| `KeyTerms` | Key terms for `keyterms_prompt`: parse a comma-separated list, fit it to the caps. | +| `TextShortcut`, `TextShortcutExpander` | Say a phrase, get saved text: expanded on the device, never sent. | What it deliberately doesn't hold: which prompt, cleanup instruction or key terms to send, where the -key is stored, or the `User-Agent`. Those are the app's choices and live in `BlurtEngine` -(`AssemblyAITranscriber`, `APIKeyValidator.blurt()`). +key, the key terms or the shortcuts are stored, or the `User-Agent`. Those are the app's choices and live in `BlurtEngine` +(`AssemblyAITranscriber`, `KeyTermsStore`, `TextShortcutStore`, `APIKeyValidator.blurt()`). Tests run with the engine's: `swift test` from the repo root. diff --git a/Sources/BlurtEngine/Audio/SoundPackCatalog.swift b/Sources/BlurtEngine/Audio/SoundPackCatalog.swift index 2112bd2c..ddeb589a 100644 --- a/Sources/BlurtEngine/Audio/SoundPackCatalog.swift +++ b/Sources/BlurtEngine/Audio/SoundPackCatalog.swift @@ -16,7 +16,7 @@ public struct SoundPackCatalog: Sendable { /// Distinct group names, in catalog order — the picker's sections. Deduped /// through a `Set` (`insert(_:).inserted` as the filter, matching - /// `KeyTermsStore.parse`) rather than an array `contains` scan per element, and + /// `KeyTerms.parse`) rather than an array `contains` scan per element, and /// resolved once at construction: the picker reads it, then `voices(in:)` per /// group, on every settings render. public let groups: [String] diff --git a/Sources/BlurtEngine/Config/APIKeySubmission.swift b/Sources/BlurtEngine/Config/APIKeySubmission.swift index c7dbb439..f7c12fac 100644 --- a/Sources/BlurtEngine/Config/APIKeySubmission.swift +++ b/Sources/BlurtEngine/Config/APIKeySubmission.swift @@ -1,4 +1,5 @@ import AssemblyAI + /// The validate-then-save flow behind the setup/settings screen's Save/Update /// button. Owned in the engine — rather than in the app coordinator — so its /// central invariant is unit-tested: **an unverified key never persists.** A key diff --git a/Sources/BlurtEngine/Config/KeyTermsStore.swift b/Sources/BlurtEngine/Config/KeyTermsStore.swift index 7c956f78..dc8ff22e 100644 --- a/Sources/BlurtEngine/Config/KeyTermsStore.swift +++ b/Sources/BlurtEngine/Config/KeyTermsStore.swift @@ -1,8 +1,9 @@ +import AssemblyAI import Foundation /// Storage for the user's dictation "key terms" — a comma-separated list of /// domain words (names, jargon, product names) sent as the dictation request's -/// keyterms list (`config.keyterms_prompt`, see `KeytermsBoost`), so the model +/// keyterms list (`config.keyterms_prompt`, see the SDK's `KeyTerms`), so the model /// favors those exact spellings. They used to ride the transcription `prompt` as /// a `Keywords: a, b, c.` clause instead; a flat list is the field the API /// provides for exactly this, so that is what they are now. @@ -21,7 +22,7 @@ import Foundation /// normalizing on write fought the text field, because the trimmed value was /// pushed back into the binding as an external change and a trailing space was /// deleted as the user typed it. Normalization lives on the read side instead -/// (`raw` trims, `parse` trims and dedupes), so a blank field still reads back +/// (`raw` trims, `KeyTerms.parse` trims and dedupes), so a blank field still reads back /// as "no terms". See the note on `KeyTermsStepView.text`. public struct KeyTermsStore { /// `UserDefaults` key for the raw, comma-separated string the user typed. @@ -42,27 +43,10 @@ public struct KeyTermsStore { defaults.string(forKey: Self.defaultsKey).trimmedNonEmpty() } - /// The stored terms parsed into a clean list: split on commas, trimmed, with - /// blanks and duplicates removed (case-insensitively, keeping first spelling). + /// The stored terms parsed into a clean list (`KeyTerms.parse`): split on + /// commas, trimmed, with blanks and duplicates removed (case-insensitively, + /// keeping first spelling). var terms: [String] { - Self.parse(raw) - } - - /// Pure parse of a comma-separated string into a clean term list. Static so - /// callers and tests can reuse the exact same rules without a `UserDefaults` - /// in hand. This is the whole normalization the request gets: `KeytermsBoost` - /// assumes terms arrive trimmed, blank-free and deduped, and only enforces the - /// field's length cap on top. - static func parse(_ text: String?) -> [String] { - guard let text else { return [] } - var seen = Set() - var result: [String] = [] - for piece in text.split(separator: ",") { - guard let term = String(piece).trimmedNonEmpty() else { continue } - let key = term.lowercased() - guard seen.insert(key).inserted else { continue } - result.append(term) - } - return result + KeyTerms.parse(raw) } } diff --git a/Sources/BlurtEngine/Config/TextShortcutStore.swift b/Sources/BlurtEngine/Config/TextShortcutStore.swift index e9f69933..29141389 100644 --- a/Sources/BlurtEngine/Config/TextShortcutStore.swift +++ b/Sources/BlurtEngine/Config/TextShortcutStore.swift @@ -1,29 +1,8 @@ +import AssemblyAI import Foundation -/// One spoken phrase and the text it expands to — "personal email" → -/// `me@example.com`. Applied to the finished transcript on the machine, after -/// the dictation response and before the paste (see `TextShortcutExpander`); -/// nothing about a shortcut goes on the wire. -/// -/// The `id` is minted once so the Settings list and its editor sheet can track -/// a row across a rename of its trigger. -public struct TextShortcut: Codable, Identifiable, Hashable, Sendable { - public let id: UUID - public var trigger: String - public var expansion: String - - public init(trigger: String, expansion: String) { - self.init(id: UUID(), trigger: trigger, expansion: expansion) - } - - init(id: UUID, trigger: String, expansion: String) { - self.id = id - self.trigger = trigger - self.expansion = expansion - } -} - -/// Storage for the user's text shortcuts, as one JSON list in `UserDefaults`. +/// Storage for the user's text shortcuts (the AssemblyAI SDK's `TextShortcut`), +/// as one JSON list in `UserDefaults` — Blurt's key and Blurt's limits. /// /// Has a setter for the same encoded-on-write reason as `StyleProfileStore`: /// the JSON shape is the store's business, so the Settings sheet writes through @@ -76,18 +55,9 @@ public struct TextShortcutStore { return Self.normalized(decoded) } - /// The identity two triggers share when the expander can't tell them apart: - /// letters and digits only, lowercased, with the separators dropped — it - /// treats any separator run (or none) between words as equal, so "personal - /// email", "Personal-Email" and "personalemail" are one phrase. Empty for a - /// trigger with no letters or digits, which can never match anything. - public static func matchKey(for trigger: String) -> String { - TextShortcutExpander.words(in: trigger).joined().lowercased() - } - /// The one funnel every list passes through, on read and on write: trimmed /// fields within their caps, entries with a blank or unmatchable trigger or a - /// blank expansion dropped, and triggers deduplicated by `matchKey` (first + /// blank expansion dropped, and triggers deduplicated by `TextShortcut.matchKey` (first /// wins — two expansions for one phrase could only ever apply one of them). /// Idempotent. static func normalized(_ shortcuts: [TextShortcut]) -> [TextShortcut] { @@ -99,7 +69,7 @@ public struct TextShortcutStore { let expansion = shortcut.expansion.trimmedNonEmpty().map({ String($0.prefix(expansionLimit)) }), - case let key = matchKey(for: trigger), !key.isEmpty, + case let key = TextShortcut.matchKey(for: trigger), !key.isEmpty, seen.insert(key).inserted else { continue } result.append(TextShortcut(id: shortcut.id, trigger: trigger, expansion: expansion)) diff --git a/Sources/BlurtEngine/Pipeline/DictationLog.swift b/Sources/BlurtEngine/Pipeline/DictationLog.swift index 4b4a51d9..0f66ad6d 100644 --- a/Sources/BlurtEngine/Pipeline/DictationLog.swift +++ b/Sources/BlurtEngine/Pipeline/DictationLog.swift @@ -1,3 +1,4 @@ +import AssemblyAI import Foundation import os @@ -51,7 +52,7 @@ public enum DictationLog { let sttPrompt: String /// The `config.keyterms_prompt` list sent for this utterance — /// the request's other steering field, so the log accounts for both. Built - /// through the same `KeytermsBoost.fitted` the request uses, so an + /// through the same `KeyTerms.fitted` the request uses, so an /// over-long list is recorded as the terms that actually went out. Empty /// when none were sent, and `encode(to:)` then omits the key, matching how /// every absent field above is left out rather than written as `null`. @@ -230,7 +231,7 @@ public enum DictationLog { app: context?.appName, window: context?.windowTitle, field: context?.fieldLabel, prior: context?.priorText, selected: context?.selectedText, sttPrompt: STTPrompt.text(context: context), - keyterms: KeytermsBoost.fitted(context?.keyTerms ?? []) + keyterms: KeyTerms.fitted(context?.keyTerms ?? []) ) } diff --git a/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift b/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift index ab5af52d..ad185bbe 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift @@ -1,3 +1,4 @@ +import AssemblyAI import Foundation import os diff --git a/Sources/BlurtEngine/Pipeline/DictationSession.swift b/Sources/BlurtEngine/Pipeline/DictationSession.swift index 11cadcb2..cac9e9c5 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession.swift @@ -1,3 +1,4 @@ +import AssemblyAI // `Dispatch`, not `Foundation`: the only thing here from outside the module is // `contextQueue`'s and `commandQueue`'s `DispatchQueue`, the same choice // `+Press.swift` documents. Foundation's last use here went with `mic.stop()` @@ -47,9 +48,8 @@ public actor DictationSession { let injector: InjectorProtocol /// Supplies the user's key terms (domain vocabulary) at press time, so each /// utterance's request boosts those spellings — as its own `keyterms_prompt` field - /// (`KeytermsBoost`), not as part of the conversation context. A closure, rather - /// than a stored list, so edits in Settings take effect on the next dictation - /// without rebuilding the session. Defaults to reading `KeyTermsStore`. + /// (`KeyTerms`), not as part of the conversation context. A closure, so edits in + /// Settings apply to the next dictation without rebuilding the session. Defaults to `KeyTermsStore`. let keyTermsProvider: @Sendable () -> [String] /// Names the style a completed dictation was made with, recorded onto its /// `RecentDictations.Entry` (the row's trailing style chip). `nil` means no diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index e4112246..cb46dd30 100644 --- a/Sources/BlurtEngine/README.md +++ b/Sources/BlurtEngine/README.md @@ -77,7 +77,7 @@ press() ──▶ MicCapture.start() → frames release() ──▶ MicCapture Key properties of the design, which your integration can rely on: - **One request per utterance, chunked upload, whole response.** The dictation API returns the complete transcript — and its LLM-rewritten form — in the response body: no job polling, no incremental deltas, no second request for the cleanup. `TranscriberProtocol.transcribe` is a single `async throws -> String`, so UIs should still show a "transcribing…" state and then the whole result; there is nothing to stream _back_. What is streamed is the request: `transcribe(frames:sampleRate:context:)` is called at press and uploads the recording in chunks as it is captured, so only inference on the last frames lands on the wait the user sits through. There is deliberately no buffered fallback — a body that has already been streamed cannot be replayed, and the transcriber refuses `URLSession`'s replay request rather than silently re-sending an empty one. -- **Cleanup happens server-side, and the opt-out is on the response.** The request's `config.llm_instruction` asks the service to apply our own cleanup instruction (`CleanupInstruction.text` — delete disfluencies, change nothing else) to the verbatim transcript inside the same call. It is one of three steering fields on the request: `config.stt_prompt` primes the _transcription_ with the text that came before — the user's recent dictations, then the text before the cursor (`STTPrompt`, omitted when there is neither) — and `config.keyterms_prompt` boosts the user's key terms (`KeytermsBoost`, omitted when there are none). `prompt` is `stt_prompt`'s alias and `conversation_context` the array-of-turns field it replaced; a request carries exactly one of the three. The instruction rides **every** request; the **enhanced transcripts** setting (`EnhancedTranscriptsStore`, on by default) then decides which of the two transcripts in the response gets pasted (`AssemblyAITranscriber.transcript(from:)`). That ordering is the route's own advice: it rewrites by default, omitting `llm_instruction` only selects the service's wording, and the reference documents no way to decline — the escape it names is to ignore `llm_response` and use `text`. The undocumented `"llm": null` does suppress it (measured 2026-09-10) and Blurt sent it until 2026-09-11; the trade for dropping it is that "off" still waits out and still pays for a rewrite nobody reads. The **active style profile**'s instructions (`StyleProfileStore`, none by default) are appended to that instruction via `CleanupInstruction.sendable(appending:)`, trimmed to the headroom the API's 2048 instruction cap leaves (measured in UTF-8 bytes, the conservative bound — the cap's own unit is unmeasured); no profile, or blank instructions, means the base instruction goes out unchanged. Only the **active** profile's text is ever sent — never a join of the user's profiles, which would blow that cap and 400 the whole request rather than degrading. With the setting on the engine pastes `llm_response`, falling back to the verbatim `text` when the best-effort rewrite failed (`llm_error`) — a degradation, never a user-facing error; with it off it pastes `text` and discards the rewrite unread. There is no client-side LLM pass, no styling stage, and deliberately no hook for one. +- **Cleanup happens server-side, and the opt-out is on the response.** The request's `config.llm_instruction` asks the service to apply our own cleanup instruction (`CleanupInstruction.text` — delete disfluencies, change nothing else) to the verbatim transcript inside the same call. It is one of three steering fields on the request: `config.stt_prompt` primes the _transcription_ with the text that came before — the user's recent dictations, then the text before the cursor (`STTPrompt`, omitted when there is neither) — and `config.keyterms_prompt` boosts the user's key terms (`KeyTerms`, omitted when there are none). `prompt` is `stt_prompt`'s alias and `conversation_context` the array-of-turns field it replaced; a request carries exactly one of the three. The instruction rides **every** request; the **enhanced transcripts** setting (`EnhancedTranscriptsStore`, on by default) then decides which of the two transcripts in the response gets pasted (`AssemblyAITranscriber.transcript(from:)`). That ordering is the route's own advice: it rewrites by default, omitting `llm_instruction` only selects the service's wording, and the reference documents no way to decline — the escape it names is to ignore `llm_response` and use `text`. The undocumented `"llm": null` does suppress it (measured 2026-09-10) and Blurt sent it until 2026-09-11; the trade for dropping it is that "off" still waits out and still pays for a rewrite nobody reads. The **active style profile**'s instructions (`StyleProfileStore`, none by default) are appended to that instruction via `CleanupInstruction.sendable(appending:)`, trimmed to the headroom the API's 2048 instruction cap leaves (measured in UTF-8 bytes, the conservative bound — the cap's own unit is unmeasured); no profile, or blank instructions, means the base instruction goes out unchanged. Only the **active** profile's text is ever sent — never a join of the user's profiles, which would blow that cap and 400 the whole request rather than degrading. With the setting on the engine pastes `llm_response`, falling back to the verbatim `text` when the best-effort rewrite failed (`llm_error`) — a degradation, never a user-facing error; with it off it pastes `text` and discards the rewrite unread. There is no client-side LLM pass, no styling stage, and deliberately no hook for one. - **Latency is pre-paid where possible, and never faked.** `press()` claims `.connecting` before it touches the mic, so a host's pill answers the keypress — but `MicCapture.start()` deliberately holds until the input device is actually delivering frames, and the _start cue_ waits for `.recording`. On a Bluetooth route those are ~1–2 s apart and the OS captures nothing in between, so cueing at the press loses the first words. Route activation itself cannot be pre-paid — measured, not assumed: nothing opens the device before `record()`, so the connecting window is where that cost lives and the gate is what makes it honest. `press()` fires a detached `warmUp()` at the transcriber (pre-opening the HTTPS connection so the request it opens moments later streams from its first frame, ~170 ms cold — measured to coalesce with that request rather than race it) and kicks off the cross-process accessibility read of the focused field without awaiting it — the read is then consumed when the _upload_ opens, with a bounded wait (`DictationSession.contextWaitBudget`, 500 ms), because the streaming route needs the `config` part before any audio — so an unresponsive frontmost app costs the transcript its priming, never a multi-second stall, and that wait is spent while the user is still speaking rather than while they wait for a transcript. On the way out, `release()` flips the phase to `.transcribing` _before_ stopping the mic, so a host's stop cue fires at key-up rather than after the Bluetooth tail linger. - **A held trigger auto-releases.** `DictationSession` stops recording after `maxRecordingSeconds` (default `SyncSTTLimits.autoReleaseSeconds`, 115 s) so audio never exceeds what the endpoint accepts, and transcribes what it has. Clips shorter than `SyncSTTLimits.minPCMBytes` (~100 ms of audio — an accidental tap) are dropped as a silent no-op rather than paying for a request that transcribes nothing (measured: the route answers a sub-floor clip with 200 and an empty transcript, not a 400). @@ -215,8 +215,8 @@ The session calls `setTarget` at press time with the app that was frontmost when - **`TranscriptionContext`** carries the frontmost app name, window title, focused-field label, the text before the caret, the selected text (which a paste will replace), and the user's key terms — of which two are sent: `priorText` (as the prompt) and `keyTerms` (as the `keyterms_prompt` list). It's captured via Accessibility at press time (skipped in secure fields), off the hot path — and consumed when the upload opens, with a bounded wait (`DictationSession.contextWaitBudget`), so a hung read costs the request its priming rather than delaying the audio. That capture is macOS-only; a host on another platform passes a `DictationSession.HostFocusCapture` to the session's initializer — two closures supplying the frontmost app (or `nil`) and a `FocusedFieldContext` built from whatever its own text surface exposes, such as an iPhone keyboard's `documentContextBeforeInput` — and the session consumes it on the same schedule and with the same bounded wait. Of the focus signals only `priorText` is sent; the app name, window title, field label and selected text stay local — the injector reads `priorText`/`windowTitle` to decide the paste's leading separator and to recognize the captured window, and the developer-mode log records the rest. - **`STTPrompt.text(context:)`** renders the prior text into the dictation request's `config.stt_prompt` (transcription steering only — the cleanup rewrite is the separate `llm_instruction`): one newline-joined string, the user's recent dictations oldest-first followed by the text before the caret last, fitted to a **4096-scalar** budget by dropping the oldest entries first. That cap is the API's and it **rejects** rather than trims (`400 stt_prompt: String should have at most 4096 characters`), so `fitted` is what stands between a long history and every dictation failing; its unit was measured — 4096 `é` (8192 bytes) passes, 820 family emoji (4100 scalars, 820 graphemes) does not — which is why it counts `unicodeScalars` and not Swift's grapheme `count`. It reads no other field of the context. A context with neither history nor prior text yields `""`, which omits the field so the model works from the audio alone. History the prior chunk already ends with is dropped rather than sent twice — after a paste the field contains what was just dictated. **`stt_prompt` is `config.prompt` under its other name** (`provide only one of stt_prompt or prompt; they are the same field`), and it replaced `config.conversation_context`, the array-of-turns field that carried the same two signals; both still work, so re-adding the turn list would send the prior text twice rather than fail. The trade the swap accepted is that a custom prompt displaces the service's managed default; re-measured 2026-09-10, language detection survives it (a Spanish clip came back in Spanish with an English prompt, a Spanish one, and none at all). Two deliberate omissions survive from the older prompt, both regression-tested: no language steering (pinning to English hurt non-English speech) and no "remove filler words" clause (not in the model's trained instruction set — a no-op). Don't reintroduce either, don't put a `Previous transcript:` heading or any instruction in the string, and don't widen the context back out to the app/window/field hints the old prompt used to carry. - **`CleanupInstruction.text`** is the other half: the string sent as `config.llm_instruction`, which the service applies to the verbatim transcript with its own rewrite model. It is the winner of a GEPA run of `evals/dictation-prompt/`, compressed to fit the API's **2048-character** cap on that field — a different, smaller limit than the 4096 on `config.stt_prompt` — and counted in a different unit (UTF-8 bytes here, Unicode scalars there), so `CleanupInstruction.characterCap` is asserted in tests rather than borrowed. Treat it as a measured artifact: a new hypothesis belongs in that harness plus a fresh run, not a hand-edit. Two caveats ship with it — it has only ever been scored against other text candidates on a stand-in model, never against the service's own default instruction on its own rewrite model, and every corpus behind it is English. Dropping the field restores the service default in one line — which is _not_ the same as turning the rewrite off; nothing on the request does that, and the **enhanced transcripts** switch works on the response instead. -- **`KeytermsBoost.fitted(_:)`** turns those terms into the request's `config.keyterms_prompt` — keyterms prompting, the other steering field next to `stt_prompt`, and a flat array of strings rather than prose. It takes whole terms in the user's order while they fit the field's own **2048-character** cap (`KeytermsBoost.characterCap`, in UTF-8 bytes — a third limit, distinct from the 4096 scalars on `config.stt_prompt` and the 2048 bytes on `config.llm_instruction`) and while the list stays within **100 terms** (`KeytermsBoost.termCap`, the reference's `maxItems` on the same field — a count, not a size, and the one a byte budget doesn't imply: 150 short terms clear 2048 bytes and 400 on the count), and returns a plain `[String]`; an empty result omits the field on the wire rather than sending `[]`. `keyterms_prompt` is the canonical name — `keyterms` and `word_boost` are legacy aliases for the same feature, mutually exclusive with it, so send exactly one (this sent `word_boost` until 2026-09-10). Don't fold key terms back into the context as a `Keywords:` clause; that was the older, worse shape for the same intent. -- **`KeyTermsStore`** persists the user's domain vocabulary (names, jargon) in `UserDefaults`; `DictationSession` re-reads it at every press via its `keyTermsProvider` closure, so Settings edits apply to the next utterance without rebuilding the session. Pass your own provider to source terms from elsewhere. Its `parse` is the whole normalization (trim, drop blanks, dedupe case-insensitively) — `KeytermsBoost` assumes that and only enforces length. +- **`KeyTerms.fitted(_:)`** turns those terms into the request's `config.keyterms_prompt` — keyterms prompting, the other steering field next to `stt_prompt`, and a flat array of strings rather than prose. It takes whole terms in the user's order while they fit the field's own **2048-character** cap (`KeyTerms.characterCap`, in UTF-8 bytes — a third limit, distinct from the 4096 scalars on `config.stt_prompt` and the 2048 bytes on `config.llm_instruction`) and while the list stays within **100 terms** (`KeyTerms.termCap`, the reference's `maxItems` on the same field — a count, not a size, and the one a byte budget doesn't imply: 150 short terms clear 2048 bytes and 400 on the count), and returns a plain `[String]`; an empty result omits the field on the wire rather than sending `[]`. `keyterms_prompt` is the canonical name — `keyterms` and `word_boost` are legacy aliases for the same feature, mutually exclusive with it, so send exactly one (this sent `word_boost` until 2026-09-10). Don't fold key terms back into the context as a `Keywords:` clause; that was the older, worse shape for the same intent. +- **`KeyTermsStore`** persists the user's domain vocabulary (names, jargon) in `UserDefaults`; `DictationSession` re-reads it at every press via its `keyTermsProvider` closure, so Settings edits apply to the next utterance without rebuilding the session. Pass your own provider to source terms from elsewhere. Its `parse` is the whole normalization (trim, drop blanks, dedupe case-insensitively) — `KeyTerms` assumes that and only enforces length. For key storage, compose against **`APIKeyGateway`** — the injectable `current` / `save(_:)` / `hasKey` seam over the key store. `ProductionAPIKeyStore` forwards to the Keychain-backed `APIKeyStore`; `InMemoryAPIKeyStore` is a ready-made in-memory conformance for tests and harnesses (Blurt's XCUITest runs use it so the real Keychain item is never touched, and its `hasKey` backs the session's `readinessCheck`). For a settings UI, **`APIKeySubmission`** wraps the gateway with the validate-then-save flow (`submit(_:)` → valid / invalid / unreachable / saveFailed, via `APIKeyValidator`): it saves only a key AssemblyAI actively accepts, so an unverified key never persists. Two projections keep the surrounding UI out of your views: `Outcome.failureReport` classifies a failure as `.inline(message:)` (recoverable — show it beside the field) or `.alert(title:message:)` (a Keychain fault retyping can't fix), and **`APIKeyDisplay.resolve(key:)`** renders the stored key for an account row — masked tail, status and VoiceOver wording, and the connect-vs-rotate control titles. The mask reveals only the last `revealedTailLength` characters and, below `minimumLengthToMask`, none at all, so a short key can't be shown whole. diff --git a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift index ebddcf0a..873e9374 100644 --- a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift +++ b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift @@ -115,7 +115,7 @@ public struct AssemblyAITranscriber: TranscriberProtocol { let config = try makeConfigData( sampleRate: sampleRate, sttPrompt: STTPrompt.text(context: context), - keytermsPrompt: KeytermsBoost.fitted(context?.keyTerms ?? [])) + keytermsPrompt: KeyTerms.fitted(context?.keyTerms ?? [])) var request = URLRequest(url: baseURL.appending(path: Self.transcribePath)) request.httpMethod = "POST" diff --git a/Sources/BlurtEngine/STT/STTPrompt.swift b/Sources/BlurtEngine/STT/STTPrompt.swift index 0b0ceb55..e5b3e346 100644 --- a/Sources/BlurtEngine/STT/STTPrompt.swift +++ b/Sources/BlurtEngine/STT/STTPrompt.swift @@ -1,3 +1,5 @@ +import AssemblyAI + /// Builds the dictation request's `config.stt_prompt` — the contextual prompt /// that primes the STT model with what came *before* this utterance. The model /// reads it as continuity: vocabulary, spelling, capitalization, and mid-sentence @@ -24,7 +26,7 @@ /// /// The key terms aren't here either, but for the opposite reason: they *are* /// sent, as their own request field. Keyterms prompting takes a flat list of -/// strings (`config.keyterms_prompt` — see `KeytermsBoost`), which is what the +/// strings (`config.keyterms_prompt` — see `KeyTerms`), which is what the /// Settings field collects. Don't fold them into this string — a /// `Keywords: a, b, c.` clause is the shape that field replaced. /// @@ -91,7 +93,7 @@ enum STTPrompt { /// and 820 family emoji — 820 grapheme clusters but 4100 scalars — is /// rejected with the message above. So Swift's `String.count`, which counts /// graphemes, would *under*-count and let a 400 through, and UTF-8 bytes (the - /// unit `KeytermsBoost` and `CleanupInstruction` use, where the server's own + /// unit `KeyTerms` and `CleanupInstruction` use, where the server's own /// unit is unmeasured) would needlessly halve the budget for any accented /// text. Scalars are exactly what the server counts. static let characterCap = 4096 @@ -117,7 +119,7 @@ enum STTPrompt { /// A plain `String`, not an optional one: "nothing to send" needs exactly one /// spelling, and the omit-vs-`""` distinction that matters to the API is /// stated once on the wire, in `DictationConfig.encode(to:)`. Same shape, and - /// the same reasoning, as `KeytermsBoost.fitted`. + /// the same reasoning, as `KeyTerms.fitted`. /// /// Every caller that reports or transmits the context goes through here — the /// transcriber and the developer-mode log both — so the log records exactly diff --git a/Sources/BlurtEngine/STT/TranscriptionContext.swift b/Sources/BlurtEngine/STT/TranscriptionContext.swift index f2fee713..d7e600b3 100644 --- a/Sources/BlurtEngine/STT/TranscriptionContext.swift +++ b/Sources/BlurtEngine/STT/TranscriptionContext.swift @@ -1,3 +1,5 @@ +import AssemblyAI + /// Everything captured at dictation start about where the user is typing: /// the frontmost app, the focused window and field, the text around the cursor, /// the user's recent dictations, and the user's key terms. @@ -66,7 +68,7 @@ public struct TranscriptionContext: Sendable, Equatable { /// from `KeyTermsStore`. Like `recentTranscripts` this isn't per-utterance /// focus state — it's the same list every time. **Sent**, but not as context: /// it goes as the request's own keyterms field, `keyterms_prompt` (see - /// `KeytermsBoost`). + /// `KeyTerms`). public let keyTerms: [String] /// The user's text shortcuts, from `TextShortcutStore`. **Not sent**: `STTPrompt` diff --git a/Tests/BlurtEngineTests/DictationLogTests.swift b/Tests/BlurtEngineTests/DictationLogTests.swift index bc2a769b..4b03d095 100644 --- a/Tests/BlurtEngineTests/DictationLogTests.swift +++ b/Tests/BlurtEngineTests/DictationLogTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +@testable import AssemblyAI @testable import BlurtEngine private struct DecodedEntry: Decodable { @@ -141,7 +142,7 @@ struct DictationLogEntryTests { @Test("records the key terms the request boosts, fitted the same way") func logsTheKeytermsTheRequestCarries() { - // Through `KeytermsBoost.fitted`, not the raw list: the log has to show the + // Through `KeyTerms.fitted`, not the raw list: the log has to show the // steering the API actually saw, so a blank entry is dropped here too. let withTerms = TranscriptionContext( appName: "Mail", priorText: "Hi Sam,", keyTerms: ["AssemblyAI", " ", "LeMUR"]) diff --git a/Tests/BlurtEngineTests/KeyTermsStoreTests.swift b/Tests/BlurtEngineTests/KeyTermsStoreTests.swift index 01456581..f8912120 100644 --- a/Tests/BlurtEngineTests/KeyTermsStoreTests.swift +++ b/Tests/BlurtEngineTests/KeyTermsStoreTests.swift @@ -3,36 +3,6 @@ import Testing @testable import BlurtEngine -@Suite("KeyTermsStore.parse") -struct KeyTermsStoreTests { - @Test("nil and blank input yield no terms") - func emptyInputs() { - #expect(KeyTermsStore.parse(nil).isEmpty) - #expect(KeyTermsStore.parse("").isEmpty) - #expect(KeyTermsStore.parse(" , , \n").isEmpty) - } - - @Test("comma-separated input splits and trims each term") - func splitsAndTrims() { - #expect(KeyTermsStore.parse("AssemblyAI, Kubernetes , Anthropic") == ["AssemblyAI", "Kubernetes", "Anthropic"]) - } - - @Test("blank entries between commas are dropped") - func dropsBlanks() { - #expect(KeyTermsStore.parse("foo,,bar, ,baz") == ["foo", "bar", "baz"]) - } - - @Test("duplicates are removed case-insensitively, keeping the first spelling") - func dedupesCaseInsensitively() { - #expect(KeyTermsStore.parse("Blurt, blurt, BLURT, Slack") == ["Blurt", "Slack"]) - } - - @Test("multi-word terms survive (only commas split)") - func multiWordTerms() { - #expect(KeyTermsStore.parse("San Francisco, New York") == ["San Francisco", "New York"]) - } -} - /// The read side, against an isolated defaults suite like every other store's /// suite (`freshDefaults()`). Each case writes the defaults slot directly, which /// is exactly how production writes it: the store has no setter, and the Settings diff --git a/Tests/BlurtEngineTests/KeytermsBoostTests.swift b/Tests/BlurtEngineTests/KeyTermsTests.swift similarity index 54% rename from Tests/BlurtEngineTests/KeytermsBoostTests.swift rename to Tests/BlurtEngineTests/KeyTermsTests.swift index c94d3225..96cd83bc 100644 --- a/Tests/BlurtEngineTests/KeytermsBoostTests.swift +++ b/Tests/BlurtEngineTests/KeyTermsTests.swift @@ -1,44 +1,45 @@ import Testing +@testable import AssemblyAI @testable import BlurtEngine -/// `KeytermsBoost.fitted` is the last thing between the user's Settings list and +/// `KeyTerms.fitted` is the last thing between the user's Settings list and /// `config.keyterms_prompt`. The list is the one unbounded input in the request, /// and over the field's cap the API rejects the whole request — so what these /// pin is that an over-long list degrades to fewer terms rather than to a failed /// dictation. -@Suite("KeytermsBoost") -struct KeytermsBoostTests { +@Suite("KeyTerms") +struct KeyTermsTests { @Test("no terms sends no field") func emptyListSendsNothing() { // Empty is the one "nothing to send" state, which the encoders turn into an // absent `keyterms_prompt` rather than a `[]` asking to boost nothing. - #expect(KeytermsBoost.fitted([]).isEmpty) + #expect(KeyTerms.fitted([]).isEmpty) } @Test("an all-blank list sends no field") func blankTermsSendNothing() { - #expect(KeytermsBoost.fitted(["", " ", "\n\t"]).isEmpty) + #expect(KeyTerms.fitted(["", " ", "\n\t"]).isEmpty) } @Test("terms pass through trimmed, in the user's order") func termsPassThroughInOrder() { // Order is the user's — it decides which terms survive a list too long to // send — and the API takes the strings as spellings, so they arrive trimmed. - #expect(KeytermsBoost.fitted(["AssemblyAI", " LeMUR ", "Blurt"]) == ["AssemblyAI", "LeMUR", "Blurt"]) + #expect(KeyTerms.fitted(["AssemblyAI", " LeMUR ", "Blurt"]) == ["AssemblyAI", "LeMUR", "Blurt"]) } @Test("blank entries are dropped from among real terms") func blanksDroppedAmongRealTerms() { // A stray comma in the Settings field must cost the request nothing. - #expect(KeytermsBoost.fitted(["AssemblyAI", " ", "LeMUR"]) == ["AssemblyAI", "LeMUR"]) + #expect(KeyTerms.fitted(["AssemblyAI", " ", "LeMUR"]) == ["AssemblyAI", "LeMUR"]) } @Test("an oversized list is fitted to the cap, keeping whole leading terms") func oversizedListIsFitted() { let terms = (0..<2000).map { "term\($0)" } - let fitted = KeytermsBoost.fitted(terms) - #expect(fitted.reduce(0) { $0 + $1.utf8.count } <= KeytermsBoost.characterCap) + let fitted = KeyTerms.fitted(terms) + #expect(fitted.reduce(0) { $0 + $1.utf8.count } <= KeyTerms.characterCap) #expect(fitted.first == "term0") #expect(fitted.count < terms.count) // Whole terms only — a truncated name boosts nothing, so every survivor is @@ -50,13 +51,13 @@ struct KeytermsBoostTests { func oneHugeTermSendsNothing() { // Not a clipped fragment: half a name is not the term the user asked to // boost, so the field is dropped whole. - #expect(KeytermsBoost.fitted([String(repeating: "k", count: KeytermsBoost.characterCap + 1)]).isEmpty) + #expect(KeyTerms.fitted([String(repeating: "k", count: KeyTerms.characterCap + 1)]).isEmpty) } @Test("a term that doesn't fit stops the list without dropping earlier ones") func overflowingTermStopsTheList() { - let huge = String(repeating: "k", count: KeytermsBoost.characterCap) - #expect(KeytermsBoost.fitted(["Blurt", huge, "LeMUR"]) == ["Blurt"]) + let huge = String(repeating: "k", count: KeyTerms.characterCap) + #expect(KeyTerms.fitted(["Blurt", huge, "LeMUR"]) == ["Blurt"]) } @Test("a list under the byte cap is still cut to the documented term cap") @@ -66,9 +67,9 @@ struct KeytermsBoostTests { // at most 100 items and 400s the whole request above that — so every // dictation would fail until the user shortened the field. let terms = (0..<150).map { "t\($0)" } - let fitted = KeytermsBoost.fitted(terms) - #expect(terms.reduce(0) { $0 + $1.utf8.count } < KeytermsBoost.characterCap) - #expect(fitted.count == KeytermsBoost.termCap) + let fitted = KeyTerms.fitted(terms) + #expect(terms.reduce(0) { $0 + $1.utf8.count } < KeyTerms.characterCap) + #expect(fitted.count == KeyTerms.termCap) // The user's order, first-typed wins — same rule as the byte cap. #expect(fitted.first == "t0") #expect(fitted.last == "t99") @@ -77,18 +78,55 @@ struct KeytermsBoostTests { @Test("a list at exactly the term cap is sent whole") func termCapBoundaryIsInclusive() { // Off-by-one guard on the boundary itself: 100 is allowed, 101 is not. - #expect(KeytermsBoost.fitted((0..<100).map { "t\($0)" }).count == 100) - #expect(KeytermsBoost.fitted((0..<101).map { "t\($0)" }).count == 100) + #expect(KeyTerms.fitted((0..<100).map { "t\($0)" }).count == 100) + #expect(KeyTerms.fitted((0..<101).map { "t\($0)" }).count == 100) } @Test("the boost cap is its own number, not the conversation context's") func capIsDistinctFromTheContextCap() { // Two caps on two fields of one request. Borrowing the other field's figure // is how an over-cap value shipped once before, so pin them apart. - #expect(KeytermsBoost.characterCap == 2048) - #expect(KeytermsBoost.characterCap != STTPrompt.characterCap) + #expect(KeyTerms.characterCap == 2048) + #expect(KeyTerms.characterCap != STTPrompt.characterCap) // And the term cap is a count, not a size — pinned so the two caps on this // one field can't be conflated either. - #expect(KeytermsBoost.termCap == 100) + #expect(KeyTerms.termCap == 100) + } +} + +@Suite("KeyTerms.parse") +struct KeyTermsParseTests { + @Test("nil and blank input yield no terms") + func emptyInputs() { + #expect(KeyTerms.parse(nil).isEmpty) + #expect(KeyTerms.parse("").isEmpty) + #expect(KeyTerms.parse(" , , \n").isEmpty) + } + + @Test("comma-separated input splits and trims each term") + func splitsAndTrims() { + #expect(KeyTerms.parse("AssemblyAI, Kubernetes , Anthropic") == ["AssemblyAI", "Kubernetes", "Anthropic"]) + } + + @Test("blank entries between commas are dropped") + func dropsBlanks() { + #expect(KeyTerms.parse("foo,,bar, ,baz") == ["foo", "bar", "baz"]) + } + + @Test("duplicates are removed case-insensitively, keeping the first spelling") + func dedupesCaseInsensitively() { + #expect(KeyTerms.parse("Blurt, blurt, BLURT, Slack") == ["Blurt", "Slack"]) + } + + @Test("multi-word terms survive (only commas split)") + func multiWordTerms() { + #expect(KeyTerms.parse("San Francisco, New York") == ["San Francisco", "New York"]) + } + + @Test("join is what parse reads back") + func joinRoundTrips() { + let terms = ["AssemblyAI", "San Francisco", "LeMUR"] + #expect(KeyTerms.join(terms) == "AssemblyAI, San Francisco, LeMUR") + #expect(KeyTerms.parse(KeyTerms.join(terms)) == terms) } } diff --git a/Tests/BlurtEngineTests/KeytermsWireTests.swift b/Tests/BlurtEngineTests/KeytermsWireTests.swift index e8672e41..a6206725 100644 --- a/Tests/BlurtEngineTests/KeytermsWireTests.swift +++ b/Tests/BlurtEngineTests/KeytermsWireTests.swift @@ -5,7 +5,7 @@ import Testing @testable import BlurtEngine /// The two steering fields of the dictation `config`, as they actually encode: -/// `KeytermsBoost` → `keyterms_prompt`, and `STTPrompt` → a single +/// `KeyTerms` → `keyterms_prompt`, and `STTPrompt` → a single /// `stt_prompt` string. An extension of the `HTTPClientTests` suite in its own /// file, exactly as the `APIKeyValidator` cases are — with its own private /// helper, since each of those files carries its own rather than sharing one diff --git a/Tests/BlurtEngineTests/PersistedSettingsTests.swift b/Tests/BlurtEngineTests/PersistedSettingsTests.swift index 19fe2538..fe3b4e37 100644 --- a/Tests/BlurtEngineTests/PersistedSettingsTests.swift +++ b/Tests/BlurtEngineTests/PersistedSettingsTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +@testable import AssemblyAI @testable import BlurtEngine /// `PersistedSettings.allDefaultsKeys` exists so "add a store" and "add it to diff --git a/Tests/BlurtEngineTests/STTPromptTests.swift b/Tests/BlurtEngineTests/STTPromptTests.swift index 37dbbebe..afe50fd4 100644 --- a/Tests/BlurtEngineTests/STTPromptTests.swift +++ b/Tests/BlurtEngineTests/STTPromptTests.swift @@ -195,7 +195,7 @@ struct STTPromptTests { /// What the context is *not* allowed to carry. Every signal below is captured at /// press time and kept on the machine: the focus fields drive the paste path /// (the leading separator, the injector's window identity) and the developer-mode -/// log, and the key terms ride their own request field (`KeytermsBoost`) rather +/// log, and the key terms ride their own request field (`KeyTerms`) rather /// than this one. `text` is the only door to the wire, so this suite is what /// stands between a captured context and AssemblyAI's servers. @Suite("STTPrompt scope") diff --git a/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift b/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift index 71d4407e..760a2108 100644 --- a/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift +++ b/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift @@ -1,6 +1,7 @@ import Foundation import Synchronization +@testable import AssemblyAI @testable import BlurtEngine /// A `DictationSession` plus the doubles a test configures or asserts against diff --git a/Tests/BlurtEngineTests/TextShortcutTests.swift b/Tests/BlurtEngineTests/TextShortcutTests.swift index 53787c53..35dce5f8 100644 --- a/Tests/BlurtEngineTests/TextShortcutTests.swift +++ b/Tests/BlurtEngineTests/TextShortcutTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +@testable import AssemblyAI @testable import BlurtEngine @Suite("TextShortcutExpander") @@ -147,7 +148,7 @@ struct TextShortcutStoreTests { TextShortcut(trigger: "...", expansion: "unmatchable"), ] #expect(store.shortcuts.map(\.expansion) == ["a@example.com"]) - #expect(TextShortcutStore.matchKey(for: "Cal.com") == TextShortcutStore.matchKey(for: "cal com")) + #expect(TextShortcut.matchKey(for: "Cal.com") == TextShortcut.matchKey(for: "cal com")) } @Test("an undecodable slot reads as no shortcuts") diff --git a/Tests/BlurtEngineTests/TranscriptionContextTests.swift b/Tests/BlurtEngineTests/TranscriptionContextTests.swift index b2337e41..969843ec 100644 --- a/Tests/BlurtEngineTests/TranscriptionContextTests.swift +++ b/Tests/BlurtEngineTests/TranscriptionContextTests.swift @@ -1,5 +1,6 @@ import Testing +@testable import AssemblyAI @testable import BlurtEngine /// `TranscriptionContext.isEmpty` is the gate `FocusCapture`/`DictationSession` @@ -47,7 +48,7 @@ struct TranscriptionContextTests { // Not in the context turns — they are sent as the request's word-boost list, // which is why a key-terms-only context is still worth carrying. #expect(STTPrompt.text(context: context).isEmpty) - #expect(KeytermsBoost.fitted(context.keyTerms) == ["Blurt"]) + #expect(KeyTerms.fitted(context.keyTerms) == ["Blurt"]) } @Test("recent dictations alone make it non-empty, and are what gets sent") diff --git a/scripts/mutate.sh b/scripts/mutate.sh index d0f02d8c..9425d073 100755 --- a/scripts/mutate.sh +++ b/scripts/mutate.sh @@ -47,6 +47,8 @@ cd "$REPO_ROOT" DEFAULT_TARGETS=( "Sources/BlurtEngine/Config/APIKeyDisplay.swift" "Sources/AssemblyAI/APIKeyValidator.swift" + "Sources/AssemblyAI/Dictation/KeyTerms.swift" + "Sources/AssemblyAI/Dictation/TextShortcuts.swift" "Sources/BlurtEngine/FocusCapture/FocusCapture+Pure.swift" "Sources/BlurtEngine/Hotkey/DictationKeyGate.swift" "Sources/BlurtEngine/Hotkey/DictationKeyRouter.swift" @@ -56,7 +58,6 @@ DEFAULT_TARGETS=( "Sources/BlurtEngine/Pipeline/OverlayPlacement.swift" "Sources/BlurtEngine/Pipeline/PipelinePhase.swift" "Sources/BlurtEngine/Pipeline/RecordingCueGate.swift" - "Sources/BlurtEngine/STT/KeytermsBoost.swift" "Sources/BlurtEngine/STT/STTPrompt.swift" "Sources/BlurtEngine/STT/SyncSTTLimits.swift" "Sources/BlurtEngine/StringNormalization.swift" From 09d74b02fc1a0beb3f24ec09c36476d39087afa4 Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 18:11:27 -0500 Subject: [PATCH 2/8] feat(ios): add a Words tab for key terms and text shortcuts The app gets Dictate and Words tabs under a brand tab bar, as in the blurt-ios prototype. MainView owns what works on either tab: Settings from each tab's gear, the consent sheet and shared key-term lists. Words, on the AssemblyAI SDK's KeyTerms and TextShortcut: - Key terms as pills with an inline add field (commas add several, duplicates drop, capped at the request's 100), the contact-name count, and Share. They move here from Settings. - Text shortcuts with an inline "When I say / Type this" form; re-adding a phrase replaces it. Expanded on the phone, never sent. BlurtDesign gains BrandTabBar, FlowLayout, brandInput() and brandFootnote(), and brand buttons fade while disabled. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- App/BlurtiOS/BlurtDesign/BrandTabBar.swift | 52 ++++ App/BlurtiOS/BlurtDesign/Chrome.swift | 33 +++ App/BlurtiOS/BlurtDesign/DesignTokens.swift | 35 ++- App/BlurtiOS/BlurtDesign/FlowLayout.swift | 52 ++++ .../BlurtiOS/Sources/BlurtiOSApp.swift | 3 +- App/BlurtiOS/BlurtiOS/Sources/HomeView.swift | 29 +- App/BlurtiOS/BlurtiOS/Sources/MainView.swift | 67 +++++ .../BlurtiOS/Sources/SettingsView.swift | 25 +- App/BlurtiOS/BlurtiOS/Sources/WordsView.swift | 247 ++++++++++++++++++ App/BlurtiOS/BlurtiOSCore/WordsEdit.swift | 34 +++ .../BlurtiOSTests/WordsEditTests.swift | 35 +++ App/BlurtiOS/DESIGN.md | 32 ++- App/BlurtiOS/Design/tokens.json | 46 +++- 14 files changed, 635 insertions(+), 57 deletions(-) create mode 100644 App/BlurtiOS/BlurtDesign/BrandTabBar.swift create mode 100644 App/BlurtiOS/BlurtDesign/FlowLayout.swift create mode 100644 App/BlurtiOS/BlurtiOS/Sources/MainView.swift create mode 100644 App/BlurtiOS/BlurtiOS/Sources/WordsView.swift create mode 100644 App/BlurtiOS/BlurtiOSCore/WordsEdit.swift create mode 100644 App/BlurtiOS/BlurtiOSTests/WordsEditTests.swift diff --git a/AGENTS.md b/AGENTS.md index 6c3f4c1a..1e3d8751 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ App/BlurtiOS/ the iPhone app and its keyboard (README.md there is SwiftUI, no audio; `package` access; coverage-gated (ios-test.sh) BlurtiOS/Sources/ the app: DictationCoordinator (composition root), ListeningWindow + WindowedAudioSource (the always-on capture, since iOS can't open - the mic from the background), HomeView and the other screens + the mic from the background), MainView (Dictate and Words tabs) and the screens BlurtKeyboard/Sources/ the keyboard's views: a remote control that inserts the words; three layouts (slim bar / panel / full keyboard) over BlurtiOSCore's model BlurtDesign/ the component library both targets link: the generated DesignTokens, diff --git a/App/BlurtiOS/BlurtDesign/BrandTabBar.swift b/App/BlurtiOS/BlurtDesign/BrandTabBar.swift new file mode 100644 index 00000000..5d619dce --- /dev/null +++ b/App/BlurtiOS/BlurtDesign/BrandTabBar.swift @@ -0,0 +1,52 @@ +import SwiftUI + +/// The brand's tab bar, in place of the system one: each tab's icon over its +/// mono uppercase label, the selected tab in the accent and the rest muted, a +/// hairline above. The tabs are the caller's; this only draws and selects. +package struct BrandTabBar: View { + let tabs: [Tab] + @Binding var selection: Tab + let label: (Tab) -> String + /// An SF Symbol; the selected tab draws its `.fill` variant. + let symbol: (Tab) -> String + + package init( + _ tabs: [Tab], selection: Binding, label: @escaping (Tab) -> String, symbol: @escaping (Tab) -> String + ) { + self.tabs = tabs + _selection = selection + self.label = label + self.symbol = symbol + } + + package var body: some View { + HStack(spacing: 0) { + ForEach(tabs) { tab in + let isSelected = tab == selection + Button { + selection = tab + } label: { + VStack(spacing: DesignTokens.Metrics.appTabGap) { + Image(systemName: isSelected ? symbol(tab) + ".fill" : symbol(tab)) + .font(.system(size: DesignTokens.Typography.sizeTabIcon, weight: DesignTokens.Typography.weightGlyph)) + Text(label(tab).uppercased()) + .font(BlurtType.mono(DesignTokens.Typography.sizeTabLabel, weight: .medium)) + .tracking(DesignTokens.Typography.trackingEyebrow) + } + .foregroundStyle(isSelected ? BlurtBrand.accent : BlurtBrand.muted) + .frame(maxWidth: .infinity, minHeight: DesignTokens.Metrics.appTabHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(label(tab)) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + } + } + .padding(.top, DesignTokens.Metrics.appLineGap) + .background(BlurtBrand.page) + .overlay(alignment: .top) { + Rectangle().fill(BlurtBrand.cardBorder).frame(height: DesignTokens.Metrics.cardBorder) + } + .animation(.easeInOut(duration: DesignTokens.Motion.colour), value: selection) + } +} diff --git a/App/BlurtiOS/BlurtDesign/Chrome.swift b/App/BlurtiOS/BlurtDesign/Chrome.swift index b9d9584b..5c82212c 100644 --- a/App/BlurtiOS/BlurtDesign/Chrome.swift +++ b/App/BlurtiOS/BlurtDesign/Chrome.swift @@ -90,10 +90,43 @@ package struct BrandButtonStyle: ButtonStyle { role == .secondary ? BlurtBrand.cardBorder : Color.clear, lineWidth: DesignTokens.Metrics.cardBorder) ) .opacity(configuration.isPressed ? DesignTokens.Metrics.opacityPressed : 1) + .modifier(FadedWhenDisabled()) .animation(.easeInOut(duration: DesignTokens.Motion.colour), value: configuration.isPressed) } } +/// A brand button that can't be pressed yet sits back rather than looking live. +/// A modifier because a `ButtonStyle` can't read the environment itself. +private struct FadedWhenDisabled: ViewModifier { + @Environment(\.isEnabled) private var isEnabled + + func body(content: Content) -> some View { + content.opacity(isEnabled ? 1 : DesignTokens.Metrics.opacityDisabled) + } +} + +extension View { + /// The brand's input: the page colour inside the card, 8 pt corners, the + /// card's hairline, the body face. + package func brandInput() -> some View { + font(BlurtType.body(DesignTokens.Typography.sizeBody)) + .foregroundStyle(BlurtBrand.text) + .padding(.horizontal, DesignTokens.Metrics.appChipPadX) + .padding(.vertical, DesignTokens.Metrics.appInputPadY) + .background(BlurtBrand.page, in: RoundedRectangle(cornerRadius: DesignTokens.Metrics.radiusInput)) + .overlay( + RoundedRectangle(cornerRadius: DesignTokens.Metrics.radiusInput) + .strokeBorder(BlurtBrand.cardBorder, lineWidth: DesignTokens.Metrics.cardBorder)) + } + + /// The brand's small print under a card: the caption size, muted. + package func brandFootnote() -> some View { + font(BlurtType.body(DesignTokens.Typography.sizeCaption)) + .foregroundStyle(BlurtBrand.muted) + .fixedSize(horizontal: false, vertical: true) + } +} + /// The lowercase wordmark from the Mac's ready screen, tinted with the accent. /// The image (`blurt-ready-logo.png`, shared from `Design/brand`) is the host /// bundle's resource, not the library's; without it the word is set in the diff --git a/App/BlurtiOS/BlurtDesign/DesignTokens.swift b/App/BlurtiOS/BlurtDesign/DesignTokens.swift index 1bde0c19..bae82a97 100644 --- a/App/BlurtiOS/BlurtDesign/DesignTokens.swift +++ b/App/BlurtiOS/BlurtDesign/DesignTokens.swift @@ -229,18 +229,32 @@ package enum DesignTokens { package nonisolated static let appHeroPad: CGFloat = 20 /// The header's gear, the copy glyph. package nonisolated static let appIcon: CGFloat = 20 + /// An app input's top and bottom (its sides are app/chip-pad-x). + package nonisolated static let appInputPadY: CGFloat = 10 /// Between lines of one thought. package nonisolated static let appLineGap: CGFloat = 6 /// The screens' side padding. package nonisolated static let appPagePad: CGFloat = 20 + /// A key-term pill's top and bottom, and its side beside the ×. + package nonisolated static let appPillPadY: CGFloat = 4 + /// The × on a key-term pill: its tap square. + package nonisolated static let appPillRemove: CGFloat = 24 /// Between the theme card's two faces. package nonisolated static let appPreviewGap: CGFloat = 8 + /// A list row's top and bottom inside a card. + package nonisolated static let appRowPadY: CGFloat = 8 + /// The × on a text-shortcut row: its tap square. + package nonisolated static let appRowRemove: CGFloat = 32 /// Between the home screen's sections. package nonisolated static let appSectionGap: CGFloat = 24 /// The setup steps' mono numerals. 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 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. + package nonisolated static let appTabHeight: CGFloat = 52 /// The card's hairline. package nonisolated static let cardBorder: CGFloat = 1 /// The app's cards (radius/card). @@ -307,7 +321,7 @@ package enum DesignTokens { package nonisolated static let marginSide: CGFloat = 6.5 /// The keyboard's top, and the slim bar's bottom. package nonisolated static let marginVertical: CGFloat = 8 - /// The + without Full Access. + /// The + without Full Access; a brand button that can't be pressed yet. package nonisolated static let opacityDisabled: CGFloat = 0.4 /// The cross glint when the words land. package nonisolated static let opacityGlint: CGFloat = 0.95 @@ -439,8 +453,16 @@ package enum DesignTokens { package nonisolated static let sizeLegend: CGFloat = 16 /// Letter keys: the iPhone's (estimated from the l glyph, apple-geometry.json). package nonisolated static let sizeLetter: CGFloat = 24 + /// The × on a key-term pill. + package nonisolated static let sizePillGlyph: CGFloat = 10 /// The letter pop-up. package nonisolated static let sizePopup: CGFloat = 32 + /// The × on a text-shortcut row. + package nonisolated static let sizeRemoveGlyph: CGFloat = 12 + /// A tab's icon. + package nonisolated static let sizeTabIcon: CGFloat = 18 + /// A tab's mono label. + package nonisolated static let sizeTabLabel: CGFloat = 11 /// The key-term field. package nonisolated static let sizeTerm: CGFloat = 17 /// The app's serif headline. @@ -605,12 +627,19 @@ package enum DesignTokens { "metrics.app/hero-gap": "14", "metrics.app/hero-pad": "20", "metrics.app/icon": "20", + "metrics.app/input-pad-y": "10", "metrics.app/line-gap": "6", "metrics.app/page-pad": "20", + "metrics.app/pill-pad-y": "4", + "metrics.app/pill-remove": "24", "metrics.app/preview-gap": "8", + "metrics.app/row-pad-y": "8", + "metrics.app/row-remove": "32", "metrics.app/section-gap": "24", "metrics.app/setup-number-width": "24", "metrics.app/stack-gap": "12", + "metrics.app/tab-gap": "4", + "metrics.app/tab-height": "52", "metrics.card/border": "1", "metrics.card/radius": "12", "metrics.caret/height": "20", @@ -718,7 +747,11 @@ package enum DesignTokens { "type.size/label": "12", "type.size/legend": "16", "type.size/letter": "24", + "type.size/pill-glyph": "10", "type.size/popup": "32", + "type.size/remove-glyph": "12", + "type.size/tab-icon": "18", + "type.size/tab-label": "11", "type.size/term": "17", "type.size/title": "34", "type.tracking/cta": "1.4", diff --git a/App/BlurtiOS/BlurtDesign/FlowLayout.swift b/App/BlurtiOS/BlurtDesign/FlowLayout.swift new file mode 100644 index 00000000..0a1ecb76 --- /dev/null +++ b/App/BlurtiOS/BlurtDesign/FlowLayout.swift @@ -0,0 +1,52 @@ +import SwiftUI + +/// Lays its children out left to right, wrapping onto a new line when the next +/// one won't fit — the key-term pills. +package struct FlowLayout: Layout { + var spacing: CGFloat + + package init(spacing: CGFloat = DesignTokens.Metrics.appChipGap) { self.spacing = spacing } + + package func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let rows = rows(for: subviews, width: proposal.width ?? .infinity) + let height = rows.map(\.height).reduce(0, +) + spacing * CGFloat(max(rows.count - 1, 0)) + return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: height) + } + + package func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var y = bounds.minY + for row in rows(for: subviews, width: bounds.width) { + var x = bounds.minX + for index in row.indices { + let size = subviews[index].sizeThatFits(.unspecified) + subviews[index].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing + } + y += row.height + spacing + } + } + + private struct Row { + var indices: [Int] = [] + var width: CGFloat = 0 + var height: CGFloat = 0 + } + + private func rows(for subviews: Subviews, width: CGFloat) -> [Row] { + var rows: [Row] = [] + var row = Row() + for index in subviews.indices { + let size = subviews[index].sizeThatFits(.unspecified) + let needed = row.indices.isEmpty ? size.width : row.width + spacing + size.width + if needed > width, !row.indices.isEmpty { + rows.append(row) + row = Row() + } + row.width = row.indices.isEmpty ? size.width : row.width + spacing + size.width + row.height = max(row.height, size.height) + row.indices.append(index) + } + if !row.indices.isEmpty { rows.append(row) } + return rows + } +} diff --git a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift index a24e3de0..9eb84874 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift @@ -60,8 +60,7 @@ struct BlurtiOSApp: App { } private var home: some View { - HomeView(coordinator: coordinator) - .onOpenURL { coordinator.handle($0) } + MainView(coordinator: coordinator) .task { await start() } } diff --git a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift index 168af9ff..d6abf538 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift @@ -4,18 +4,19 @@ import BlurtEngine import BlurtiOSCore import SwiftUI -/// The app's one screen: the Blurt landing page on a phone. The wordmark and +/// 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 /// 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. struct HomeView: View { var coordinator: DictationCoordinator + /// Opens Settings, which `MainView` presents over either tab. + let showSettings: () -> Void @Environment(\.scenePhase) private var scenePhase @State private var microphoneGranted = AVAudioApplication.shared.recordPermission == .granted @State private var keyboardSeen = SharedStore.keyboardEverSeen @State private var showsKeyEntry = false - @State private var showsSettings = 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 @@ -52,21 +53,6 @@ struct HomeView: View { .environment(\.voiceElementKind, VoiceElementKind(rawValue: voiceRaw) ?? .shipped) .toolbar(.hidden, for: .navigationBar) .sheet(isPresented: $showsKeyEntry) { KeyEntryView(apiKey: coordinator.apiKey) } - .sheet(isPresented: $showsSettings) { SettingsView(coordinator: coordinator) } - .sheet(isPresented: Binding(get: { coordinator.needsConsent }, set: { coordinator.needsConsent = $0 })) { - ConsentView { Task { await coordinator.grantConsent() } } - } - .sheet(item: Binding(get: { coordinator.pendingTermPack }, set: { coordinator.pendingTermPack = $0 })) { - ImportTermsView(pack: $0) - } - .alert( - "Couldn't read that list", - isPresented: Binding(get: { coordinator.termPackUnreadable }, set: { coordinator.termPackUnreadable = $0 }) - ) { - Button("OK") {} - } message: { - Text("It isn't a Blurt key-term list, or it's too big.") - } .onChange(of: scenePhase) { _, phase in if phase == .active { refreshStatus() } } @@ -82,14 +68,7 @@ struct HomeView: View { HStack { Wordmark() Spacer() - Button { - showsSettings = true - } label: { - Image(systemName: "gearshape") - .font(.system(size: DesignTokens.Metrics.appIcon, weight: DesignTokens.Typography.weightGlyph)) - .foregroundStyle(BlurtBrand.muted) - } - .accessibilityLabel("Settings") + SettingsButton(action: showSettings) } .frame(height: DesignTokens.Metrics.appHeaderHeight) } diff --git a/App/BlurtiOS/BlurtiOS/Sources/MainView.swift b/App/BlurtiOS/BlurtiOS/Sources/MainView.swift new file mode 100644 index 00000000..ac615bf4 --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/MainView.swift @@ -0,0 +1,67 @@ +import BlurtDesign +import BlurtiOSCore +import SwiftUI + +/// 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 +/// key-term list arriving from Messages. +struct MainView: View { + enum Tab: CaseIterable, Identifiable { + case dictate, words + + var id: Self { self } + + var label: String { + switch self { + case .dictate: "Dictate" + case .words: "Words" + } + } + + var symbol: String { + switch self { + case .dictate: "mic" + case .words: "character.book.closed" + } + } + } + + var coordinator: DictationCoordinator + @State private var tab = Tab.dictate + @State private var showsSettings = false + + var body: some View { + Group { + switch tab { + case .dictate: HomeView(coordinator: coordinator) { showsSettings = true } + case .words: WordsView(coordinator: coordinator) { showsSettings = true } + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + BrandTabBar(Tab.allCases, selection: $tab, label: \.label, symbol: \.symbol) + } + .sheet(isPresented: $showsSettings) { SettingsView(coordinator: coordinator) } + .sheet(isPresented: Binding(get: { coordinator.needsConsent }, set: { coordinator.needsConsent = $0 })) { + ConsentView { Task { await coordinator.grantConsent() } } + } + .sheet(item: Binding(get: { coordinator.pendingTermPack }, set: { coordinator.pendingTermPack = $0 })) { + ImportTermsView(pack: $0) + } + .alert( + "Couldn't read that list", + isPresented: Binding(get: { coordinator.termPackUnreadable }, set: { coordinator.termPackUnreadable = $0 }) + ) { + Button("OK") {} + } message: { + Text("It isn't a Blurt key-term list, or it's too big.") + } + // The keyboard's `blurt://start` is about the listening window, which the + // Dictate tab shows; a key-term list lands on Words, where it's added. + .onOpenURL { url in + tab = TermPack.looksLikePack(url) ? .words : .dictate + coordinator.handle(url) + } + .tint(BlurtBrand.accent) + } +} diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index f6bcb265..37848d4b 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -1,4 +1,3 @@ -import AssemblyAI import BlurtDesign import BlurtEngine import BlurtiOSCore @@ -7,7 +6,7 @@ import SwiftUI /// Everything adjustable, grouped the way the Mac's Settings are: the keyboard /// (layout, theme, hands-free), listening, transcription, the account, and /// about — a form on the brand's page, its sections under mono eyebrows. -/// Presented from the home screen's gear. +/// Presented from either tab's gear. struct SettingsView: View { var coordinator: DictationCoordinator @Environment(\.dismiss) private var dismiss @@ -20,7 +19,6 @@ struct SettingsView: View { @AppStorage(BlurtShared.Key.voiceElement, store: SharedStore.defaults) private var voiceRaw = VoiceElementKind.shipped.rawValue @State private var showsKeyEntry = false - @AppStorage(SharedStore.keyTermsKey, store: SharedStore.defaults) private var keyTerms = "" @AppStorage(EnhancedTranscriptsStore.defaultsKey) private var enhancedTranscripts = EnhancedTranscriptsStore.defaultValue @@ -105,24 +103,10 @@ struct SettingsView: View { Section { Toggle("Enhanced transcripts", isOn: $enhancedTranscripts) NavigationLink("Output styles") { StylesView() } - TextField("Key terms, comma-separated", text: $keyTerms, axis: .vertical) - .autocorrectionDisabled() - LabeledContent("Key terms", value: "\(KeyTerms.parse(keyTerms).count)") - if !KeyTerms.parse(keyTerms).isEmpty { - ShareLink( - item: termPack, subject: Text("Blurt key terms"), message: Text(termPack.plainText), - preview: SharePreview(termPack.name, icon: Image(systemName: "text.badge.plus")) - ) { - Label("Share key terms…", systemImage: "square.and.arrow.up") - } - } } header: { Eyebrow("Transcription") } footer: { - Text( - "Enhanced transcripts clean up punctuation and wording. Key terms are names and jargon to spell right — " - + "add one from the keyboard with the + beside the orb, share the list with a group chat so everyone's " - + "dictation gets the names right; \(coordinator.lexiconNameCount) contact names come along automatically.") + Text("Enhanced transcripts clean up punctuation and wording. Key terms and text shortcuts are on the Words tab.") } } @@ -146,11 +130,6 @@ struct SettingsView: View { } } - /// The whole list, for a friend: a `.blurtterms` file plus the words as text. - private var termPack: TermPack { - TermPack(name: "Key terms", from: nil, terms: KeyTerms.parse(keyTerms)) - } - private static var version: String { let short = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?" let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?" diff --git a/App/BlurtiOS/BlurtiOS/Sources/WordsView.swift b/App/BlurtiOS/BlurtiOS/Sources/WordsView.swift new file mode 100644 index 00000000..3545648c --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/WordsView.swift @@ -0,0 +1,247 @@ +import AssemblyAI +import BlurtDesign +import BlurtEngine +import BlurtiOSCore +import SwiftUI + +/// The Words tab: key terms the speech model should spell exactly, and text +/// shortcuts that turn a spoken phrase into longer text. Both apply to every +/// dictation, from the app and from the keyboard, as on Blurt for Mac. +struct WordsView: View { + var coordinator: DictationCoordinator + let showSettings: () -> Void + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appSectionGap) { + HStack { + Text("Words") + .font(BlurtType.heading(DesignTokens.Typography.sizeTitle)) + .foregroundStyle(BlurtBrand.text) + .accessibilityAddTraits(.isHeader) + Spacer() + SettingsButton(action: showSettings) + } + .frame(height: DesignTokens.Metrics.appHeaderHeight) + KeyTermsCard(contactNames: coordinator.lexiconNameCount) + TextShortcutsCard() + } + .padding(.horizontal, DesignTokens.Metrics.appPagePad) + .padding(.bottom, DesignTokens.Metrics.appSectionGap) + } + .scrollDismissesKeyboard(.interactively) + .page() + } +} + +/// The gear in each tab's header, which opens Settings. +struct SettingsButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "gearshape") + .font(.system(size: DesignTokens.Metrics.appIcon, weight: DesignTokens.Typography.weightGlyph)) + .foregroundStyle(BlurtBrand.muted) + } + .accessibilityLabel("Settings") + } +} + +/// The key terms as the design system's pills, a field to add more, and the +/// list's share link. They live in the App Group, where the keyboard's + adds +/// one on the spot, so this reads and writes the shared slot. +private struct KeyTermsCard: View { + let contactNames: Int + @AppStorage(SharedStore.keyTermsKey, store: SharedStore.defaults) private var raw = "" + @State private var draft = "" + + private var terms: [String] { KeyTerms.parse(raw) } + + var body: some View { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appStackGap) { + Eyebrow("Key terms") + if !terms.isEmpty { + FlowLayout { + ForEach(terms, id: \.self) { term in + TermPill(term: term) { SharedStore.keyTerms = terms.filter { $0 != term } } + } + } + } + HStack(spacing: DesignTokens.Metrics.appChipGap) { + TextField("Add a name or product term", text: $draft) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.done) + .onSubmit(add) + .brandInput() + Button("Add", action: add) + .buttonStyle(BrandButtonStyle()) + .fixedSize() + .disabled(KeyTerms.parse(draft).isEmpty || terms.count >= KeyTerms.termCap) + } + Text( + "Spelled exactly as written in every dictation. Separate several with commas, or add one from the " + + "keyboard with the + beside the mic. \(terms.count) of \(KeyTerms.termCap)." + + (contactNames > 0 ? " Your \(contactNames) contact names come along automatically." : "") + ) + .brandFootnote() + if !terms.isEmpty { + ShareLink( + item: pack, subject: Text("Blurt key terms"), message: Text(pack.plainText), + preview: SharePreview(pack.name, icon: Image(systemName: "text.badge.plus")) + ) { + Label("Share key terms", systemImage: "square.and.arrow.up") + .font(BlurtType.body(DesignTokens.Typography.sizeCaption, bold: true)) + } + .tint(BlurtBrand.accent) + } + } + .padding(DesignTokens.Metrics.appCardPad) + .frame(maxWidth: .infinity, alignment: .leading) + .card() + } + + /// The whole list, for a friend: a `.blurtterms` file plus the words as text. + private var pack: TermPack { TermPack(name: "Key terms", from: nil, terms: terms) } + + private func add() { + let updated = WordsEdit.addingKeyTerms(draft, to: terms) + guard updated != terms else { return } + SharedStore.keyTerms = updated + draft = "" + } +} + +/// A key term as the design system's pill: full radius, the page's fill inside +/// the card, a hairline, the body face. Its × removes it. +private struct TermPill: View { + let term: String + let remove: () -> Void + + var body: some View { + HStack(spacing: DesignTokens.Metrics.appLineGap) { + Text(term) + .font(BlurtType.body(DesignTokens.Typography.sizeCaption)) + .foregroundStyle(BlurtBrand.text) + Button(action: remove) { + Image(systemName: "xmark") + .font(.system(size: DesignTokens.Typography.sizePillGlyph, weight: .bold)) + .foregroundStyle(BlurtBrand.muted) + .frame(width: DesignTokens.Metrics.appPillRemove, height: DesignTokens.Metrics.appPillRemove) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Remove \(term)") + } + .padding(.leading, DesignTokens.Metrics.appChipPadX) + .padding(.trailing, DesignTokens.Metrics.appPillPadY) + .padding(.vertical, DesignTokens.Metrics.appPillPadY) + .background(BlurtBrand.page, in: Capsule()) + .overlay(Capsule().strokeBorder(BlurtBrand.cardBorder, lineWidth: DesignTokens.Metrics.cardBorder)) + } +} + +/// The text shortcuts, each with its ×, over two fields that add one. The +/// session expands them on the phone (`TextShortcutExpander`), so nothing here +/// goes to AssemblyAI. The store owns the JSON: this binds the raw slot only to +/// redraw, and writes through `TextShortcutStore`. +private struct TextShortcutsCard: View { + @AppStorage(TextShortcutStore.defaultsKey) private var raw = "" + @State private var trigger = "" + @State private var expansion = "" + @FocusState private var focus: Field? + private let store = TextShortcutStore() + + private enum Field { case trigger, expansion } + + private var shortcuts: [TextShortcut] { store.shortcuts(decoding: raw) } + + var body: some View { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appStackGap) { + Eyebrow("Text shortcuts") + if !shortcuts.isEmpty { + VStack(spacing: 0) { + ForEach(Array(shortcuts.enumerated()), id: \.element.id) { index, shortcut in + if index > 0 { Rectangle().fill(BlurtBrand.cardBorder).frame(height: DesignTokens.Metrics.cardBorder) } + ShortcutRow(shortcut: shortcut) { store.shortcuts = shortcuts.filter { $0.id != shortcut.id } } + } + } + } + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appChipGap) { + TextField("When I say… (my email)", text: $trigger) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.next) + .focused($focus, equals: .trigger) + .onSubmit { focus = .expansion } + .onChange(of: trigger) { _, value in + if value.count > TextShortcutStore.triggerLimit { + trigger = String(value.prefix(TextShortcutStore.triggerLimit)) + } + } + .brandInput() + TextField("Type this (me@example.com)", text: $expansion, axis: .vertical) + .lineLimit(1...4) // literal-ok: the field's rows + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focus, equals: .expansion) + .onChange(of: expansion) { _, value in + if value.count > TextShortcutStore.expansionLimit { + expansion = String(value.prefix(TextShortcutStore.expansionLimit)) + } + } + .brandInput() + Button("Add shortcut", action: add) + .buttonStyle(BrandButtonStyle()) + .disabled(!canAdd) + } + Text("Say the phrase anywhere in a dictation and Blurt types the full text instead. They stay on this phone.") + .brandFootnote() + } + .padding(DesignTokens.Metrics.appCardPad) + .frame(maxWidth: .infinity, alignment: .leading) + .card() + } + + private var canAdd: Bool { + WordsEdit.canAddShortcut(trigger: trigger, expansion: expansion) + && shortcuts.count < TextShortcutStore.shortcutLimit + } + + private func add() { + guard canAdd else { return } + store.shortcuts = WordsEdit.addingShortcut(trigger: trigger, expansion: expansion, to: shortcuts) + trigger = "" + expansion = "" + focus = nil + } +} + +private struct ShortcutRow: View { + let shortcut: TextShortcut + let remove: () -> Void + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Metrics.appStackGap) { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appLineGap) { + Eyebrow(shortcut.trigger, color: BlurtBrand.accent) + Text(shortcut.expansion) + .font(BlurtType.body(DesignTokens.Typography.sizeCaption)) + .foregroundStyle(BlurtBrand.text) + .lineLimit(2) // literal-ok: two lines of the replacement + } + Spacer(minLength: 0) + Button(action: remove) { + Image(systemName: "xmark") + .font(.system(size: DesignTokens.Typography.sizeRemoveGlyph, weight: .semibold)) + .foregroundStyle(BlurtBrand.muted) + .frame(width: DesignTokens.Metrics.appRowRemove, height: DesignTokens.Metrics.appRowRemove) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Remove \(shortcut.trigger)") + } + .padding(.vertical, DesignTokens.Metrics.appRowPadY) + } +} diff --git a/App/BlurtiOS/BlurtiOSCore/WordsEdit.swift b/App/BlurtiOS/BlurtiOSCore/WordsEdit.swift new file mode 100644 index 00000000..1f1d403c --- /dev/null +++ b/App/BlurtiOS/BlurtiOSCore/WordsEdit.swift @@ -0,0 +1,34 @@ +import AssemblyAI +import Foundation + +/// The Words tab's edits, apart from its views: what adding a key term or a +/// text shortcut does to the list it joins. The lists' own rules — parsing and +/// the request's caps (`KeyTerms`), a shortcut's match key (`TextShortcut`) — +/// are the AssemblyAI SDK's; the stores' limits are the engine's. +package nonisolated enum WordsEdit { + /// `terms` with whatever `draft` holds added at the end: one term, or several + /// separated by commas. Duplicates of a term already there drop + /// case-insensitively, and the list stops at the request's term cap, so a + /// term that couldn't be sent isn't kept either. + package static func addingKeyTerms(_ draft: String, to terms: [String]) -> [String] { + Array(KeyTerms.parse(KeyTerms.join(terms + KeyTerms.parse(draft))).prefix(KeyTerms.termCap)) + } + + /// Whether a shortcut from these fields could be saved: a phrase with a + /// letter or digit in it, and a replacement that isn't blank. + package static func canAddShortcut(trigger: String, expansion: String) -> Bool { + !TextShortcut.matchKey(for: trigger).isEmpty + && !expansion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// `shortcuts` with a new one at the top. A phrase already there — by match + /// key, so "my email" and "My-Email" are one — gets the new replacement + /// rather than a second entry the expander could never apply. + package static func addingShortcut(trigger: String, expansion: String, to shortcuts: [TextShortcut]) + -> [TextShortcut] + { + let key = TextShortcut.matchKey(for: trigger) + return [TextShortcut(trigger: trigger, expansion: expansion)] + + shortcuts.filter { TextShortcut.matchKey(for: $0.trigger) != key } + } +} diff --git a/App/BlurtiOS/BlurtiOSTests/WordsEditTests.swift b/App/BlurtiOS/BlurtiOSTests/WordsEditTests.swift new file mode 100644 index 00000000..95a290d3 --- /dev/null +++ b/App/BlurtiOS/BlurtiOSTests/WordsEditTests.swift @@ -0,0 +1,35 @@ +import AssemblyAI +import Testing + +@testable import BlurtiOSCore + +@Suite("Words tab edits") +struct WordsEditTests { + @Test("adding key terms appends them, splitting on commas and dropping duplicates of any case") + func addsKeyTerms() { + #expect(WordsEdit.addingKeyTerms("Neil Bisht, blurt", to: ["Blurt"]) == ["Blurt", "Neil Bisht"]) + #expect(WordsEdit.addingKeyTerms(" ", to: ["Blurt"]) == ["Blurt"]) + } + + @Test("key terms stop at the request's term cap, so nothing is kept that couldn't be sent") + func keyTermsStopAtTheCap() { + let full = (0.. Date: Fri, 2 Oct 2026 18:11:27 -0500 Subject: [PATCH 3/8] feat(ios): show lifetime dictation stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stats card at the top of the Dictate tab, as in the blurt-ios prototype: dictations, words, and time saved against the 36 wpm phone typing average, as serif numbers with mono units. Time saved shows in full and moves to its own row past an hour; every value takes the largest of four sizes that fits. Every dictation counts, from the app or the keyboard. The coordinator times the recording phase and records on the main actor when the dictation's terminal phase renders, so the spoken time is the right one and a reset can't race a record. Totals stay on the phone (DictationStats, BlurtiOSCore); Settings › Data resets them. Co-Authored-By: Claude Opus 5.5 --- App/BlurtiOS/BlurtDesign/DesignTokens.swift | 27 +++++ .../Sources/DictationCoordinator.swift | 36 +++++- App/BlurtiOS/BlurtiOS/Sources/HomeView.swift | 3 +- .../BlurtiOS/Sources/SettingsView.swift | 18 +++ App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift | 98 ++++++++++++++++ .../BlurtiOSCore/DictationStats.swift | 105 ++++++++++++++++++ .../BlurtiOSTests/DictationStatsTests.swift | 65 +++++++++++ App/BlurtiOS/DESIGN.md | 15 ++- App/BlurtiOS/Design/tokens.json | 36 ++++++ 9 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift create mode 100644 App/BlurtiOS/BlurtiOSCore/DictationStats.swift create mode 100644 App/BlurtiOS/BlurtiOSTests/DictationStatsTests.swift diff --git a/App/BlurtiOS/BlurtDesign/DesignTokens.swift b/App/BlurtiOS/BlurtDesign/DesignTokens.swift index bae82a97..bb240fa7 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. @@ -459,6 +469,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. @@ -638,6 +656,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 +728,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", @@ -750,6 +773,10 @@ 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", diff --git a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift index 2b5715e4..71147b1b 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..59f2477f 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift @@ -5,7 +5,7 @@ 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. @@ -40,6 +40,7 @@ struct HomeView: View { 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) diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index 37848d4b..b342eb13 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -19,6 +19,7 @@ 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 @AppStorage(EnhancedTranscriptsStore.defaultsKey) private var enhancedTranscripts = EnhancedTranscriptsStore.defaultValue @@ -29,6 +30,7 @@ struct SettingsView: View { listeningSection transcriptionSection accountSection + dataSection aboutSection } .brandForm() @@ -119,6 +121,22 @@ 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.") + } + .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 { Section { LabeledContent("Version", value: Self.version) diff --git a/App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift b/App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift new file mode 100644 index 00000000..c8bbe241 --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift @@ -0,0 +1,98 @@ +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() + + var body: some View { + let stats = DictationStats.decode(raw) + let saved = stats.timeSaved() + 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/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/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/DESIGN.md b/App/BlurtiOS/DESIGN.md index 65ad9778..3673857a 100644 --- a/App/BlurtiOS/DESIGN.md +++ b/App/BlurtiOS/DESIGN.md @@ -182,6 +182,10 @@ 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 | @@ -545,6 +549,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 +621,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 | @@ -702,7 +711,11 @@ accent over its replacement, × to remove, between hairlines; two inputs and disabled (`opacity/disabled`). **Home** (`HomeView`, `HomeHero`, `SetupCard`, `StyleChips`, `RecentSection`, -`HomeStatus` for the words): the setup card only while something is missing, +`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 headline (Not listening · Ready to dictate · Connecting… · Listening… · Transcribing… · Pasted · Copied · the error), the line under it, the voice diff --git a/App/BlurtiOS/Design/tokens.json b/App/BlurtiOS/Design/tokens.json index d4a90598..87cd602a 100644 --- a/App/BlurtiOS/Design/tokens.json +++ b/App/BlurtiOS/Design/tokens.json @@ -772,6 +772,26 @@ "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" } }, "type": { @@ -867,6 +887,22 @@ "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" } }, "motion": { From 640ffd489509439fdac50293bf45240de082dbf4 Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 18:11:27 -0500 Subject: [PATCH 4/8] style(sdk): keep KeyTerms.join's doc comment on its declaration Co-Authored-By: Claude Opus 5.5 --- Sources/AssemblyAI/Dictation/KeyTerms.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AssemblyAI/Dictation/KeyTerms.swift b/Sources/AssemblyAI/Dictation/KeyTerms.swift index 9dbbc246..c7eef65b 100644 --- a/Sources/AssemblyAI/Dictation/KeyTerms.swift +++ b/Sources/AssemblyAI/Dictation/KeyTerms.swift @@ -40,8 +40,8 @@ public enum KeyTerms { return result } - /// Terms as the comma-separated text `parse` reads back unchanged. // periphery:ignore - public for the iOS app (App/BlurtiOS), which the Mac scan doesn't index. + /// Terms as the comma-separated text `parse` reads back unchanged. public static func join(_ terms: [String]) -> String { terms.joined(separator: ", ") } From 419783af0ff492251d802d23aad0bf03023194e9 Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 18:30:09 -0500 Subject: [PATCH 5/8] fix(ios): drop the Words tab pointer from Settings Co-Authored-By: Claude Opus 5.5 --- App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index 37848d4b..3e19377e 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -106,7 +106,7 @@ struct SettingsView: View { } header: { Eyebrow("Transcription") } footer: { - Text("Enhanced transcripts clean up punctuation and wording. Key terms and text shortcuts are on the Words tab.") + Text("Enhanced transcripts clean up punctuation and wording.") } } From a54cb82ebaee52d5b02d83702df57ce3a6d9c281 Mon Sep 17 00:00:00 2001 From: James He Date: Wed, 14 Oct 2026 19:04:05 -0500 Subject: [PATCH 6/8] feat(ios): add the Easter Egg (#234) * 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 * 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 --------- Co-authored-by: Claude Opus 5.5 --- App/BlurtiOS/BlurtDesign/DesignTokens.swift | 45 +++ .../BlurtiOS/Sources/BlurtiOSApp.swift | 3 + .../BlurtiOS/Sources/SettingsView.swift | 28 +- 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, 608 insertions(+), 6 deletions(-) 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 5fb72257..78c9ff40 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 @@ -40,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) } @@ -130,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 { @@ -143,6 +148,19 @@ 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") } 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 27cd67a9209ad3e7aedb680cdacb0d923726173b Mon Sep 17 00:00:00 2001 From: James He Date: Wed, 14 Oct 2026 19:05:07 -0500 Subject: [PATCH 7/8] feat(ios): add a Words tab for key terms and text shortcuts (#232) * feat(ios): add a Words tab for key terms and text shortcuts The app gets Dictate and Words tabs under a brand tab bar, as in the blurt-ios prototype. MainView owns what works on either tab: Settings from each tab's gear, the consent sheet and shared key-term lists. Words, on the AssemblyAI SDK's KeyTerms and TextShortcut: - Key terms as pills with an inline add field (commas add several, duplicates drop, capped at the request's 100), the contact-name count, and Share. They move here from Settings. - Text shortcuts with an inline "When I say / Type this" form; re-adding a phrase replaces it. Expanded on the phone, never sent. BlurtDesign gains BrandTabBar, FlowLayout, brandInput() and brandFootnote(), and brand buttons fade while disabled. Co-Authored-By: Claude Opus 5.5 * fix(ios): drop the Words tab pointer from Settings Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 2 +- App/BlurtiOS/BlurtDesign/BrandTabBar.swift | 52 ++++ App/BlurtiOS/BlurtDesign/Chrome.swift | 33 +++ App/BlurtiOS/BlurtDesign/DesignTokens.swift | 35 ++- App/BlurtiOS/BlurtDesign/FlowLayout.swift | 52 ++++ .../BlurtiOS/Sources/BlurtiOSApp.swift | 3 +- App/BlurtiOS/BlurtiOS/Sources/HomeView.swift | 29 +- App/BlurtiOS/BlurtiOS/Sources/MainView.swift | 67 +++++ .../BlurtiOS/Sources/SettingsView.swift | 25 +- App/BlurtiOS/BlurtiOS/Sources/WordsView.swift | 247 ++++++++++++++++++ App/BlurtiOS/BlurtiOSCore/WordsEdit.swift | 34 +++ .../BlurtiOSTests/WordsEditTests.swift | 35 +++ App/BlurtiOS/DESIGN.md | 32 ++- App/BlurtiOS/Design/tokens.json | 46 +++- 14 files changed, 635 insertions(+), 57 deletions(-) create mode 100644 App/BlurtiOS/BlurtDesign/BrandTabBar.swift create mode 100644 App/BlurtiOS/BlurtDesign/FlowLayout.swift create mode 100644 App/BlurtiOS/BlurtiOS/Sources/MainView.swift create mode 100644 App/BlurtiOS/BlurtiOS/Sources/WordsView.swift create mode 100644 App/BlurtiOS/BlurtiOSCore/WordsEdit.swift create mode 100644 App/BlurtiOS/BlurtiOSTests/WordsEditTests.swift diff --git a/AGENTS.md b/AGENTS.md index 6c3f4c1a..1e3d8751 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ App/BlurtiOS/ the iPhone app and its keyboard (README.md there is SwiftUI, no audio; `package` access; coverage-gated (ios-test.sh) BlurtiOS/Sources/ the app: DictationCoordinator (composition root), ListeningWindow + WindowedAudioSource (the always-on capture, since iOS can't open - the mic from the background), HomeView and the other screens + the mic from the background), MainView (Dictate and Words tabs) and the screens BlurtKeyboard/Sources/ the keyboard's views: a remote control that inserts the words; three layouts (slim bar / panel / full keyboard) over BlurtiOSCore's model BlurtDesign/ the component library both targets link: the generated DesignTokens, diff --git a/App/BlurtiOS/BlurtDesign/BrandTabBar.swift b/App/BlurtiOS/BlurtDesign/BrandTabBar.swift new file mode 100644 index 00000000..5d619dce --- /dev/null +++ b/App/BlurtiOS/BlurtDesign/BrandTabBar.swift @@ -0,0 +1,52 @@ +import SwiftUI + +/// The brand's tab bar, in place of the system one: each tab's icon over its +/// mono uppercase label, the selected tab in the accent and the rest muted, a +/// hairline above. The tabs are the caller's; this only draws and selects. +package struct BrandTabBar: View { + let tabs: [Tab] + @Binding var selection: Tab + let label: (Tab) -> String + /// An SF Symbol; the selected tab draws its `.fill` variant. + let symbol: (Tab) -> String + + package init( + _ tabs: [Tab], selection: Binding, label: @escaping (Tab) -> String, symbol: @escaping (Tab) -> String + ) { + self.tabs = tabs + _selection = selection + self.label = label + self.symbol = symbol + } + + package var body: some View { + HStack(spacing: 0) { + ForEach(tabs) { tab in + let isSelected = tab == selection + Button { + selection = tab + } label: { + VStack(spacing: DesignTokens.Metrics.appTabGap) { + Image(systemName: isSelected ? symbol(tab) + ".fill" : symbol(tab)) + .font(.system(size: DesignTokens.Typography.sizeTabIcon, weight: DesignTokens.Typography.weightGlyph)) + Text(label(tab).uppercased()) + .font(BlurtType.mono(DesignTokens.Typography.sizeTabLabel, weight: .medium)) + .tracking(DesignTokens.Typography.trackingEyebrow) + } + .foregroundStyle(isSelected ? BlurtBrand.accent : BlurtBrand.muted) + .frame(maxWidth: .infinity, minHeight: DesignTokens.Metrics.appTabHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(label(tab)) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + } + } + .padding(.top, DesignTokens.Metrics.appLineGap) + .background(BlurtBrand.page) + .overlay(alignment: .top) { + Rectangle().fill(BlurtBrand.cardBorder).frame(height: DesignTokens.Metrics.cardBorder) + } + .animation(.easeInOut(duration: DesignTokens.Motion.colour), value: selection) + } +} diff --git a/App/BlurtiOS/BlurtDesign/Chrome.swift b/App/BlurtiOS/BlurtDesign/Chrome.swift index b9d9584b..5c82212c 100644 --- a/App/BlurtiOS/BlurtDesign/Chrome.swift +++ b/App/BlurtiOS/BlurtDesign/Chrome.swift @@ -90,10 +90,43 @@ package struct BrandButtonStyle: ButtonStyle { role == .secondary ? BlurtBrand.cardBorder : Color.clear, lineWidth: DesignTokens.Metrics.cardBorder) ) .opacity(configuration.isPressed ? DesignTokens.Metrics.opacityPressed : 1) + .modifier(FadedWhenDisabled()) .animation(.easeInOut(duration: DesignTokens.Motion.colour), value: configuration.isPressed) } } +/// A brand button that can't be pressed yet sits back rather than looking live. +/// A modifier because a `ButtonStyle` can't read the environment itself. +private struct FadedWhenDisabled: ViewModifier { + @Environment(\.isEnabled) private var isEnabled + + func body(content: Content) -> some View { + content.opacity(isEnabled ? 1 : DesignTokens.Metrics.opacityDisabled) + } +} + +extension View { + /// The brand's input: the page colour inside the card, 8 pt corners, the + /// card's hairline, the body face. + package func brandInput() -> some View { + font(BlurtType.body(DesignTokens.Typography.sizeBody)) + .foregroundStyle(BlurtBrand.text) + .padding(.horizontal, DesignTokens.Metrics.appChipPadX) + .padding(.vertical, DesignTokens.Metrics.appInputPadY) + .background(BlurtBrand.page, in: RoundedRectangle(cornerRadius: DesignTokens.Metrics.radiusInput)) + .overlay( + RoundedRectangle(cornerRadius: DesignTokens.Metrics.radiusInput) + .strokeBorder(BlurtBrand.cardBorder, lineWidth: DesignTokens.Metrics.cardBorder)) + } + + /// The brand's small print under a card: the caption size, muted. + package func brandFootnote() -> some View { + font(BlurtType.body(DesignTokens.Typography.sizeCaption)) + .foregroundStyle(BlurtBrand.muted) + .fixedSize(horizontal: false, vertical: true) + } +} + /// The lowercase wordmark from the Mac's ready screen, tinted with the accent. /// The image (`blurt-ready-logo.png`, shared from `Design/brand`) is the host /// bundle's resource, not the library's; without it the word is set in the diff --git a/App/BlurtiOS/BlurtDesign/DesignTokens.swift b/App/BlurtiOS/BlurtDesign/DesignTokens.swift index 1bde0c19..bae82a97 100644 --- a/App/BlurtiOS/BlurtDesign/DesignTokens.swift +++ b/App/BlurtiOS/BlurtDesign/DesignTokens.swift @@ -229,18 +229,32 @@ package enum DesignTokens { package nonisolated static let appHeroPad: CGFloat = 20 /// The header's gear, the copy glyph. package nonisolated static let appIcon: CGFloat = 20 + /// An app input's top and bottom (its sides are app/chip-pad-x). + package nonisolated static let appInputPadY: CGFloat = 10 /// Between lines of one thought. package nonisolated static let appLineGap: CGFloat = 6 /// The screens' side padding. package nonisolated static let appPagePad: CGFloat = 20 + /// A key-term pill's top and bottom, and its side beside the ×. + package nonisolated static let appPillPadY: CGFloat = 4 + /// The × on a key-term pill: its tap square. + package nonisolated static let appPillRemove: CGFloat = 24 /// Between the theme card's two faces. package nonisolated static let appPreviewGap: CGFloat = 8 + /// A list row's top and bottom inside a card. + package nonisolated static let appRowPadY: CGFloat = 8 + /// The × on a text-shortcut row: its tap square. + package nonisolated static let appRowRemove: CGFloat = 32 /// Between the home screen's sections. package nonisolated static let appSectionGap: CGFloat = 24 /// The setup steps' mono numerals. 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 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. + package nonisolated static let appTabHeight: CGFloat = 52 /// The card's hairline. package nonisolated static let cardBorder: CGFloat = 1 /// The app's cards (radius/card). @@ -307,7 +321,7 @@ package enum DesignTokens { package nonisolated static let marginSide: CGFloat = 6.5 /// The keyboard's top, and the slim bar's bottom. package nonisolated static let marginVertical: CGFloat = 8 - /// The + without Full Access. + /// The + without Full Access; a brand button that can't be pressed yet. package nonisolated static let opacityDisabled: CGFloat = 0.4 /// The cross glint when the words land. package nonisolated static let opacityGlint: CGFloat = 0.95 @@ -439,8 +453,16 @@ package enum DesignTokens { package nonisolated static let sizeLegend: CGFloat = 16 /// Letter keys: the iPhone's (estimated from the l glyph, apple-geometry.json). package nonisolated static let sizeLetter: CGFloat = 24 + /// The × on a key-term pill. + package nonisolated static let sizePillGlyph: CGFloat = 10 /// The letter pop-up. package nonisolated static let sizePopup: CGFloat = 32 + /// The × on a text-shortcut row. + package nonisolated static let sizeRemoveGlyph: CGFloat = 12 + /// A tab's icon. + package nonisolated static let sizeTabIcon: CGFloat = 18 + /// A tab's mono label. + package nonisolated static let sizeTabLabel: CGFloat = 11 /// The key-term field. package nonisolated static let sizeTerm: CGFloat = 17 /// The app's serif headline. @@ -605,12 +627,19 @@ package enum DesignTokens { "metrics.app/hero-gap": "14", "metrics.app/hero-pad": "20", "metrics.app/icon": "20", + "metrics.app/input-pad-y": "10", "metrics.app/line-gap": "6", "metrics.app/page-pad": "20", + "metrics.app/pill-pad-y": "4", + "metrics.app/pill-remove": "24", "metrics.app/preview-gap": "8", + "metrics.app/row-pad-y": "8", + "metrics.app/row-remove": "32", "metrics.app/section-gap": "24", "metrics.app/setup-number-width": "24", "metrics.app/stack-gap": "12", + "metrics.app/tab-gap": "4", + "metrics.app/tab-height": "52", "metrics.card/border": "1", "metrics.card/radius": "12", "metrics.caret/height": "20", @@ -718,7 +747,11 @@ package enum DesignTokens { "type.size/label": "12", "type.size/legend": "16", "type.size/letter": "24", + "type.size/pill-glyph": "10", "type.size/popup": "32", + "type.size/remove-glyph": "12", + "type.size/tab-icon": "18", + "type.size/tab-label": "11", "type.size/term": "17", "type.size/title": "34", "type.tracking/cta": "1.4", diff --git a/App/BlurtiOS/BlurtDesign/FlowLayout.swift b/App/BlurtiOS/BlurtDesign/FlowLayout.swift new file mode 100644 index 00000000..0a1ecb76 --- /dev/null +++ b/App/BlurtiOS/BlurtDesign/FlowLayout.swift @@ -0,0 +1,52 @@ +import SwiftUI + +/// Lays its children out left to right, wrapping onto a new line when the next +/// one won't fit — the key-term pills. +package struct FlowLayout: Layout { + var spacing: CGFloat + + package init(spacing: CGFloat = DesignTokens.Metrics.appChipGap) { self.spacing = spacing } + + package func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let rows = rows(for: subviews, width: proposal.width ?? .infinity) + let height = rows.map(\.height).reduce(0, +) + spacing * CGFloat(max(rows.count - 1, 0)) + return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: height) + } + + package func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var y = bounds.minY + for row in rows(for: subviews, width: bounds.width) { + var x = bounds.minX + for index in row.indices { + let size = subviews[index].sizeThatFits(.unspecified) + subviews[index].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing + } + y += row.height + spacing + } + } + + private struct Row { + var indices: [Int] = [] + var width: CGFloat = 0 + var height: CGFloat = 0 + } + + private func rows(for subviews: Subviews, width: CGFloat) -> [Row] { + var rows: [Row] = [] + var row = Row() + for index in subviews.indices { + let size = subviews[index].sizeThatFits(.unspecified) + let needed = row.indices.isEmpty ? size.width : row.width + spacing + size.width + if needed > width, !row.indices.isEmpty { + rows.append(row) + row = Row() + } + row.width = row.indices.isEmpty ? size.width : row.width + spacing + size.width + row.height = max(row.height, size.height) + row.indices.append(index) + } + if !row.indices.isEmpty { rows.append(row) } + return rows + } +} diff --git a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift index a24e3de0..9eb84874 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift @@ -60,8 +60,7 @@ struct BlurtiOSApp: App { } private var home: some View { - HomeView(coordinator: coordinator) - .onOpenURL { coordinator.handle($0) } + MainView(coordinator: coordinator) .task { await start() } } diff --git a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift index 168af9ff..d6abf538 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift @@ -4,18 +4,19 @@ import BlurtEngine import BlurtiOSCore import SwiftUI -/// The app's one screen: the Blurt landing page on a phone. The wordmark and +/// 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 /// 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. struct HomeView: View { var coordinator: DictationCoordinator + /// Opens Settings, which `MainView` presents over either tab. + let showSettings: () -> Void @Environment(\.scenePhase) private var scenePhase @State private var microphoneGranted = AVAudioApplication.shared.recordPermission == .granted @State private var keyboardSeen = SharedStore.keyboardEverSeen @State private var showsKeyEntry = false - @State private var showsSettings = 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 @@ -52,21 +53,6 @@ struct HomeView: View { .environment(\.voiceElementKind, VoiceElementKind(rawValue: voiceRaw) ?? .shipped) .toolbar(.hidden, for: .navigationBar) .sheet(isPresented: $showsKeyEntry) { KeyEntryView(apiKey: coordinator.apiKey) } - .sheet(isPresented: $showsSettings) { SettingsView(coordinator: coordinator) } - .sheet(isPresented: Binding(get: { coordinator.needsConsent }, set: { coordinator.needsConsent = $0 })) { - ConsentView { Task { await coordinator.grantConsent() } } - } - .sheet(item: Binding(get: { coordinator.pendingTermPack }, set: { coordinator.pendingTermPack = $0 })) { - ImportTermsView(pack: $0) - } - .alert( - "Couldn't read that list", - isPresented: Binding(get: { coordinator.termPackUnreadable }, set: { coordinator.termPackUnreadable = $0 }) - ) { - Button("OK") {} - } message: { - Text("It isn't a Blurt key-term list, or it's too big.") - } .onChange(of: scenePhase) { _, phase in if phase == .active { refreshStatus() } } @@ -82,14 +68,7 @@ struct HomeView: View { HStack { Wordmark() Spacer() - Button { - showsSettings = true - } label: { - Image(systemName: "gearshape") - .font(.system(size: DesignTokens.Metrics.appIcon, weight: DesignTokens.Typography.weightGlyph)) - .foregroundStyle(BlurtBrand.muted) - } - .accessibilityLabel("Settings") + SettingsButton(action: showSettings) } .frame(height: DesignTokens.Metrics.appHeaderHeight) } diff --git a/App/BlurtiOS/BlurtiOS/Sources/MainView.swift b/App/BlurtiOS/BlurtiOS/Sources/MainView.swift new file mode 100644 index 00000000..ac615bf4 --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/MainView.swift @@ -0,0 +1,67 @@ +import BlurtDesign +import BlurtiOSCore +import SwiftUI + +/// 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 +/// key-term list arriving from Messages. +struct MainView: View { + enum Tab: CaseIterable, Identifiable { + case dictate, words + + var id: Self { self } + + var label: String { + switch self { + case .dictate: "Dictate" + case .words: "Words" + } + } + + var symbol: String { + switch self { + case .dictate: "mic" + case .words: "character.book.closed" + } + } + } + + var coordinator: DictationCoordinator + @State private var tab = Tab.dictate + @State private var showsSettings = false + + var body: some View { + Group { + switch tab { + case .dictate: HomeView(coordinator: coordinator) { showsSettings = true } + case .words: WordsView(coordinator: coordinator) { showsSettings = true } + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + BrandTabBar(Tab.allCases, selection: $tab, label: \.label, symbol: \.symbol) + } + .sheet(isPresented: $showsSettings) { SettingsView(coordinator: coordinator) } + .sheet(isPresented: Binding(get: { coordinator.needsConsent }, set: { coordinator.needsConsent = $0 })) { + ConsentView { Task { await coordinator.grantConsent() } } + } + .sheet(item: Binding(get: { coordinator.pendingTermPack }, set: { coordinator.pendingTermPack = $0 })) { + ImportTermsView(pack: $0) + } + .alert( + "Couldn't read that list", + isPresented: Binding(get: { coordinator.termPackUnreadable }, set: { coordinator.termPackUnreadable = $0 }) + ) { + Button("OK") {} + } message: { + Text("It isn't a Blurt key-term list, or it's too big.") + } + // The keyboard's `blurt://start` is about the listening window, which the + // Dictate tab shows; a key-term list lands on Words, where it's added. + .onOpenURL { url in + tab = TermPack.looksLikePack(url) ? .words : .dictate + coordinator.handle(url) + } + .tint(BlurtBrand.accent) + } +} diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index f6bcb265..3e19377e 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -1,4 +1,3 @@ -import AssemblyAI import BlurtDesign import BlurtEngine import BlurtiOSCore @@ -7,7 +6,7 @@ import SwiftUI /// Everything adjustable, grouped the way the Mac's Settings are: the keyboard /// (layout, theme, hands-free), listening, transcription, the account, and /// about — a form on the brand's page, its sections under mono eyebrows. -/// Presented from the home screen's gear. +/// Presented from either tab's gear. struct SettingsView: View { var coordinator: DictationCoordinator @Environment(\.dismiss) private var dismiss @@ -20,7 +19,6 @@ struct SettingsView: View { @AppStorage(BlurtShared.Key.voiceElement, store: SharedStore.defaults) private var voiceRaw = VoiceElementKind.shipped.rawValue @State private var showsKeyEntry = false - @AppStorage(SharedStore.keyTermsKey, store: SharedStore.defaults) private var keyTerms = "" @AppStorage(EnhancedTranscriptsStore.defaultsKey) private var enhancedTranscripts = EnhancedTranscriptsStore.defaultValue @@ -105,24 +103,10 @@ struct SettingsView: View { Section { Toggle("Enhanced transcripts", isOn: $enhancedTranscripts) NavigationLink("Output styles") { StylesView() } - TextField("Key terms, comma-separated", text: $keyTerms, axis: .vertical) - .autocorrectionDisabled() - LabeledContent("Key terms", value: "\(KeyTerms.parse(keyTerms).count)") - if !KeyTerms.parse(keyTerms).isEmpty { - ShareLink( - item: termPack, subject: Text("Blurt key terms"), message: Text(termPack.plainText), - preview: SharePreview(termPack.name, icon: Image(systemName: "text.badge.plus")) - ) { - Label("Share key terms…", systemImage: "square.and.arrow.up") - } - } } header: { Eyebrow("Transcription") } footer: { - Text( - "Enhanced transcripts clean up punctuation and wording. Key terms are names and jargon to spell right — " - + "add one from the keyboard with the + beside the orb, share the list with a group chat so everyone's " - + "dictation gets the names right; \(coordinator.lexiconNameCount) contact names come along automatically.") + Text("Enhanced transcripts clean up punctuation and wording.") } } @@ -146,11 +130,6 @@ struct SettingsView: View { } } - /// The whole list, for a friend: a `.blurtterms` file plus the words as text. - private var termPack: TermPack { - TermPack(name: "Key terms", from: nil, terms: KeyTerms.parse(keyTerms)) - } - private static var version: String { let short = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?" let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?" diff --git a/App/BlurtiOS/BlurtiOS/Sources/WordsView.swift b/App/BlurtiOS/BlurtiOS/Sources/WordsView.swift new file mode 100644 index 00000000..3545648c --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/WordsView.swift @@ -0,0 +1,247 @@ +import AssemblyAI +import BlurtDesign +import BlurtEngine +import BlurtiOSCore +import SwiftUI + +/// The Words tab: key terms the speech model should spell exactly, and text +/// shortcuts that turn a spoken phrase into longer text. Both apply to every +/// dictation, from the app and from the keyboard, as on Blurt for Mac. +struct WordsView: View { + var coordinator: DictationCoordinator + let showSettings: () -> Void + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appSectionGap) { + HStack { + Text("Words") + .font(BlurtType.heading(DesignTokens.Typography.sizeTitle)) + .foregroundStyle(BlurtBrand.text) + .accessibilityAddTraits(.isHeader) + Spacer() + SettingsButton(action: showSettings) + } + .frame(height: DesignTokens.Metrics.appHeaderHeight) + KeyTermsCard(contactNames: coordinator.lexiconNameCount) + TextShortcutsCard() + } + .padding(.horizontal, DesignTokens.Metrics.appPagePad) + .padding(.bottom, DesignTokens.Metrics.appSectionGap) + } + .scrollDismissesKeyboard(.interactively) + .page() + } +} + +/// The gear in each tab's header, which opens Settings. +struct SettingsButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "gearshape") + .font(.system(size: DesignTokens.Metrics.appIcon, weight: DesignTokens.Typography.weightGlyph)) + .foregroundStyle(BlurtBrand.muted) + } + .accessibilityLabel("Settings") + } +} + +/// The key terms as the design system's pills, a field to add more, and the +/// list's share link. They live in the App Group, where the keyboard's + adds +/// one on the spot, so this reads and writes the shared slot. +private struct KeyTermsCard: View { + let contactNames: Int + @AppStorage(SharedStore.keyTermsKey, store: SharedStore.defaults) private var raw = "" + @State private var draft = "" + + private var terms: [String] { KeyTerms.parse(raw) } + + var body: some View { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appStackGap) { + Eyebrow("Key terms") + if !terms.isEmpty { + FlowLayout { + ForEach(terms, id: \.self) { term in + TermPill(term: term) { SharedStore.keyTerms = terms.filter { $0 != term } } + } + } + } + HStack(spacing: DesignTokens.Metrics.appChipGap) { + TextField("Add a name or product term", text: $draft) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.done) + .onSubmit(add) + .brandInput() + Button("Add", action: add) + .buttonStyle(BrandButtonStyle()) + .fixedSize() + .disabled(KeyTerms.parse(draft).isEmpty || terms.count >= KeyTerms.termCap) + } + Text( + "Spelled exactly as written in every dictation. Separate several with commas, or add one from the " + + "keyboard with the + beside the mic. \(terms.count) of \(KeyTerms.termCap)." + + (contactNames > 0 ? " Your \(contactNames) contact names come along automatically." : "") + ) + .brandFootnote() + if !terms.isEmpty { + ShareLink( + item: pack, subject: Text("Blurt key terms"), message: Text(pack.plainText), + preview: SharePreview(pack.name, icon: Image(systemName: "text.badge.plus")) + ) { + Label("Share key terms", systemImage: "square.and.arrow.up") + .font(BlurtType.body(DesignTokens.Typography.sizeCaption, bold: true)) + } + .tint(BlurtBrand.accent) + } + } + .padding(DesignTokens.Metrics.appCardPad) + .frame(maxWidth: .infinity, alignment: .leading) + .card() + } + + /// The whole list, for a friend: a `.blurtterms` file plus the words as text. + private var pack: TermPack { TermPack(name: "Key terms", from: nil, terms: terms) } + + private func add() { + let updated = WordsEdit.addingKeyTerms(draft, to: terms) + guard updated != terms else { return } + SharedStore.keyTerms = updated + draft = "" + } +} + +/// A key term as the design system's pill: full radius, the page's fill inside +/// the card, a hairline, the body face. Its × removes it. +private struct TermPill: View { + let term: String + let remove: () -> Void + + var body: some View { + HStack(spacing: DesignTokens.Metrics.appLineGap) { + Text(term) + .font(BlurtType.body(DesignTokens.Typography.sizeCaption)) + .foregroundStyle(BlurtBrand.text) + Button(action: remove) { + Image(systemName: "xmark") + .font(.system(size: DesignTokens.Typography.sizePillGlyph, weight: .bold)) + .foregroundStyle(BlurtBrand.muted) + .frame(width: DesignTokens.Metrics.appPillRemove, height: DesignTokens.Metrics.appPillRemove) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Remove \(term)") + } + .padding(.leading, DesignTokens.Metrics.appChipPadX) + .padding(.trailing, DesignTokens.Metrics.appPillPadY) + .padding(.vertical, DesignTokens.Metrics.appPillPadY) + .background(BlurtBrand.page, in: Capsule()) + .overlay(Capsule().strokeBorder(BlurtBrand.cardBorder, lineWidth: DesignTokens.Metrics.cardBorder)) + } +} + +/// The text shortcuts, each with its ×, over two fields that add one. The +/// session expands them on the phone (`TextShortcutExpander`), so nothing here +/// goes to AssemblyAI. The store owns the JSON: this binds the raw slot only to +/// redraw, and writes through `TextShortcutStore`. +private struct TextShortcutsCard: View { + @AppStorage(TextShortcutStore.defaultsKey) private var raw = "" + @State private var trigger = "" + @State private var expansion = "" + @FocusState private var focus: Field? + private let store = TextShortcutStore() + + private enum Field { case trigger, expansion } + + private var shortcuts: [TextShortcut] { store.shortcuts(decoding: raw) } + + var body: some View { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appStackGap) { + Eyebrow("Text shortcuts") + if !shortcuts.isEmpty { + VStack(spacing: 0) { + ForEach(Array(shortcuts.enumerated()), id: \.element.id) { index, shortcut in + if index > 0 { Rectangle().fill(BlurtBrand.cardBorder).frame(height: DesignTokens.Metrics.cardBorder) } + ShortcutRow(shortcut: shortcut) { store.shortcuts = shortcuts.filter { $0.id != shortcut.id } } + } + } + } + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appChipGap) { + TextField("When I say… (my email)", text: $trigger) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.next) + .focused($focus, equals: .trigger) + .onSubmit { focus = .expansion } + .onChange(of: trigger) { _, value in + if value.count > TextShortcutStore.triggerLimit { + trigger = String(value.prefix(TextShortcutStore.triggerLimit)) + } + } + .brandInput() + TextField("Type this (me@example.com)", text: $expansion, axis: .vertical) + .lineLimit(1...4) // literal-ok: the field's rows + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focus, equals: .expansion) + .onChange(of: expansion) { _, value in + if value.count > TextShortcutStore.expansionLimit { + expansion = String(value.prefix(TextShortcutStore.expansionLimit)) + } + } + .brandInput() + Button("Add shortcut", action: add) + .buttonStyle(BrandButtonStyle()) + .disabled(!canAdd) + } + Text("Say the phrase anywhere in a dictation and Blurt types the full text instead. They stay on this phone.") + .brandFootnote() + } + .padding(DesignTokens.Metrics.appCardPad) + .frame(maxWidth: .infinity, alignment: .leading) + .card() + } + + private var canAdd: Bool { + WordsEdit.canAddShortcut(trigger: trigger, expansion: expansion) + && shortcuts.count < TextShortcutStore.shortcutLimit + } + + private func add() { + guard canAdd else { return } + store.shortcuts = WordsEdit.addingShortcut(trigger: trigger, expansion: expansion, to: shortcuts) + trigger = "" + expansion = "" + focus = nil + } +} + +private struct ShortcutRow: View { + let shortcut: TextShortcut + let remove: () -> Void + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Metrics.appStackGap) { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appLineGap) { + Eyebrow(shortcut.trigger, color: BlurtBrand.accent) + Text(shortcut.expansion) + .font(BlurtType.body(DesignTokens.Typography.sizeCaption)) + .foregroundStyle(BlurtBrand.text) + .lineLimit(2) // literal-ok: two lines of the replacement + } + Spacer(minLength: 0) + Button(action: remove) { + Image(systemName: "xmark") + .font(.system(size: DesignTokens.Typography.sizeRemoveGlyph, weight: .semibold)) + .foregroundStyle(BlurtBrand.muted) + .frame(width: DesignTokens.Metrics.appRowRemove, height: DesignTokens.Metrics.appRowRemove) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Remove \(shortcut.trigger)") + } + .padding(.vertical, DesignTokens.Metrics.appRowPadY) + } +} diff --git a/App/BlurtiOS/BlurtiOSCore/WordsEdit.swift b/App/BlurtiOS/BlurtiOSCore/WordsEdit.swift new file mode 100644 index 00000000..1f1d403c --- /dev/null +++ b/App/BlurtiOS/BlurtiOSCore/WordsEdit.swift @@ -0,0 +1,34 @@ +import AssemblyAI +import Foundation + +/// The Words tab's edits, apart from its views: what adding a key term or a +/// text shortcut does to the list it joins. The lists' own rules — parsing and +/// the request's caps (`KeyTerms`), a shortcut's match key (`TextShortcut`) — +/// are the AssemblyAI SDK's; the stores' limits are the engine's. +package nonisolated enum WordsEdit { + /// `terms` with whatever `draft` holds added at the end: one term, or several + /// separated by commas. Duplicates of a term already there drop + /// case-insensitively, and the list stops at the request's term cap, so a + /// term that couldn't be sent isn't kept either. + package static func addingKeyTerms(_ draft: String, to terms: [String]) -> [String] { + Array(KeyTerms.parse(KeyTerms.join(terms + KeyTerms.parse(draft))).prefix(KeyTerms.termCap)) + } + + /// Whether a shortcut from these fields could be saved: a phrase with a + /// letter or digit in it, and a replacement that isn't blank. + package static func canAddShortcut(trigger: String, expansion: String) -> Bool { + !TextShortcut.matchKey(for: trigger).isEmpty + && !expansion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// `shortcuts` with a new one at the top. A phrase already there — by match + /// key, so "my email" and "My-Email" are one — gets the new replacement + /// rather than a second entry the expander could never apply. + package static func addingShortcut(trigger: String, expansion: String, to shortcuts: [TextShortcut]) + -> [TextShortcut] + { + let key = TextShortcut.matchKey(for: trigger) + return [TextShortcut(trigger: trigger, expansion: expansion)] + + shortcuts.filter { TextShortcut.matchKey(for: $0.trigger) != key } + } +} diff --git a/App/BlurtiOS/BlurtiOSTests/WordsEditTests.swift b/App/BlurtiOS/BlurtiOSTests/WordsEditTests.swift new file mode 100644 index 00000000..95a290d3 --- /dev/null +++ b/App/BlurtiOS/BlurtiOSTests/WordsEditTests.swift @@ -0,0 +1,35 @@ +import AssemblyAI +import Testing + +@testable import BlurtiOSCore + +@Suite("Words tab edits") +struct WordsEditTests { + @Test("adding key terms appends them, splitting on commas and dropping duplicates of any case") + func addsKeyTerms() { + #expect(WordsEdit.addingKeyTerms("Neil Bisht, blurt", to: ["Blurt"]) == ["Blurt", "Neil Bisht"]) + #expect(WordsEdit.addingKeyTerms(" ", to: ["Blurt"]) == ["Blurt"]) + } + + @Test("key terms stop at the request's term cap, so nothing is kept that couldn't be sent") + func keyTermsStopAtTheCap() { + let full = (0.. Date: Wed, 14 Oct 2026 19:16:30 -0500 Subject: [PATCH 8/8] feat(ios): open on a setup screen, then keep setup in Settings (#236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * feat(ios): open on a setup screen, then keep setup in Settings 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 --------- 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 78c9ff40..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 @@ -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