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:
@@ -107,7 +107,6 @@
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>picture-in-picture</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -43,7 +43,7 @@ struct APISettingsCard: View {
|
||||
rowDivider
|
||||
SettingsProviderToolsRow(validate: validateConnection)
|
||||
}
|
||||
.modifier(SettingsSurfaceCardModifier(enabled: showsSurface))
|
||||
.surfaceCard(enabled: showsSurface)
|
||||
}
|
||||
|
||||
private var rowDivider: some View {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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" = "当前设备无法使用麦克风。";
|
||||
|
||||
Reference in New Issue
Block a user