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..