feat: add streaming cloud ASR, polish routing, and settings card layout

Unify Bailian/Volcengine/OpenAI realtime streaming, ABE polish routing with
fun styles, and a shared card-page Settings hierarchy; bump to 1.1 (build 32).
This commit is contained in:
Rocky
2026-07-28 20:25:17 +08:00
parent 956331a2af
commit d656bac8c3
58 changed files with 4153 additions and 1396 deletions
-1
View File
@@ -107,7 +107,6 @@
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>picture-in-picture</string>
</array>
<key>UILaunchScreen</key>
<dict>
+107 -25
View File
@@ -543,9 +543,15 @@ final class FlowSessionManager: ObservableObject {
private func reactivateCaptureIfNeeded() async {
guard isActive else { return }
if usesPiPKeepAlive, !isUtteranceRecording, !isUtteranceProcessing, !capture.running {
refreshHostReady()
return
// PiP releases the mic between utterances (and after drain while
// processing). Only reassert when an utterance is actively recording
// with capture already running otherwise a foreground bounce was
// cold-starting the mic mid-finalize (`!pri` / session churn).
if usesPiPKeepAlive {
guard isUtteranceRecording, capture.running else {
refreshHostReady()
return
}
}
// A system interruption (call / Siri) may be in progress. Probe it:
// `setActive(true)` inside `reassertIfRunning` fails while the
@@ -1262,29 +1268,51 @@ final class FlowSessionManager: ObservableObject {
private func handleStartRecordingCommand(utteranceId: UUID?, commandSeq: Int64) async {
if usesPiPKeepAlive {
refreshHostReady()
let micReady = await ensureCaptureReadyForPiPUtterance()
guard micReady else {
// Open the mic and utterance gate ASAP. Waiting for audio proof
// *before* beginUtterance left spin-up frames in a tiny preroll
// while the keyboard already showed "recording" users spoke into
// a closed gate and got ~1s PCM for a multi-second press.
guard startCaptureForPiPUtteranceIfNeeded() else {
failUtterance(
message: AppL10n.string("flow.coldStart.error.audioTimeout"),
kind: .audioUnavailable
)
return
}
beginUtterance(
utteranceId: utteranceId,
commandSeq: commandSeq,
requireRecentAudio: false
)
if capture.engineHasRecentAudio(maxAge: 2) {
return
}
let micReady = await capture.awaitAudioFlowing(
timeout: Self.coldStartAudioProofTimeout
)
if !micReady {
failUtterance(
message: AppL10n.string("flow.coldStart.error.audioTimeout"),
kind: .audioUnavailable
)
}
return
}
beginUtterance(utteranceId: utteranceId, commandSeq: commandSeq)
}
private func ensureCaptureReadyForPiPUtterance() async -> Bool {
/// Start capture for a PiP utterance without blocking on the first frame.
private func startCaptureForPiPUtteranceIfNeeded() -> Bool {
if capture.engineHasRecentAudio(maxAge: 2) {
return true
}
do {
try capture.start()
return true
} catch {
debug("PiP utterance capture start failed: \(error.localizedDescription)")
return false
}
return await capture.awaitAudioFlowing(timeout: Self.coldStartAudioProofTimeout)
}
private func releaseCaptureAfterPiPUtteranceIfNeeded() {
@@ -1297,14 +1325,31 @@ final class FlowSessionManager: ObservableObject {
refreshHostReady()
}
private func beginUtterance(utteranceId: UUID? = nil, commandSeq: Int64 = 0) {
guard capture.engineHasRecentAudio(maxAge: 2) else {
traceState("beginUtterance.blocked", extra: "reason=audioNotRecent")
failUtterance(
message: AppL10n.string("flow.error.audioUnavailable"),
kind: .audioUnavailable
)
return
private func beginUtterance(
utteranceId: UUID? = nil,
commandSeq: Int64 = 0,
requireRecentAudio: Bool = true
) {
if requireRecentAudio {
guard capture.engineHasRecentAudio(maxAge: 2) else {
traceState("beginUtterance.blocked", extra: "reason=audioNotRecent")
failUtterance(
message: AppL10n.string("flow.error.audioUnavailable"),
kind: .audioUnavailable
)
return
}
} else {
// PiP cold path: capture was just started; open the gate so the
// first tap frames enter the ASR stream instead of preroll only.
guard capture.running || capture.engineIsLive else {
traceState("beginUtterance.blocked", extra: "reason=captureNotRunning")
failUtterance(
message: AppL10n.string("flow.error.audioUnavailable"),
kind: .audioUnavailable
)
return
}
}
guard !isUtteranceProcessing else {
traceState("beginUtterance.ignored", extra: "reason=processing")
@@ -1339,8 +1384,18 @@ final class FlowSessionManager: ObservableObject {
let locale = SpeechLocaleResolver.resolve(localeId)
let stream = capture.beginUtterance()
let pipeline = ChunkedUtterancePipeline(asr: asr, locale: locale)
chunkedPipeline = pipeline
let useStreaming =
store.engineMode == "cloud"
&& CloudASRModelCatalog.supportsTrueStreamingASR(for: store.asrProviderId)
let pipeline: ChunkedUtterancePipeline?
if useStreaming {
chunkedPipeline = nil
pipeline = nil
} else {
let created = ChunkedUtterancePipeline(asr: asr, locale: locale)
chunkedPipeline = created
pipeline = created
}
isUtteranceRecording = true
utteranceRecordingStartedAt = Date()
@@ -1349,18 +1404,33 @@ final class FlowSessionManager: ObservableObject {
updateLiveActivityPhase(.recording)
FlowDiagnostics.log(
"beginUtterance engine=\(store.engineMode) " +
"asrType=\(type(of: asr)) pipelined=true " +
"asrType=\(type(of: asr)) streaming=\(useStreaming) " +
"localCustomLM=\(store.localASRCustomLanguageModelEnabled) " +
"max=\(Int(FlowSessionKeys.maxUtteranceDuration))s"
)
let cloudASRForStreaming = useStreaming ? (asr as? CloudASRService) : nil
asrTask = Task.detached(priority: .userInitiated) { [weak manager = self] in
let outcome = await pipeline.transcribe(stream: stream) { partial in
Task { @MainActor in
guard let manager else { return }
manager.currentPartial = partial
manager.storeCurrentPartial(partial)
let outcome: ChunkedUtterancePipelineOutcome
if let cloud = cloudASRForStreaming {
outcome = await cloud.transcribeUtteranceStreaming(stream: stream, locale: locale) { partial in
Task { @MainActor in
guard let manager else { return }
manager.currentPartial = partial
manager.storeCurrentPartial(partial)
}
}
} else if let pipeline {
outcome = await pipeline.transcribe(stream: stream) { partial in
Task { @MainActor in
guard let manager else { return }
manager.currentPartial = partial
manager.storeCurrentPartial(partial)
}
}
} else {
outcome = .failure(SharedL10n.string("error.asr.noSpeech"))
}
// Re-bind `manager` inside the `@MainActor` block so the
// weak reference is captured under the right isolation. Swift
@@ -1369,7 +1439,7 @@ final class FlowSessionManager: ObservableObject {
await MainActor.run { [weak manager] in
guard let manager else { return }
FlowDiagnostics.log(
"chunkedASR finished partialLen=\(manager.currentPartial.count) " +
"asr finished streaming=\(useStreaming) partialLen=\(manager.currentPartial.count) " +
"finalPending=\(manager.lastFinal.isEmpty)"
)
switch outcome {
@@ -1379,7 +1449,19 @@ final class FlowSessionManager: ObservableObject {
manager.currentPartial = ""
case .failure(let message):
manager.debug("asr error: \(message)")
if manager.isUtteranceRecording {
// Prefer any non-empty partial over a hard no-speech failure.
// finishProcessing used to clear bestPartialSnapshot and race
// finalize into an empty transcript even when ASR had text.
let recovery = [
manager.currentPartial,
manager.bestPartialSnapshot
]
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.first(where: { !$0.isEmpty })
if let recovery {
manager.lastFinal = recovery
manager.debug("asr error recovered via partial len=\(recovery.count)")
} else if manager.isUtteranceRecording {
manager.failUtterance(message: message, kind: .asrFailed)
} else if manager.isUtteranceProcessing {
manager.finishProcessing(withError: message, kind: .asrFailed)
+1 -1
View File
@@ -43,7 +43,7 @@ struct APISettingsCard: View {
rowDivider
SettingsProviderToolsRow(validate: validateConnection)
}
.modifier(SettingsSurfaceCardModifier(enabled: showsSurface))
.surfaceCard(enabled: showsSurface)
}
private var rowDivider: some View {
+1 -1
View File
@@ -23,7 +23,7 @@ struct ASRSettingsCard: View {
rowDivider
SettingsProviderToolsRow(validate: validateConnection)
}
.modifier(SettingsSurfaceCardModifier(enabled: showsSurface))
.surfaceCard(enabled: showsSurface)
}
@ViewBuilder
@@ -39,18 +39,42 @@ extension View {
preference(key: TabBarHiddenPreferenceKey.self, value: true)
}
/// Bottom inset for scroll content above the floating dock (tab root pages only).
/// Bottom inset for scroll *content* above the floating dock (ScrollView inner stacks).
func tabBarScrollBottomPadding() -> some View {
modifier(TabBarScrollBottomPaddingModifier())
}
/// Bottom scroll-content margin for `List` tab roots. Unlike padding on the list
/// container, this extends the scrollable area so rows can scroll above the dock.
func tabBarListScrollBottomMargin() -> some View {
modifier(TabBarListScrollBottomMarginModifier())
}
}
enum TabBarDockMetrics {
/// Clearance above the floating dock (icon row + vertical padding + home indicator).
static let scrollClearance: CGFloat = 100
}
private struct TabBarScrollBottomPaddingModifier: ViewModifier {
@Environment(\.isTabBarVisible) private var isTabBarVisible
private let dockClearance: CGFloat = 100
func body(content: Content) -> some View {
content.padding(.bottom, isTabBarVisible ? dockClearance : Spacing.lg)
content.padding(
.bottom,
isTabBarVisible ? TabBarDockMetrics.scrollClearance : Spacing.lg
)
}
}
private struct TabBarListScrollBottomMarginModifier: ViewModifier {
@Environment(\.isTabBarVisible) private var isTabBarVisible
func body(content: Content) -> some View {
content.contentMargins(
.bottom,
isTabBarVisible ? TabBarDockMetrics.scrollClearance : Spacing.lg,
for: .scrollContent
)
}
}
+19 -44
View File
@@ -7,34 +7,37 @@
import SwiftUI
import OSGKeyboardShared
struct EnginePickerSection: View {
struct EnginePickerSection<ConfigurationRows: View>: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
private let configurationRows: ConfigurationRows
init(
config: ProviderConfig,
@ViewBuilder configurationRows: () -> ConfigurationRows
) {
self.config = config
self.configurationRows = configurationRows()
}
var body: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.engine.title")
CardSection("settings.engine.title") {
VStack(spacing: 0) {
engineOptionRow(
id: "local",
systemIcon: "iphone.badge.checkmark",
title: AppL10n.string("settings.engine.local.title"),
subtitle: localSubtitle
)
Divider().background(palette.divider)
engineOptionRow(
id: "cloud",
systemIcon: "wand.and.stars",
title: AppL10n.string("settings.engine.cloud.title"),
subtitle: AppL10n.string("settings.engine.cloud.subtitle")
)
configurationRows
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
.surfaceCard()
}
}
@@ -44,8 +47,6 @@ struct EnginePickerSection: View {
private func engineOptionRow(
id: String,
assetName: String? = nil,
systemIcon: String? = nil,
title: String,
subtitle: String
) -> some View {
@@ -55,11 +56,6 @@ struct EnginePickerSection: View {
selectEngine(id)
} label: {
HStack(spacing: Spacing.sm) {
engineMark(
assetName: assetName,
systemIcon: systemIcon,
isSelected: isSelected
)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(TypeStyle.body)
@@ -90,33 +86,12 @@ struct EnginePickerSection: View {
}
}
@ViewBuilder
private func engineMark(assetName: String?, systemIcon: String?, isSelected: Bool) -> some View {
ZStack {
Circle()
.fill(isSelected ? palette.accentMuted : palette.surfaceElevated)
.frame(width: 32, height: 32)
if let assetName {
Image(assetName)
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
} else if let systemIcon {
Image(systemName: systemIcon)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(isSelected ? palette.accent : palette.textSecondary)
}
}
.frame(width: 32, height: 32)
}
}
@ViewBuilder
private func sectionHeader(_ title: LocalizedStringKey) -> some View {
Text(title)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
.frame(maxWidth: .infinity, alignment: .leading)
extension EnginePickerSection where ConfigurationRows == EmptyView {
init(config: ProviderConfig) {
self.init(config: config) {
EmptyView()
}
}
}
+50 -6
View File
@@ -9,6 +9,8 @@ struct HistoryView: View {
@ObservedObject private var store = SpeechHistoryStore.shared
@State private var showClearConfirmation = false
@State private var showDeleteDayConfirmation = false
@State private var dayPendingDelete: Date?
private static let dayFormatter: DateFormatter = {
let f = DateFormatter()
@@ -62,6 +64,23 @@ struct HistoryView: View {
} message: {
Text("history.clear.message")
}
.confirmationDialog(
"history.clearDay.title",
isPresented: $showDeleteDayConfirmation,
titleVisibility: .visible
) {
Button("history.clearDay.confirm", role: .destructive) {
if let day = dayPendingDelete {
store.deleteEntries(on: day)
}
dayPendingDelete = nil
}
Button("common.cancel", role: .cancel) {
dayPendingDelete = nil
}
} message: {
Text("history.clearDay.message")
}
}
}
@@ -81,11 +100,7 @@ struct HistoryView: View {
delete(items: group.items, at: offsets)
}
} header: {
Text(Self.dayFormatter.string(from: group.day))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.textCase(.uppercase)
.tracking(0.5)
daySectionHeader(day: group.day)
}
.listSectionMargins(.horizontal, Spacing.lg)
}
@@ -95,7 +110,36 @@ struct HistoryView: View {
.scrollContentBackground(.hidden)
.background(palette.background)
.contentMargins(.top, Spacing.md, for: .scrollContent)
.tabBarScrollBottomPadding()
.tabBarListScrollBottomMargin()
}
/// Date label + per-day delete, flush with the section card's left/right edges
/// (Settings section labels share the same edge; system List headers inset further).
private func daySectionHeader(day: Date) -> some View {
HStack(alignment: .center, spacing: Spacing.sm) {
Text(Self.dayFormatter.string(from: day))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
Spacer(minLength: 0)
Button {
dayPendingDelete = day
showDeleteDayConfirmation = true
} label: {
Text("common.delete")
.font(TypeStyle.caption2)
.foregroundStyle(palette.danger)
}
.buttonStyle(.plain)
.accessibilityLabel("history.clearDay.button")
}
.frame(maxWidth: .infinity, alignment: .leading)
// Cancel the default List section-header content inset so the label
// lines up with the card's left edge (rows use leading: 0).
.padding(.horizontal, -SettingsListMetrics.rowHorizontalPadding)
.textCase(nil)
}
private var emptyState: some View {
@@ -30,11 +30,7 @@ struct LocalModelsGroup: View {
customLanguageModelDiagnosticRow
#endif
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
.surfaceCard()
}
// MARK: Speech row
+2 -2
View File
@@ -742,7 +742,7 @@ private struct APISetupPage: View {
Divider().background(palette.divider)
ASRSettingsCard(config: config, showsSurface: false)
}
.modifier(SettingsSurfaceCardModifier(enabled: true))
.surfaceCard()
.padding(.horizontal, Spacing.lg)
} else {
Text("onboarding.api.localModels.hint")
@@ -789,7 +789,7 @@ private struct PolishSetupPage: View {
Divider().background(palette.divider)
APISettingsCard(config: config, showsSurface: false)
}
.modifier(SettingsSurfaceCardModifier(enabled: true))
.surfaceCard()
.padding(.horizontal, Spacing.lg)
}
.padding(.bottom, Spacing.xxxl)
+3 -11
View File
@@ -13,7 +13,7 @@ struct OpenSourceLicensesView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
CardPageContent(spacing: SettingsListMetrics.sectionLabelSpacing) {
Text("settings.licenses.footer")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
@@ -34,14 +34,8 @@ struct OpenSourceLicensesView: View {
}
}
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
.surfaceCard()
}
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.md)
}
.background(palette.background.ignoresSafeArea())
.navigationTitle("settings.licenses.title")
@@ -78,7 +72,7 @@ private struct OpenSourceLicenseDetailView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.sm) {
CardPageContent(spacing: Spacing.sm) {
if let url = entry.url {
Link(destination: url) {
HStack(spacing: Spacing.xs) {
@@ -105,8 +99,6 @@ private struct OpenSourceLicenseDetailView: View {
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.md)
}
.background(palette.background.ignoresSafeArea())
.navigationTitle(entry.name)
@@ -121,7 +121,7 @@ struct PersonalDictionaryView: View {
placement: .navigationBarDrawer(displayMode: .always),
prompt: "settings.personalDictionary.search.prompt"
)
.tabBarScrollBottomPadding()
.tabBarListScrollBottomMargin()
}
private func entryRow(_ entry: PersonalDictionary.Entry) -> some View {
+27 -47
View File
@@ -21,17 +21,21 @@ struct PolishStylesView: View {
private let store = AppGroupStore()
private let columns = [
GridItem(.flexible(), spacing: Spacing.md),
GridItem(.flexible(), spacing: Spacing.md),
GridItem(.flexible(), spacing: Spacing.sm),
GridItem(.flexible(), spacing: Spacing.sm),
]
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.xl) {
CardPageContent(spacing: Spacing.xl) {
packGridSection(
title: "polishStyles.builtin.section",
packs: PolishStylePackCatalog.builtins
packs: PolishStylePackCatalog.BuiltinStyleGroup.practical.packs
)
packGridSection(
title: "polishStyles.fun.section",
packs: PolishStylePackCatalog.BuiltinStyleGroup.fun.packs
)
if !catalog.entries.isEmpty {
packGridSection(
@@ -41,12 +45,9 @@ struct PolishStylesView: View {
)
}
}
.padding(.horizontal, Spacing.md)
.padding(.top, Spacing.sm)
.padding(.bottom, Spacing.xl)
.tabBarScrollBottomPadding()
}
.background(palette.background)
.tabBarScrollBottomPadding()
.navigationTitle("polishStyles.title")
.navigationBarTitleDisplayMode(.large)
.toolbar {
@@ -98,14 +99,8 @@ struct PolishStylesView: View {
title: LocalizedStringKey,
packs: [PolishStylePack]
) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(title)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
.frame(maxWidth: .infinity, alignment: .leading)
LazyVGrid(columns: columns, spacing: Spacing.md) {
CardSection(title) {
LazyVGrid(columns: columns, spacing: Spacing.sm) {
ForEach(packs) { pack in
packCard(pack)
}
@@ -120,20 +115,18 @@ struct PolishStylesView: View {
activate(pack)
} label: {
VStack(alignment: .leading, spacing: Spacing.sm) {
Image(systemName: iconName(for: pack))
.font(.system(size: 24, weight: .medium))
.foregroundStyle(isSelected ? palette.accent : palette.textSecondary)
Text(pack.displayName(language: config.uiLanguage))
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.padding(.trailing, 32)
Text(descriptionKey(for: pack))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.lineLimit(3)
.lineLimit(2)
Spacer()
}
.frame(maxWidth: .infinity, minHeight: 132, alignment: .leading)
.frame(maxWidth: .infinity, minHeight: 96, alignment: .leading)
.padding(Spacing.md)
.contentShape(Rectangle())
}
@@ -193,17 +186,6 @@ struct PolishStylesView: View {
}
}
private func iconName(for pack: PolishStylePack) -> String {
switch pack.id {
case "builtin.structured": return "list.bullet.rectangle"
case "builtin.formal": return "briefcase"
case "builtin.dating": return "heart.text.square"
case "builtin.chat": return "bubble.left.and.bubble.right"
case "builtin.light": return "wand.and.sparkles"
default: return "text.badge.star"
}
}
private func descriptionKey(for pack: PolishStylePack) -> LocalizedStringKey {
guard pack.kind == .builtin else { return "polishStyles.custom.description" }
switch pack.id {
@@ -211,6 +193,10 @@ struct PolishStylesView: View {
case "builtin.formal": return "polishStyles.formal.description"
case "builtin.dating": return "polishStyles.dating.description"
case "builtin.chat": return "polishStyles.chat.description"
case "builtin.flex": return "polishStyles.flex.description"
case "builtin.corp": return "polishStyles.corp.description"
case "builtin.diba": return "polishStyles.diba.description"
case "builtin.xhs": return "polishStyles.xhs.description"
default: return "polishStyles.light.description"
}
}
@@ -294,21 +280,15 @@ private struct PolishStylePromptDetailSheet: View {
var body: some View {
NavigationStack {
ScrollView {
Text(pack.prompt)
.font(.body.monospaced())
.foregroundStyle(palette.textPrimary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
.background(
palette.surface,
in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
.padding(Spacing.md)
CardPageContent {
Text(pack.prompt)
.font(.body.monospaced())
.foregroundStyle(palette.textPrimary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
.surfaceCard()
}
}
.background(palette.background)
.navigationTitle(pack.displayName(language: language))
+14 -1
View File
@@ -49,7 +49,7 @@ struct ProviderPickerSection: View {
}
.buttonStyle(.plain)
}
.modifier(SettingsSurfaceCardModifier(enabled: showsSurface))
.surfaceCard(enabled: showsSurface)
}
private func select(_ provider: LLMProvider) {
@@ -75,6 +75,9 @@ struct ProviderPickerSection: View {
if selectedProvider.supportsPersonalDictionaryCloudASR {
personalDictionaryBadge
}
if role == .asr, selectedProvider.supportsStreamingCloudASR {
streamingBadge
}
Spacer(minLength: Spacing.xs)
@@ -115,4 +118,14 @@ struct ProviderPickerSection: View {
.padding(.vertical, 4)
.background(palette.accentMuted, in: Capsule())
}
/// Bailian / Volcengine / OpenAI Realtime utterance-level true streaming.
private var streamingBadge: some View {
Text("settings.provider.streamingBadge")
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 4)
.background(palette.accentMuted, in: Capsule())
}
}
@@ -1,26 +0,0 @@
// SettingsCardChrome.swift
// OSGKeyboard · Main App
//
// Shared rounded surface chrome for settings list cards.
import SwiftUI
import OSGKeyboardShared
struct SettingsSurfaceCardModifier: ViewModifier {
@Environment(\.themePalette) private var palette: ThemePalette
let enabled: Bool
func body(content: Content) -> some View {
if enabled {
content
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
} else {
content
}
}
}
@@ -0,0 +1,308 @@
// SettingsPreferenceRows.swift
// OSGKeyboard · Main App
//
// Shared preference picker / toggle rows used by Settings home and
// secondary pages (General, Voice session, Daily).
import SwiftUI
import Speech
import OSGKeyboardShared
// MARK: - App language picker row
struct AppLanguagePickerRow: View {
@Binding var selection: AppUILanguage
private var options: [(id: String, label: String)] {
AppUILanguage.allCases.map { language in
(language.rawValue, AppL10n.string(language.labelKey))
}
}
var body: some View {
SettingsMenuPickerRow(
title: AppL10n.string("settings.appLanguage.title"),
options: options,
selection: Binding(
get: { selection.rawValue },
set: { newValue in
selection = AppUILanguage(rawValue: newValue) ?? .auto
}
)
)
}
}
// MARK: - Appearance picker row
struct AppearancePickerRow: View {
@AppStorage(AppearancePreference.storageKey)
private var appearanceRaw = AppearancePreference.system.rawValue
private var options: [(id: String, label: String)] {
AppearancePreference.allCases.map { preference in
(preference.rawValue, AppL10n.string(preference.labelKey))
}
}
var body: some View {
SettingsMenuPickerRow(
title: AppL10n.string("settings.appearance.title"),
options: options,
selection: $appearanceRaw
)
}
}
// MARK: - Flow keep-alive mode picker row
struct FlowKeepAliveModePickerRow: View {
@Binding var selection: FlowKeepAliveMode
private var options: [(id: String, label: String)] {
FlowKeepAliveMode.allCases.map { mode in
(mode.rawValue, AppL10n.string(mode.labelKey))
}
}
var body: some View {
SettingsMenuPickerRow(
title: AppL10n.string("settings.flow.keepAlive.title"),
options: options,
selection: Binding(
get: { selection.rawValue },
set: { newValue in
selection = FlowKeepAliveMode(rawValue: newValue) ?? .default
}
)
)
}
}
// MARK: - Flow inactivity picker row
struct FlowInactivityPickerRow: View {
@Binding var selection: FlowInactivityDuration
private var options: [(id: String, label: String)] {
FlowInactivityDuration.allCases.map { duration in
(duration.rawValue, AppL10n.string(duration.labelKey))
}
}
var body: some View {
SettingsMenuPickerRow(
title: AppL10n.string("settings.flow.inactivity.title"),
options: options,
selection: Binding(
get: { selection.rawValue },
set: { newValue in
selection = FlowInactivityDuration(rawValue: newValue) ?? .default
}
)
)
}
}
// MARK: - Handedness picker row
struct HandednessPickerRow: View {
@Binding var selection: HandednessPreference
private var options: [(id: String, label: String)] {
HandednessPreference.allCases.map { preference in
(preference.rawValue, AppL10n.string(preference.labelKey))
}
}
var body: some View {
SettingsMenuPickerRow(
title: AppL10n.string("settings.handedness.title"),
options: options,
selection: Binding(
get: { selection.rawValue },
set: { newValue in
selection = HandednessPreference(rawValue: newValue) ?? .left
}
)
)
}
}
// MARK: - Polish intensity picker row
struct PolishIntensityPickerRow: View {
@ObservedObject var config: ProviderConfig
var body: some View {
//
SettingsMenuPickerRow(
title: AppL10n.string("settings.polishIntensity.title"),
options: PolishIntensity.allCases.map { intensity in
(intensity.rawValue, SharedL10n.string(intensity.labelKey, language: config.uiLanguage))
},
selection: Binding(
get: { config.polishIntensity.rawValue },
set: { newValue in
config.polishIntensity = PolishIntensity(rawValue: newValue) ?? .medium
}
)
)
}
}
// MARK: - Cursor drag navigation toggle
struct CursorDragNavigationToggleRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Binding var isOn: Bool
var body: some View {
Toggle(isOn: $isOn) {
Text("settings.cursorDragNavigation.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
}
.tint(palette.accent)
.settingsListRow()
}
}
// MARK: - Menu picker row (generic)
struct SettingsMenuPickerRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
let title: String
let options: [(id: String, label: String)]
@Binding var selection: String
var body: some View {
HStack {
Text(title)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
Menu {
ForEach(options, id: \.id) { o in
Button {
selection = o.id
} label: {
if o.id == selection {
Label(o.label, systemImage: "checkmark")
} else {
Text(o.label)
}
}
}
} label: {
HStack(spacing: 4) {
Text(currentLabel)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(palette.textTertiary)
}
}
}
.settingsListRow()
}
private var currentLabel: String {
options.first(where: { $0.id == selection })?.label ?? ""
}
}
// MARK: - Locale picker row (with on-device indicator)
struct LocalePickerRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject private var config = ProviderConfig.shared
let locales: [(id: String, onDevice: Bool)]
@Binding var selection: String
var body: some View {
HStack {
Text("settings.asrLocale")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
Menu {
ForEach(locales, id: \.id) { locale in
Button {
selection = locale.id
} label: {
// iOS Menu converts SwiftUI Label to UIAction (title + image).
// Using Label keeps checkmark + on-device icon both visible.
let name = label(for: locale.id)
if locale.id == selection {
Label(name, systemImage: "checkmark")
} else if locale.onDevice {
Label(name, systemImage: "iphone")
} else {
Text(name)
}
}
}
} label: {
HStack(spacing: 6) {
// On-device badge for the currently selected locale.
if let current = locales.first(where: { $0.id == selection }), current.onDevice {
Image(systemName: "iphone")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(palette.accent)
}
Text(currentLabel)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(palette.textTertiary)
}
}
}
.settingsListRow()
}
private func label(for localeId: String) -> String {
ASRLocaleLabels.displayName(for: localeId, language: config.uiLanguage)
}
private var currentLabel: String {
label(for: selection)
}
}
// MARK: - Dynamic ASR locale loading
enum SettingsASRLocales {
/// Falls back to a short static list while `SFSpeechRecognizer` is loading.
static let staticFallback: [(id: String, onDevice: Bool)] = [
("auto", false),
("zh-Hans", false),
("zh-Hant", false),
("en-US", false),
("ja-JP", false),
("ko-KR", false),
]
static func loadDynamic() async -> [(id: String, onDevice: Bool)] {
// Run everything in a background task: `SFSpeechRecognizer.supportedLocales()`
// can return 100+ locales, and we probe supportsOnDeviceRecognition for each.
// Creating `SFSpeechRecognizer` instances in a @Sendable closure is
// safe here; we only read locale metadata (no transcription session).
await Task.detached(priority: .userInitiated) {
var result: [(id: String, onDevice: Bool)] = [("auto", false)]
for locale in SFSpeechRecognizer.supportedLocales()
.sorted(by: { $0.identifier < $1.identifier }) {
let id = locale.identifier
let onDevice = SFSpeechRecognizer(locale: locale)?.supportsOnDeviceRecognition ?? false
result.append((id: id, onDevice: onDevice))
}
return result
}.value
}
}
@@ -0,0 +1,351 @@
// SettingsSecondaryPages.swift
// OSGKeyboard · Main App
//
// Secondary Settings screens: speech recognition, text polish, voice
// session, general preferences, and about. Main Settings stays a
// daily console with summary navigation rows.
import SwiftUI
import OSGKeyboardShared
// MARK: - Navigation row (title + optional summary subtitle)
struct SettingsNavigationRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
let title: LocalizedStringKey
var subtitle: String?
var body: some View {
HStack(spacing: Spacing.sm) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(title)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.lineLimit(1)
}
}
Spacer(minLength: Spacing.xs)
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.settingsListRow()
.contentShape(Rectangle())
}
}
// MARK: - Config entry summaries (shown on Settings home)
enum SettingsConfigSummary {
static func speechRecognition(config: ProviderConfig) -> String {
if config.engineMode == "local" {
return SharedL10n.string(
"engine.asr.appleSpeech",
language: config.uiLanguage
)
}
let providerName = ProviderDisplayName.name(
for: config.asrProviderId,
language: config.uiLanguage
)
let trimmedModel = config.asrModel.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedModel.isEmpty {
return providerName
}
return "\(providerName) · \(trimmedModel)"
}
static func textPolish(config: ProviderConfig) -> String {
let providerName = ProviderDisplayName.name(
for: config.providerId,
language: config.uiLanguage
)
let trimmedModel = config.model.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedModel.isEmpty {
return providerName
}
return "\(providerName) · \(trimmedModel)"
}
}
// MARK: - Shared cloud provider card chrome
private struct CloudProviderSettingsCard<Content: View>: View {
@ViewBuilder let content: () -> Content
var body: some View {
VStack(spacing: 0) {
content()
}
.surfaceCard()
}
}
// MARK: - Speech recognition (ASR / local engine)
struct SpeechRecognitionSettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
var body: some View {
ScrollView {
CardPageContent {
if config.engineMode == "cloud" {
CardSection("settings.asrProvider.title") {
CloudProviderSettingsCard {
ProviderPickerSection(config: config, role: .asr, showsSurface: false)
Divider().background(palette.divider)
ASRSettingsCard(config: config, showsSurface: false)
}
}
} else {
CardSection("settings.localEngine.title") {
LocalModelsGroup(config: config)
}
}
}
}
.background(palette.background.ignoresSafeArea())
.navigationTitle("settings.speechRecognition.title")
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
}
}
// MARK: - Text polish (LLM)
struct TextPolishSettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
var body: some View {
ScrollView {
CardPageContent {
CardSection("settings.polishProvider.title") {
CloudProviderSettingsCard {
ProviderPickerSection(config: config, role: .polish, showsSurface: false)
Divider().background(palette.divider)
APISettingsCard(config: config, showsSurface: false)
}
}
}
}
.background(palette.background.ignoresSafeArea())
.navigationTitle("settings.textPolish.title")
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
}
}
// MARK: - Voice session rows (embedded in Daily)
struct VoiceSessionSettingsRows: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
@State private var showActiveFlowSessionAlert = false
var body: some View {
VStack(spacing: 0) {
FlowKeepAliveModePickerRow(
selection: Binding(
get: { config.flowKeepAliveMode },
set: { applyKeepAliveModeChange($0) }
)
)
if config.flowKeepAliveMode == .liveActivity {
Divider().background(palette.divider)
FlowInactivityPickerRow(
selection: Binding(
get: { config.flowInactivityDuration },
set: { config.flowInactivityDuration = $0 }
)
)
Divider().background(palette.divider)
Toggle(isOn: $config.flowSkipAppSwitch) {
flowSkipAppSwitchLabel
}
.tint(palette.accent)
.settingsListRow()
} else {
Divider().background(palette.divider)
Text("settings.flow.keepAlive.pictureInPicture.note")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
.settingsListRow()
}
}
.alert("settings.flow.keepAlive.activeSession.title", isPresented: $showActiveFlowSessionAlert) {
Button("common.done", role: .cancel) {}
} message: {
Text("settings.flow.keepAlive.activeSession.message")
}
}
private var flowSkipAppSwitchLabel: some View {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text("settings.flow.skipAppSwitch.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Text("settings.flow.skipAppSwitch.subtitle")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
}
}
private func applyKeepAliveModeChange(_ newMode: FlowKeepAliveMode) {
guard newMode != config.flowKeepAliveMode else { return }
if FlowSessionBridge.isSessionActive() {
showActiveFlowSessionAlert = true
return
}
config.flowKeepAliveMode = newMode
}
}
// MARK: - General (appearance, keyboard, sync)
struct GeneralSettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
var body: some View {
ScrollView {
CardPageContent {
CardSection("settings.general.appearanceLanguage.title") {
VStack(spacing: 0) {
AppLanguagePickerRow(
selection: Binding(
get: { config.uiLanguage },
set: { config.uiLanguage = $0 }
)
)
Divider().background(palette.divider)
AppearancePickerRow()
}
.surfaceCard()
}
CardSection("settings.general.keyboard.title") {
VStack(spacing: 0) {
HandednessPickerRow(
selection: Binding(
get: { config.handednessPreference },
set: { config.handednessPreference = $0 }
)
)
Divider().background(palette.divider)
CursorDragNavigationToggleRow(
isOn: $config.cursorDragNavigationEnabled
)
}
.surfaceCard()
}
CardSection("settings.general.sync.title") {
VStack(spacing: 0) {
SettingsICloudSyncRow()
}
.surfaceCard()
}
}
}
.background(palette.background.ignoresSafeArea())
.navigationTitle("settings.general.title")
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
}
}
// MARK: - About
struct AboutSettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.openURL) private var openURL
@ObservedObject var config: ProviderConfig
var body: some View {
ScrollView {
CardPageContent {
CardSection("settings.about.title") {
VStack(spacing: 0) {
Button {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
config.hasCompletedOnboarding = false
config.onboardingPage = 0
}
} label: {
SettingsNavigationRow(title: "settings.onboarding.replay")
}
.buttonStyle(.plain)
Divider().background(palette.divider)
NavigationLink {
PrivacyPolicyView()
} label: {
SettingsNavigationRow(title: "settings.privacy.policy")
}
.buttonStyle(.plain)
Divider().background(palette.divider)
NavigationLink {
HelpFeedbackView()
} label: {
SettingsNavigationRow(title: "settings.link.support")
}
.buttonStyle(.plain)
Divider().background(palette.divider)
Button {
openURL(LegalLinks.repositoryURL)
} label: {
HStack(spacing: Spacing.sm) {
Text("settings.link.github")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
MaterialIcon(name: .openInNew, size: 18)
.foregroundStyle(palette.textTertiary)
}
.settingsListRow()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Divider().background(palette.divider)
NavigationLink {
OpenSourceLicensesView()
} label: {
SettingsNavigationRow(title: "settings.link.licenses")
}
.buttonStyle(.plain)
}
.surfaceCard()
}
}
}
.background(palette.background.ignoresSafeArea())
.navigationTitle("settings.about.title")
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
}
}
+99 -626
View File
@@ -1,11 +1,10 @@
// SettingsView.swift
// OSGKeyboard · Main App
//
// Sheet that hosts the API configuration. Single scrollable column, every
// field earns its space.
// Settings home: daily controls + summary navigation into secondary
// pages for low-frequency configuration.
import SwiftUI
import Speech
import OSGKeyboardShared
enum SettingsPresentation {
@@ -13,12 +12,21 @@ enum SettingsPresentation {
case sheet
}
/// Routes pushed from Settings home. Value-based navigation keeps
/// destinations out of the root view tree until push important so
/// `hidesTabBarWhenPushed()` preferences do not leak onto the home
/// screen (and so we avoid NavigationLink + `dismiss` freeze cycles).
private enum SettingsRoute: Hashable {
case speechRecognition
case textPolish
case general
case about
}
struct SettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config = ProviderConfig.shared
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
let presentation: SettingsPresentation
@@ -29,38 +37,21 @@ struct SettingsView: View {
// Dynamic locale list loaded from SFSpeechRecognizer on first appear.
@State private var dynamicLocales: [(id: String, onDevice: Bool)] = []
@State private var showResetConfirmation = false
@State private var showActiveFlowSessionAlert = false
@State private var pendingKeepAliveMode: FlowKeepAliveMode?
// v0.2.0: no on-device model manager / pending download state
// iOS `SpeechAnalyzer` ships with iOS 26 and needs nothing
// downloaded.
@State private var path = NavigationPath()
var body: some View {
NavigationStack {
NavigationStack(path: $path) {
ZStack {
palette.background.ignoresSafeArea()
ScrollView {
VStack(spacing: Spacing.md) {
CardPageContent {
if presentation == .tab {
SupportDeveloperSection(language: config.uiLanguage)
}
languageAndPolishSection
dictionaryAndPolishSection
flowSessionSection
engineSection
if config.engineMode == "cloud" {
asrSettingsSection
}
if config.engineMode == "local" {
localEngineSettingsSection
}
polishSettingsSection
if presentation == .tab {
footerLinks
}
dailySection
transcriptionAndPolishSection
moreEntriesSection
}
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.md)
.modifier(SettingsScrollBottomPadding(presentation: presentation))
}
}
@@ -91,119 +82,38 @@ struct SettingsView: View {
}
if presentation == .sheet {
ToolbarItem(placement: .confirmationAction) {
Button("common.done") { dismiss() }
// Keep `dismiss` off the Settings root pairing it
// with NavigationLink / stack pushes can freeze UI.
SettingsSheetDismissButton()
}
}
}
.navigationDestination(for: SettingsRoute.self) { route in
settingsDestination(for: route)
}
.task { await loadDynamicLocales() }
// v0.2.0: no on-device model manager to refresh the
// iOS ASR backend is always ready.
}
}
// MARK: - Flow session
@ViewBuilder
private func settingsDestination(for route: SettingsRoute) -> some View {
switch route {
case .speechRecognition:
SpeechRecognitionSettingsView(config: config)
case .textPolish:
TextPolishSettingsView(config: config)
case .general:
GeneralSettingsView(config: config)
case .about:
AboutSettingsView(config: config)
}
}
private var flowSessionSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.flow.title")
// MARK: - Daily (high-frequency)
private var dailySection: some View {
CardSection("settings.daily.title") {
VStack(spacing: 0) {
FlowKeepAliveModePickerRow(
selection: Binding(
get: { config.flowKeepAliveMode },
set: { newMode in
applyKeepAliveModeChange(newMode)
}
)
)
if config.flowKeepAliveMode == .liveActivity {
Divider().background(palette.divider)
FlowInactivityPickerRow(
selection: Binding(
get: { config.flowInactivityDuration },
set: { config.flowInactivityDuration = $0 }
)
)
Divider().background(palette.divider)
Toggle(isOn: $config.flowSkipAppSwitch) {
flowSkipAppSwitchLabel
}
.tint(palette.accent)
.settingsListRow()
} else {
Divider().background(palette.divider)
Text("settings.flow.keepAlive.pictureInPicture.note")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
.settingsListRow()
}
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
.alert("settings.flow.keepAlive.activeSession.title", isPresented: $showActiveFlowSessionAlert) {
Button("common.done", role: .cancel) {
pendingKeepAliveMode = nil
}
} message: {
Text("settings.flow.keepAlive.activeSession.message")
}
}
private var flowSkipAppSwitchLabel: some View {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text("settings.flow.skipAppSwitch.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Text("settings.flow.skipAppSwitch.subtitle")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
}
}
private func applyKeepAliveModeChange(_ newMode: FlowKeepAliveMode) {
guard newMode != config.flowKeepAliveMode else { return }
if FlowSessionBridge.isSessionActive() {
pendingKeepAliveMode = newMode
showActiveFlowSessionAlert = true
return
}
config.flowKeepAliveMode = newMode
}
// MARK: - Engine
private var engineSection: some View {
EnginePickerSection(config: config)
}
// MARK: - Language & polish
private var languageAndPolishSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.preferences.title")
VStack(spacing: 0) {
AppLanguagePickerRow(
selection: Binding(
get: { config.uiLanguage },
set: { config.uiLanguage = $0 }
)
)
Divider().background(palette.divider)
AppearancePickerRow()
Divider().background(palette.divider)
LocalePickerRow(
locales: effectiveLocales,
selection: Binding(
@@ -214,297 +124,88 @@ struct SettingsView: View {
Divider().background(palette.divider)
HandednessPickerRow(
selection: Binding(
get: { config.handednessPreference },
set: { config.handednessPreference = $0 }
)
)
Divider().background(palette.divider)
cursorDragNavigationToggleRow
Divider().background(palette.divider)
SettingsICloudSyncRow()
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
// Legacy legend block: kept for UI compatibility, but with
// iOS 26 as minimum target this branch never executes.
if #unavailable(iOS 26) {
HStack(spacing: Spacing.xs) {
Image(systemName: "iphone")
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)
Text("settings.legend.onDevice")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
Spacer()
Image(systemName: "cloud")
.font(TypeStyle.caption2)
.foregroundStyle(palette.warning)
Text("settings.legend.cloudFallback")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
}
.padding(.horizontal, Spacing.xs)
}
}
}
// MARK: - Dictionary & polish
private var dictionaryAndPolishSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.polishPreferences.title")
VStack(spacing: 0) {
polishIntensityPreferenceRows
PolishIntensityPickerRow(config: config)
Divider().background(palette.divider)
TranslationPickerRow(config: config, isVisible: config.isTranslationRowVisible)
Divider().background(palette.divider)
VoiceSessionSettingsRows(config: config)
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
.surfaceCard()
}
}
// MARK: - Transcription & polish
private var transcriptionAndPolishSection: some View {
EnginePickerSection(config: config) {
Divider().background(palette.divider)
settingsRouteButton(
.speechRecognition,
title: "settings.speechRecognition.title",
subtitle: SettingsConfigSummary.speechRecognition(config: config)
)
Divider().background(palette.divider)
settingsRouteButton(
.textPolish,
title: "settings.textPolish.title",
subtitle: SettingsConfigSummary.textPolish(config: config)
)
}
}
/// v0.2.1 follow-up: dedicated section for the local engine's
/// settings (cloud-polish toggle + translation row). Renders only
/// when `engineMode == "local"` so the cloud-engine user doesn't
/// see rows that are inert for them. The translation row lives
/// inside `LocalModelsGroup` so it shares the group's surface card
/// chrome see `LocalEngineSettingsRows.swift` for the layout.
private var localEngineSettingsSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.localEngine.title")
LocalModelsGroup(config: config)
}
}
// MARK: - General / About
private var polishSettingsSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.polishProvider.title")
cloudProviderSettingsCard {
ProviderPickerSection(config: config, role: .polish, showsSurface: false)
Divider().background(palette.divider)
APISettingsCard(config: config, showsSurface: false)
}
}
}
private var asrSettingsSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.asrProvider.title")
cloudProviderSettingsCard {
ProviderPickerSection(config: config, role: .asr, showsSurface: false)
Divider().background(palette.divider)
ASRSettingsCard(config: config, showsSurface: false)
}
}
}
@ViewBuilder
private func cloudProviderSettingsCard<Content: View>(
@ViewBuilder content: () -> Content
) -> some View {
private var moreEntriesSection: some View {
VStack(spacing: 0) {
content()
}
.modifier(SettingsSurfaceCardModifier(enabled: true))
}
settingsRouteButton(.general, title: "settings.general.title")
// MARK: - Language helpers
/// Falls back to a static list while dynamic locales are loading.
private var effectiveLocales: [(id: String, onDevice: Bool)] {
dynamicLocales.isEmpty ? staticLocales : dynamicLocales
}
private var staticLocales: [(id: String, onDevice: Bool)] {
[
("auto", false),
("zh-Hans", false),
("zh-Hant", false),
("en-US", false),
("ja-JP", false),
("ko-KR", false),
]
}
// MARK: - Dynamic locale loading
private func loadDynamicLocales() async {
// Run everything in a background task: `SFSpeechRecognizer.supportedLocales()`
// can return 100+ locales, and we probe supportsOnDeviceRecognition for each.
// Creating `SFSpeechRecognizer` instances in a @Sendable closure is
// safe here; we only read locale metadata (no transcription session).
let entries: [(id: String, onDevice: Bool)] = await Task.detached(
priority: .userInitiated
) {
var result: [(id: String, onDevice: Bool)] = [("auto", false)]
for locale in SFSpeechRecognizer.supportedLocales()
.sorted(by: { $0.identifier < $1.identifier }) {
let id = locale.identifier
let onDevice = SFSpeechRecognizer(locale: locale)?.supportsOnDeviceRecognition ?? false
result.append((id: id, onDevice: onDevice))
if presentation == .tab {
Divider().background(palette.divider)
settingsRouteButton(.about, title: "settings.about.title")
}
return result
}.value
// .task {} calls us from the main actor, so this assignment is safe.
dynamicLocales = entries
}
// MARK: - Preference row helpers
private var polishIntensityPreferenceRows: some View {
//
PickerRow(
title: AppL10n.string("settings.polishIntensity.title"),
options: PolishIntensity.allCases.map { intensity in
(intensity.rawValue, SharedL10n.string(intensity.labelKey, language: config.uiLanguage))
},
selection: Binding(
get: { config.polishIntensity.rawValue },
set: { newValue in
config.polishIntensity = PolishIntensity(rawValue: newValue) ?? .medium
}
)
)
}
private var cursorDragNavigationToggleRow: some View {
Toggle(isOn: $config.cursorDragNavigationEnabled) {
Text("settings.cursorDragNavigation.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
}
.tint(palette.accent)
.settingsListRow()
.surfaceCard()
}
// MARK: - Footer links (tab settings only)
private var footerLinks: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.about.title")
VStack(spacing: 0) {
Button {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
config.hasCompletedOnboarding = false
config.onboardingPage = 0
}
} label: {
HStack(spacing: Spacing.sm) {
Text("settings.onboarding.replay")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.settingsListRow()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Divider().background(palette.divider)
NavigationLink {
PrivacyPolicyView()
} label: {
footerNavigationRow(title: "settings.privacy.policy")
}
.buttonStyle(.plain)
Divider().background(palette.divider)
NavigationLink {
HelpFeedbackView()
} label: {
footerNavigationRow(title: "settings.link.support")
}
.buttonStyle(.plain)
Divider().background(palette.divider)
footerExternalLinkRow(
title: "settings.link.github",
url: LegalLinks.repositoryURL
)
Divider().background(palette.divider)
NavigationLink {
OpenSourceLicensesView()
} label: {
footerNavigationRow(title: "settings.link.licenses")
}
.buttonStyle(.plain)
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
}
private func footerExternalLinkRow(title: LocalizedStringKey, url: URL) -> some View {
private func settingsRouteButton(
_ route: SettingsRoute,
title: LocalizedStringKey,
subtitle: String? = nil
) -> some View {
Button {
openURL(url)
path.append(route)
} label: {
HStack(spacing: Spacing.sm) {
Text(title)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
MaterialIcon(name: .openInNew, size: 18)
.foregroundStyle(palette.textTertiary)
}
.settingsListRow()
.contentShape(Rectangle())
SettingsNavigationRow(title: title, subtitle: subtitle)
}
.buttonStyle(.plain)
}
/// In-app disclosure row that pushes a child view onto the
/// `NavigationStack` rather than opening Safari. Used for the
/// Third-Party Licenses entry so the system "back" button
/// returns to Settings.
private func footerNavigationRow(title: LocalizedStringKey) -> some View {
HStack(spacing: Spacing.sm) {
Text(title)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.settingsListRow()
.contentShape(Rectangle())
// MARK: - Locale helpers
/// Falls back to a static list while dynamic locales are loading.
private var effectiveLocales: [(id: String, onDevice: Bool)] {
dynamicLocales.isEmpty ? SettingsASRLocales.staticFallback : dynamicLocales
}
// MARK: - Header
private func loadDynamicLocales() async {
dynamicLocales = await SettingsASRLocales.loadDynamic()
}
}
private func sectionHeader(_ title: LocalizedStringKey) -> some View {
Text(title)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
.frame(maxWidth: .infinity, alignment: .leading)
// MARK: - Sheet dismiss (isolated from Settings root)
private struct SettingsSheetDismissButton: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
Button("common.done") { dismiss() }
}
}
@@ -521,231 +222,3 @@ private struct SettingsScrollBottomPadding: ViewModifier {
}
}
}
// MARK: - App language picker row
private struct AppLanguagePickerRow: View {
@Binding var selection: AppUILanguage
private var options: [(id: String, label: String)] {
AppUILanguage.allCases.map { language in
(language.rawValue, AppL10n.string(language.labelKey))
}
}
var body: some View {
PickerRow(
title: AppL10n.string("settings.appLanguage.title"),
options: options,
selection: Binding(
get: { selection.rawValue },
set: { newValue in
selection = AppUILanguage(rawValue: newValue) ?? .auto
}
)
)
}
}
// MARK: - Appearance picker row
private struct AppearancePickerRow: View {
@AppStorage(AppearancePreference.storageKey)
private var appearanceRaw = AppearancePreference.system.rawValue
private var options: [(id: String, label: String)] {
AppearancePreference.allCases.map { preference in
(preference.rawValue, AppL10n.string(preference.labelKey))
}
}
var body: some View {
PickerRow(
title: AppL10n.string("settings.appearance.title"),
options: options,
selection: $appearanceRaw
)
}
}
// MARK: - Flow keep-alive mode picker row
private struct FlowKeepAliveModePickerRow: View {
@Binding var selection: FlowKeepAliveMode
private var options: [(id: String, label: String)] {
FlowKeepAliveMode.allCases.map { mode in
(mode.rawValue, AppL10n.string(mode.labelKey))
}
}
var body: some View {
PickerRow(
title: AppL10n.string("settings.flow.keepAlive.title"),
options: options,
selection: Binding(
get: { selection.rawValue },
set: { newValue in
selection = FlowKeepAliveMode(rawValue: newValue) ?? .default
}
)
)
}
}
// MARK: - Flow inactivity picker row
private struct FlowInactivityPickerRow: View {
@Binding var selection: FlowInactivityDuration
private var options: [(id: String, label: String)] {
FlowInactivityDuration.allCases.map { duration in
(duration.rawValue, AppL10n.string(duration.labelKey))
}
}
var body: some View {
PickerRow(
title: AppL10n.string("settings.flow.inactivity.title"),
options: options,
selection: Binding(
get: { selection.rawValue },
set: { newValue in
selection = FlowInactivityDuration(rawValue: newValue) ?? .default
}
)
)
}
}
// MARK: - Handedness picker row
private struct HandednessPickerRow: View {
@Binding var selection: HandednessPreference
private var options: [(id: String, label: String)] {
HandednessPreference.allCases.map { preference in
(preference.rawValue, AppL10n.string(preference.labelKey))
}
}
var body: some View {
PickerRow(
title: AppL10n.string("settings.handedness.title"),
options: options,
selection: Binding(
get: { selection.rawValue },
set: { newValue in
selection = HandednessPreference(rawValue: newValue) ?? .left
}
)
)
}
}
// MARK: - Picker row (generic)
private struct PickerRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
let title: String
let options: [(id: String, label: String)]
@Binding var selection: String
var body: some View {
HStack {
Text(title)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
Menu {
ForEach(options, id: \.id) { o in
Button {
selection = o.id
} label: {
if o.id == selection {
Label(o.label, systemImage: "checkmark")
} else {
Text(o.label)
}
}
}
} label: {
HStack(spacing: 4) {
Text(currentLabel)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(palette.textTertiary)
}
}
}
.settingsListRow()
}
private var currentLabel: String {
options.first(where: { $0.id == selection })?.label ?? ""
}
}
// MARK: - Locale picker row (with on-device indicator)
private struct LocalePickerRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject private var config = ProviderConfig.shared
let locales: [(id: String, onDevice: Bool)]
@Binding var selection: String
var body: some View {
HStack {
Text("settings.asrLocale")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
Menu {
ForEach(locales, id: \.id) { locale in
Button {
selection = locale.id
} label: {
// iOS Menu converts SwiftUI Label to UIAction (title + image).
// Using Label keeps checkmark + on-device icon both visible.
let name = label(for: locale.id)
if locale.id == selection {
Label(name, systemImage: "checkmark")
} else if locale.onDevice {
Label(name, systemImage: "iphone")
} else {
Text(name)
}
}
}
} label: {
HStack(spacing: 6) {
// On-device badge for the currently selected locale.
if let current = locales.first(where: { $0.id == selection }), current.onDevice {
Image(systemName: "iphone")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(palette.accent)
}
Text(currentLabel)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(palette.textTertiary)
}
}
}
.settingsListRow()
}
private func label(for localeId: String) -> String {
ASRLocaleLabels.displayName(for: localeId, language: config.uiLanguage)
}
private var currentLabel: String {
label(for: selection)
}
}
+20 -2
View File
@@ -99,7 +99,7 @@
"settings.reset.title" = "Reset all settings?";
"settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared.";
"settings.reset.confirm" = "Reset all settings";
"settings.engine.title" = "Speech transcription method";
"settings.engine.title" = "Speech Transcription & Polish";
"settings.engine.subtitle" = "Local: built-in system transcription. Cloud: ASR transcription with optional LLM polish.";
"settings.engine.local.title" = "On-device transcription";
"settings.engine.local.ios26" = "Always on-device, no network.";
@@ -109,6 +109,7 @@
"settings.engine.cloud.badge" = "Cloud engine";
"settings.provider.title" = "Provider";
"settings.provider.personalDictionaryBadge" = "Personal dictionary";
"settings.provider.streamingBadge" = "Streaming";
"settings.provider.subtitle" = "Pick the LLM that polishes your dictation.";
"settings.polishProvider.title" = "Text polish (LLM)";
"settings.polishProvider.subtitle" = "Cleans up the transcript after recognition. Independent from the ASR provider.";
@@ -177,6 +178,14 @@
"settings.systemPrompt.edit" = "Edit system prompt";
"settings.systemPrompt.hint" = "Used by the cloud LLM when polishing your dictation. Local engine skips this step.";
"settings.about.title" = "About";
"settings.daily.title" = "Daily";
"settings.config.title" = "Configuration";
"settings.general.title" = "General";
"settings.general.appearanceLanguage.title" = "Appearance & Language";
"settings.general.keyboard.title" = "Keyboard & Gestures";
"settings.general.sync.title" = "Sync";
"settings.speechRecognition.title" = "Speech Recognition";
"settings.textPolish.title" = "Text Polish";
"settings.preferences.title" = "Preferences";
"settings.dictionaryAndPolish.title" = "Dictionary & polish";
"settings.polishPreferences.title" = "Polish preferences";
@@ -368,14 +377,19 @@
"polishStyles.viewPrompt" = "View full prompt";
"polishStyles.duplicate" = "Duplicate";
"polishStyles.builtin.section" = "Built-in";
"polishStyles.fun.section" = "Fun styles";
"polishStyles.custom.section" = "My styles";
"polishStyles.intro.title" = "Choose a writing personality";
"polishStyles.intro.body" = "The selected style shapes every polished dictation. Your custom styles sync through iCloud when settings sync is enabled.";
"polishStyles.light.description" = "Fix recognition errors and punctuation with minimal rewriting.";
"polishStyles.structured.description" = "Organize multiple points into clear paragraphs and lists.";
"polishStyles.formal.description" = "Professional, restrained writing for email and work.";
"polishStyles.dating.description" = "Warm, playful messages that invite conversation while respecting boundaries.";
"polishStyles.dating.description" = "Warm, playful messages with a light touch of wit.";
"polishStyles.chat.description" = "Short, natural messages without a formal tone.";
"polishStyles.flex.description" = "4A / study-abroad Chinglish with optional luxury seasoning.";
"polishStyles.corp.description" = "Big-tech buzzwords for syncs, pushback, and blame-shifting.";
"polishStyles.diba.description" = "Clean logical takedowns that leave the other side stuck.";
"polishStyles.xhs.description" = "Sisterly Xiaohongshu note voice with hooks, ready to post.";
"polishStyles.custom.description" = "Custom complete writing personality";
"polishStyles.copyName" = "%@ Copy";
"polishStyles.editor.name" = "Name";
@@ -398,6 +412,10 @@
"history.clear.message" = "This cannot be undone.";
"history.clear.confirm" = "Clear all";
"history.clear.button" = "Clear all history";
"history.clearDay.title" = "Delete this day's history?";
"history.clearDay.message" = "All transcripts from this day will be removed. This cannot be undone.";
"history.clearDay.confirm" = "Delete day";
"history.clearDay.button" = "Delete this day's history";
"flow.error.speechRequired" = "Speech recognition access is required for voice sessions.";
"flow.error.micRequired" = "Microphone access is required for background voice sessions.";
"flow.error.micUnavailable" = "Microphone is unavailable on this device.";
+20 -2
View File
@@ -99,7 +99,7 @@
"settings.reset.title" = "重置所有设置?";
"settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态。";
"settings.reset.confirm" = "重置所有设置";
"settings.engine.title" = "语音转写方式";
"settings.engine.title" = "语音转写与润色";
"settings.engine.subtitle" = "本地:系统内置转写;云端:ASR 转写,可用 LLM 润色。";
"settings.engine.local.title" = "本地转写";
"settings.engine.local.ios26" = "全程在手机本地,不用联网";
@@ -109,6 +109,7 @@
"settings.engine.cloud.badge" = "云端引擎";
"settings.provider.title" = "云端引擎";
"settings.provider.personalDictionaryBadge" = "个性词库";
"settings.provider.streamingBadge" = "流式识别";
"settings.provider.subtitle" = "选择 LLM 提供商。";
"settings.polishProvider.title" = "文本润色(LLM";
"settings.polishProvider.subtitle" = "识别完成后整理文字,可与 ASR 服务商不同。";
@@ -177,6 +178,14 @@
"settings.systemPrompt.edit" = "编辑系统提示";
"settings.systemPrompt.hint" = "云端润色时使用。本地识别模式不会用到此提示词。";
"settings.about.title" = "关于";
"settings.daily.title" = "日常";
"settings.config.title" = "配置";
"settings.general.title" = "通用";
"settings.general.appearanceLanguage.title" = "外观与语言";
"settings.general.keyboard.title" = "键盘与操作";
"settings.general.sync.title" = "同步";
"settings.speechRecognition.title" = "语音识别配置";
"settings.textPolish.title" = "文本润色配置";
"settings.preferences.title" = "偏好设置";
"settings.dictionaryAndPolish.title" = "词库与润色";
"settings.polishPreferences.title" = "润色偏好";
@@ -367,14 +376,19 @@
"polishStyles.viewPrompt" = "查看完整提示词";
"polishStyles.duplicate" = "创建副本";
"polishStyles.builtin.section" = "内置风格";
"polishStyles.fun.section" = "趣味风格";
"polishStyles.custom.section" = "我的风格";
"polishStyles.intro.title" = "选择完整写作人格";
"polishStyles.intro.body" = "选中的风格会影响每次听写润色。开启设置同步后,自定义风格会通过 iCloud 保存。";
"polishStyles.light.description" = "修正识别错误与标点,尽量少改原话。";
"polishStyles.structured.description" = "将多个事项整理为清晰段落与列表。";
"polishStyles.formal.description" = "适合邮件和工作的专业、克制表达。";
"polishStyles.dating.description" = "自然会撩、有温度,也尊重对方边界。";
"polishStyles.dating.description" = "有态度、好接,偶尔带一点巧思的恋爱聊天。";
"polishStyles.chat.description" = "简短自然的聊天消息,避免公文腔。";
"polishStyles.flex.description" = "中英夹杂的 4A / 留学装逼腔,偶尔点缀品牌格调。";
"polishStyles.corp.description" = "大厂开会黑话:汇报、吵架、甩锅都像那么回事。";
"polishStyles.diba.description" = "不脏字的逻辑碾压回复,让对方接不住。";
"polishStyles.xhs.description" = "姐妹向小红书笔记体:有钩子、可种草、可直接发帖。";
"polishStyles.custom.description" = "自定义完整写作人格";
"polishStyles.copyName" = "%@副本";
"polishStyles.editor.name" = "名称";
@@ -397,6 +411,10 @@
"history.clear.message" = "此操作无法撤销。";
"history.clear.confirm" = "全部清空";
"history.clear.button" = "清空全部历史";
"history.clearDay.title" = "删除这一天的记录?";
"history.clearDay.message" = "将删除该日全部语音记录,此操作无法撤销。";
"history.clearDay.confirm" = "删除当天";
"history.clearDay.button" = "删除当天历史";
"flow.error.speechRequired" = "需要语音识别权限才能使用语音会话。";
"flow.error.micRequired" = "需要麦克风权限才能维持后台语音会话。";
"flow.error.micUnavailable" = "当前设备无法使用麦克风。";