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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
52 changes: 52 additions & 0 deletions App/BlurtiOS/BlurtDesign/BrandTabBar.swift
Original file line number Diff line number Diff line change
@@ -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<Tab: Hashable & Identifiable>: 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<Tab>, 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)
}
}
33 changes: 33 additions & 0 deletions App/BlurtiOS/BlurtDesign/Chrome.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 34 additions & 1 deletion App/BlurtiOS/BlurtDesign/DesignTokens.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
52 changes: 52 additions & 0 deletions App/BlurtiOS/BlurtDesign/FlowLayout.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
3 changes: 1 addition & 2 deletions App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}

Expand Down
29 changes: 4 additions & 25 deletions App/BlurtiOS/BlurtiOS/Sources/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() }
}
Expand All @@ -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)
}
Expand Down
67 changes: 67 additions & 0 deletions App/BlurtiOS/BlurtiOS/Sources/MainView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading