diff --git a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift index bf26f6f6..a1c3ccf2 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift @@ -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. diff --git a/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift b/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift index d8a00b25..511b8191 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift @@ -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 = "" @@ -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 @@ -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 @@ -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 { @@ -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) } @@ -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 : []) + } +} diff --git a/App/BlurtiOS/BlurtiOSCore/StylePreset.swift b/App/BlurtiOS/BlurtiOSCore/StylePreset.swift new file mode 100644 index 00000000..68ad9597 --- /dev/null +++ b/App/BlurtiOS/BlurtiOSCore/StylePreset.swift @@ -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) + } +} diff --git a/App/BlurtiOS/BlurtiOSTests/StylePresetTests.swift b/App/BlurtiOS/BlurtiOSTests/StylePresetTests.swift new file mode 100644 index 00000000..c5533885 --- /dev/null +++ b/App/BlurtiOS/BlurtiOSTests/StylePresetTests.swift @@ -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.. [StyleProfile] { - profiles.prefix(profileLimit).map { profile in + static func normalized(_ profiles: [StyleProfile], limit: Int = limit) -> [StyleProfile] { + profiles.prefix(limit).map { profile in StyleProfile( id: profile.id, name: String((profile.name.trimmedNonEmpty() ?? fallbackName).prefix(nameLimit)), diff --git a/Sources/BlurtEngine/HostIdentity.swift b/Sources/BlurtEngine/HostIdentity.swift index ef3be51e..dc4ef387 100644 --- a/Sources/BlurtEngine/HostIdentity.swift +++ b/Sources/BlurtEngine/HostIdentity.swift @@ -76,18 +76,28 @@ public struct HostIdentity: Sendable, Equatable { /// logs are written to (`~/Library/Logs/Blurt/{dictations,errors}.jsonl`). public let logDirectoryName: String + // periphery:ignore - public for hosts outside this repo (the iOS app); the Mac app keeps the default. + /// How many style profiles the user may define (`StyleProfileStore.limit`). + /// Blurt's is `StyleProfileStore.profileLimit`, the most its main window's + /// switcher holds; a host whose styles live in a scrolling list can allow + /// more. Every store reads it, so the list the transcriber resolves the active + /// style from is never trimmed shorter than the one the user sees. + public let styleProfileLimit: Int + public init( productName: String, subsystem: String, keychainService: String, defaultsPrefix: String, - logDirectoryName: String + logDirectoryName: String, + styleProfileLimit: Int = StyleProfileStore.profileLimit ) { self.productName = productName self.subsystem = subsystem self.keychainService = keychainService self.defaultsPrefix = defaultsPrefix self.logDirectoryName = logDirectoryName + self.styleProfileLimit = max(1, styleProfileLimit) } /// Blurt's own identity, and the value an unconfigured host inherits — so the @@ -127,7 +137,8 @@ public struct HostIdentity: Sendable, Equatable { subsystem: subsystem, keychainService: service, defaultsPrefix: defaultsPrefix, - logDirectoryName: logDirectoryName) + logDirectoryName: logDirectoryName, + styleProfileLimit: styleProfileLimit) } /// The identity every engine component reads. `.blurt` until a host calls diff --git a/Tests/BlurtEngineTests/HostIdentityTests.swift b/Tests/BlurtEngineTests/HostIdentityTests.swift index ee74d62a..b74585fe 100644 --- a/Tests/BlurtEngineTests/HostIdentityTests.swift +++ b/Tests/BlurtEngineTests/HostIdentityTests.swift @@ -65,6 +65,17 @@ struct HostIdentityTests { #expect(HostIdentity.current == .blurt) } + @Test("a host's style limit defaults to Blurt's, and is never below one") + func styleProfileLimit() { + let identity = HostIdentity( + productName: "Host", subsystem: "host", keychainService: "host", defaultsPrefix: "Host", logDirectoryName: "Host") + #expect(identity.styleProfileLimit == StyleProfileStore.profileLimit) + let none = HostIdentity( + productName: "Host", subsystem: "host", keychainService: "host", defaultsPrefix: "Host", logDirectoryName: "Host", + styleProfileLimit: 0) + #expect(none.styleProfileLimit == 1) + } + @Test("defaults keys are the prefix plus the case's raw value") func defaultsKeysComposeFromThePrefix() { // Blurt's prefix reproduces the keys already on disk — renaming one abandons diff --git a/Tests/BlurtEngineTests/StyleProfileStoreTests.swift b/Tests/BlurtEngineTests/StyleProfileStoreTests.swift index 7c3773ae..c979fbf5 100644 --- a/Tests/BlurtEngineTests/StyleProfileStoreTests.swift +++ b/Tests/BlurtEngineTests/StyleProfileStoreTests.swift @@ -97,6 +97,15 @@ struct StyleProfileStoreTests { #expect(store.profiles.last?.name == "Style \(StyleProfileStore.profileLimit)") } + @Test("a host's own limit keeps more profiles than Blurt's four; Blurt's stays four") + func hostLimitSetsTheCap() { + let ten = (1...10).map { StyleProfile(name: "Style \($0)", instructions: "instruction \($0)") } + #expect(StyleProfileStore.normalized(ten, limit: 100).count == 10) + #expect(StyleProfileStore.normalized(ten, limit: StyleProfileStore.profileLimit).count == 4) + #expect(HostIdentity.blurt.styleProfileLimit == StyleProfileStore.profileLimit) + #expect(StyleProfileStore.profileLimit == 4) + } + /// The cap is per profile because only one profile is ever sent. Both are /// trimmed to the full budget even though their sum is far over it — a sum that /// would matter only if they were concatenated, which is precisely what this