Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ struct BlurtiOSApp: App {
subsystem: "com.assemblyai.blurt.iphone",
keychainService: "blurt-ios",
defaultsPrefix: "Blurt",
logDirectoryName: "Blurt"))
logDirectoryName: "Blurt",
// Styles live in a scrolling list here, not the Mac's five-button
// switcher, so the phone allows far more than its four.
styleProfileLimit: 100))
// The unit tests are hosted by this app: they get a coordinator that
// touches neither the Keychain nor the App Group (the Mac's UITestSupport
// does the same for its harness), and `start()` below never runs.
Expand Down
181 changes: 157 additions & 24 deletions App/BlurtiOS/BlurtiOS/Sources/StylesView.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import BlurtDesign
import BlurtEngine
import BlurtiOSCore
import SwiftUI

/// Output styles: Default (the engine's base cleanup) plus up to four named
/// profiles whose instructions ride the cleanup rewrite. The store owns the
/// JSON and the active-style rule (`StyleProfileStore`); this view binds to the
/// raw defaults slots only to redraw when they change, and writes through the
/// store, the same discipline as the Mac's style row.
/// Output styles: Default (the engine's base cleanup) plus the user's named
/// profiles (up to `StyleProfileStore.limit`, 100 on the phone) whose
/// instructions ride the cleanup rewrite. Three ways in, each
/// one tap: use a style (tap its row), edit one (its pencil), or add one, from
/// a preset (`StylePreset`) or from scratch.
/// The store owns the JSON and the active-style rule (`StyleProfileStore`);
/// this view binds to the raw defaults slots only to redraw when they change,
/// and writes through the store, the same discipline as the Mac's style row.
struct StylesView: View {
@AppStorage(StyleProfileStore.defaultsKey) private var profilesRaw = ""
@AppStorage(StyleProfileStore.activeDefaultsKey) private var activeRaw = ""
Expand All @@ -16,63 +20,144 @@ struct StylesView: View {

private var profiles: [StyleProfile] { store.profiles(decoding: profilesRaw) }
private var active: StyleProfile? { StyleProfileStore.active(in: profiles, id: activeRaw) }
private var isFull: Bool { profiles.count >= StyleProfileStore.limit }

var body: some View {
List {
Section {
StyleRow(
name: StyleProfileStore.defaultStyleName, detail: "Filler out, punctuation fixed, your words kept.",
isActive: active == nil
isActive: active == nil, edit: nil
) { store.activateDefault() }
ForEach(profiles) { profile in
StyleRow(name: profile.name, detail: profile.instructions, isActive: active?.id == profile.id) {
store.activate(profile)
}
.swipeActions {
Button("Edit") { editing = profile }.tint(BlurtBrand.accent)
}
StyleRow(
name: profile.name, detail: profile.instructions, isActive: active?.id == profile.id,
edit: { editing = profile }, activate: { store.activate(profile) })
}
} header: {
Eyebrow("Styles")
Eyebrow("Your styles")
} footer: {
Text("Tap a style to use it. Swipe a style to edit it. Up to \(StyleProfileStore.profileLimit).")
Text("Tap a style to use it, or the pencil to change it.")
}
Section {
Button("Add style") { adding = true }
.disabled(profiles.count >= StyleProfileStore.profileLimit)
ForEach(StylePreset.allCases) { preset in
PresetRow(preset: preset, isAdded: preset.added(in: profiles) != nil, isFull: isFull) {
add(preset)
}
}
} header: {
Eyebrow("Start from a preset")
} footer: {
Text(
isFull
? "You have \(StyleProfileStore.limit) styles, the most there can be. Delete one to add another."
: "Adds it to your styles and switches to it. Edit it any time.")
}
}
.brandForm()
// Pinned under the list, always in reach, rather than a row the list's
// card shape would clip.
.safeAreaInset(edge: .bottom) {
Button {
adding = true
} label: {
Label("New style", systemImage: "plus")
}
.buttonStyle(BrandButtonStyle())
.disabled(isFull)
.padding(DesignTokens.Metrics.appPagePad)
.background(BlurtBrand.page)
}
.navigationTitle("Output styles")
.sheet(isPresented: $adding) { StyleEditor(profile: nil, store: store) }
.sheet(item: $editing) { StyleEditor(profile: $0, store: store) }
}

/// One tap: the preset joins the user's styles (unless it already did) and
/// becomes the active one.
private func add(_ preset: StylePreset) {
guard let result = preset.adding(to: store.profiles) else { return }
store.profiles = result.profiles
store.activate(result.style)
}
}

/// A style: tap to use it; its pencil (a style of the user's own) edits it.
private struct StyleRow: View {
let name: String
let detail: String
let isActive: Bool
let edit: (() -> Void)?
let activate: () -> Void

var body: some View {
Button(action: activate) {
HStack {
HStack(spacing: DesignTokens.Metrics.appStackGap) {
Button(action: activate) {
HStack {
VStack(alignment: .leading, spacing: DesignTokens.Metrics.appLineGap) {
Text(name).foregroundStyle(BlurtBrand.text)
Text(detail).font(BlurtType.body(DesignTokens.Typography.sizeCaption)).foregroundStyle(BlurtBrand.muted)
.lineLimit(2) // literal-ok: two lines of instructions
}
Spacer()
if isActive { Image(systemName: "checkmark").foregroundStyle(BlurtBrand.accent) }
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityAddTraits(isActive ? .isSelected : [])
if let edit {
Button(action: edit) {
Image(systemName: "pencil")
.font(.system(size: DesignTokens.Typography.sizeGlyph, weight: DesignTokens.Typography.weightGlyph))
.foregroundStyle(BlurtBrand.accent)
}
.buttonStyle(.borderless)
.accessibilityLabel("Edit \(name)")
}
}
}
}

/// A preset: its icon, name and a line of what it does; tap to add it.
private struct PresetRow: View {
let preset: StylePreset
let isAdded: Bool
let isFull: Bool
let add: () -> Void

var body: some View {
Button(action: add) {
HStack(spacing: DesignTokens.Metrics.appStackGap) {
Image(systemName: preset.symbol)
.font(.system(size: DesignTokens.Typography.sizeGlyph, weight: DesignTokens.Typography.weightGlyph))
.foregroundStyle(BlurtBrand.accent)
.frame(width: DesignTokens.Metrics.appIcon)
VStack(alignment: .leading, spacing: DesignTokens.Metrics.appLineGap) {
Text(name).foregroundStyle(BlurtBrand.text)
Text(detail).font(BlurtType.body(DesignTokens.Typography.sizeCaption)).foregroundStyle(BlurtBrand.muted)
.lineLimit(2) // literal-ok: two lines of instructions
Text(preset.name).foregroundStyle(BlurtBrand.text)
Text(preset.instructions).font(BlurtType.body(DesignTokens.Typography.sizeCaption))
.foregroundStyle(BlurtBrand.muted)
.lineLimit(1) // literal-ok: a line of the preset's instructions
}
Spacer()
if isActive { Image(systemName: "checkmark").foregroundStyle(BlurtBrand.accent) }
if isAdded {
Label("Added", systemImage: "checkmark").labelStyle(.iconOnly).foregroundStyle(BlurtBrand.accent)
} else {
Eyebrow("Add", color: BlurtBrand.accent)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!isAdded && isFull)
.accessibilityLabel(isAdded ? "\(preset.name), added" : "Add \(preset.name)")
}
}

/// Name plus instructions, with the same caps the Mac's editor enforces: 24
/// characters of name, and instructions trimmed to the bytes the API's
/// instruction cap leaves once the base cleanup text has taken its share.
/// instruction cap leaves once the base cleanup text has taken its share. A
/// new style can start from a preset, which fills both fields to edit.
private struct StyleEditor: View {
let profile: StyleProfile?
let store: StyleProfileStore
Expand All @@ -95,6 +180,23 @@ private struct StyleEditor: View {
var body: some View {
NavigationStack {
Form {
if profile == nil {
Section {
// The three share the row in equal widths, so none runs off it.
HStack(spacing: DesignTokens.Metrics.appChipGap) {
ForEach(StylePreset.allCases) { preset in
PresetChip(preset: preset, isChosen: instructions == preset.instructions) {
name = preset.name
instructions = preset.instructions
}
}
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
} header: {
Eyebrow("Start from")
}
}
Section {
TextField("e.g. Casual", text: $name)
.onChange(of: name) { _, value in
Expand All @@ -115,7 +217,7 @@ private struct StyleEditor: View {
} header: {
Eyebrow("Instructions")
} footer: {
Text("\(instructions.utf8.count)/\(StyleProfileStore.characterLimit)")
Text("Added on top of the built-in cleanup. \(instructions.utf8.count)/\(StyleProfileStore.characterLimit)")
}
if profile != nil {
Section {
Expand All @@ -128,6 +230,7 @@ private struct StyleEditor: View {
}
.brandForm()
.navigationTitle(profile == nil ? "New style" : "Edit style")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .confirmationAction) { Button("Save") { save() }.disabled(!canSave) }
Expand All @@ -150,3 +253,33 @@ private struct StyleEditor: View {
dismiss()
}
}

/// A preset as a chip in the editor: its icon and name, filled with the accent
/// while the fields hold its text.
private struct PresetChip: View {
let preset: StylePreset
let isChosen: Bool
let choose: () -> Void

var body: some View {
Button(action: choose) {
HStack(spacing: DesignTokens.Metrics.appLineGap) {
Image(systemName: preset.symbol)
Text(preset.name).lineLimit(1)
}
.font(BlurtType.body(DesignTokens.Typography.sizeCaption, bold: true))
.padding(.vertical, DesignTokens.Metrics.appChipPadY)
.frame(maxWidth: .infinity)
.foregroundStyle(isChosen ? BlurtBrand.ctaText : BlurtBrand.text)
.background(
isChosen ? BlurtBrand.cta : BlurtBrand.cardFill,
in: RoundedRectangle(cornerRadius: DesignTokens.Metrics.radiusButton)
)
.overlay(
RoundedRectangle(cornerRadius: DesignTokens.Metrics.radiusButton)
.strokeBorder(BlurtBrand.cardBorder, lineWidth: DesignTokens.Metrics.cardBorder))
}
.buttonStyle(.plain)
.accessibilityAddTraits(isChosen ? .isSelected : [])
}
}
56 changes: 56 additions & 0 deletions App/BlurtiOS/BlurtiOSCore/StylePreset.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import BlurtEngine
import Foundation

/// Ready-made output styles (Professional, Casual, Ultracasual): a starting
/// point a user adds in one tap and can then edit like any style of their own. Each
/// becomes an ordinary `StyleProfile` (its instructions ride the cleanup
/// rewrite on top of the base instruction), so nothing downstream knows it
/// began as a preset.
///
/// The wording is a starting point, not eval-scored like the base instruction;
/// run it through `evals/dictation-prompt` before treating it as tuned.
package nonisolated enum StylePreset: String, CaseIterable, Identifiable, Sendable {
case professional = "Professional"
case casual = "Casual"
case ultracasual = "Ultracasual"

package var id: String { rawValue }
package var name: String { rawValue }

package var symbol: String {
switch self {
case .professional: "briefcase"
case .casual: "cup.and.saucer"
case .ultracasual: "face.smiling"
}
}

package var instructions: String {
switch self {
case .professional:
"Use a clear, polished, professional tone suitable for work email. Write complete sentences "
+ "with standard capitalization and punctuation. No slang and no emojis."
case .casual:
"Use a relaxed, friendly tone, like a message to a coworker you get along with. Contractions "
+ "are fine. Keep standard capitalization and punctuation. No emojis."
case .ultracasual:
"Write like a quick text to a close friend: all lowercase, light punctuation, and no period "
+ "at the end. Keep it short and natural. An emoji is fine when it fits."
}
}

/// The user's style this preset already became, if any: matched by name,
/// whatever its instructions have been edited to since.
package func added(in profiles: [StyleProfile]) -> StyleProfile? {
profiles.first { $0.name.compare(name, options: .caseInsensitive) == .orderedSame }
}

/// `profiles` with this preset added at the end, and the style it became;
/// the existing one when it was already added. Nil when the list is full.
package func adding(to profiles: [StyleProfile]) -> (profiles: [StyleProfile], style: StyleProfile)? {
if let existing = added(in: profiles) { return (profiles, existing) }
guard profiles.count < StyleProfileStore.limit else { return nil }
let style = StyleProfile(name: name, instructions: instructions)
return (profiles + [style], style)
}
}
39 changes: 39 additions & 0 deletions App/BlurtiOS/BlurtiOSTests/StylePresetTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import BlurtEngine
import Testing

@testable import BlurtiOSCore

@Suite("Style presets")
struct StylePresetTests {
@Test("the phone allows 100 styles, and the three presets fit the store's caps")
func fitTheCaps() {
// The test host is the app, which configures the phone's limit at launch.
#expect(StyleProfileStore.limit == 100)
#expect(StylePreset.allCases.map(\.name) == ["Professional", "Casual", "Ultracasual"])
for preset in StylePreset.allCases {
#expect(preset.name.count <= StyleProfileStore.nameLimit)
#expect(preset.instructions.utf8.count <= StyleProfileStore.characterLimit)
}
}

@Test("adding a preset appends it once; an added one is found by name, whatever its text became")
func adding() throws {
let added = try #require(StylePreset.casual.adding(to: []))
#expect(added.profiles.map(\.name) == ["Casual"])
#expect(added.style.instructions == StylePreset.casual.instructions)
var edited = added.style
edited.instructions = "my own words"
let again = try #require(StylePreset.casual.adding(to: [edited]))
#expect(again.profiles == [edited])
#expect(again.style == edited)
#expect(StylePreset.casual.added(in: [StyleProfile(name: "casual", instructions: "x")]) != nil)
}

@Test("a full list takes no new preset, but still finds one already added")
func full() {
let full = (0..<StyleProfileStore.limit).map { StyleProfile(name: "Style \($0)", instructions: "x") }
#expect(StylePreset.professional.adding(to: full) == nil)
let withPreset = Array(full.dropLast()) + [StyleProfile(name: "Professional", instructions: "x")]
#expect(StylePreset.professional.adding(to: withPreset)?.profiles == withPreset)
}
}
7 changes: 7 additions & 0 deletions App/BlurtiOS/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,13 @@ 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").

**Styles** (`StylesView`, `StylePreset`): `YOUR STYLES` (Default, then each
of the user's, the active one checked; tap to use, the pencil to edit),
`START FROM A PRESET` (Professional, Casual and Ultracasual, each with its icon and a line of its text; one tap adds it as a
style and switches to it, an added one shows a check), and `NEW STYLE` pinned
underneath, whose editor starts with the presets as equal-width chips that fill
in the name and the instructions.

**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
Expand Down
Loading
Loading