Files
OSGKeyboard/OSGKeyboard/Views/PolishStylesView.swift
T
Rocky 7df5bbdaa0 Refine adaptive polish and cross-platform account support
Improve learned-style and clipboard skill presentation while keeping shared account clients buildable in the source-composed macOS target.
2026-08-22 17:06:22 +08:00

547 lines
21 KiB
Swift

// PolishStylesView.swift
// OSGKeyboard · Main App
//
// Main-app editor for complete polish writing personalities. The keyboard
// reads the selected pack from App Group storage on the next polish request.
import OSGKeyboardShared
import SwiftUI
@MainActor
struct PolishStylesView: View {
@Environment(\.themePalette) private var palette
@ObservedObject private var config = ProviderConfig.shared
@ObservedObject private var history = SpeechHistoryStore.shared
@State private var catalog = AppGroupStore().polishStyleCatalog
@State private var activeID = AppGroupStore().activePolishStyleId
/// Drives the editor sheet via `sheet(item:)` so create/edit always
/// receives a concrete pack (avoids `isPresented` + nil race showing defaults).
@State private var editingPack: PolishStylePack?
@State private var viewingPack: PolishStylePack?
@State private var errorMessage: String?
@State private var isGeneratingLearnedStyle = false
private let store = AppGroupStore()
private let columns = [
GridItem(.flexible(), spacing: Spacing.sm),
GridItem(.flexible(), spacing: Spacing.sm)
]
var body: some View {
NavigationStack {
ScrollView {
CardPageContent(spacing: Spacing.xl) {
styleLearningCard
packGridSection(
title: "polishStyles.builtin.section",
packs: PolishStylePackCatalog.BuiltinStyleGroup.practical.packs
)
packGridSection(
title: "polishStyles.fun.section",
packs: PolishStylePackCatalog.BuiltinStyleGroup.fun.packs
)
if !catalog.entries.isEmpty {
packGridSection(
title: "polishStyles.custom.section",
packs: PolishStylePackCatalog.all(userCatalog: catalog)
.filter { $0.kind == .user }
)
}
}
.tabBarScrollBottomPadding()
}
.background(palette.background)
.navigationTitle("polishStyles.title")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
editingPack = Self.makeDraftPack()
} label: {
Image(systemName: "plus")
}
.disabled(catalog.entries.count >= PolishStyleLimits.maximumUserPacks)
.accessibilityLabel(Text("polishStyles.add"))
}
}
}
.sheet(item: $editingPack) { pack in
PolishStyleEditorSheet(
pack: pack,
isNew: !catalog.entries.contains(where: { $0.id == pack.id })
) { saved in
save(saved)
}
}
.sheet(item: $viewingPack) { pack in
PolishStylePromptDetailSheet(pack: pack, language: config.uiLanguage)
}
.alert(
Text("polishStyles.error.title"),
isPresented: Binding(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)
) {
Button("common.done") { errorMessage = nil }
} message: {
Text(errorMessage ?? "")
}
.task {
reload()
await PolishStyleCloudSync.shared.pullAndMergeIfEnabled()
reload()
}
.onReceive(NotificationCenter.default.publisher(for: .polishStylesDidSyncFromCloud)) { _ in
reload()
}
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
reload()
}
}
private var styleLearningCorpus: PolishStyleLearningCorpus {
PolishStyleLearningCorpusBuilder.build(from: history.snapshot())
}
private var styleLearningCard: some View {
let corpus = styleLearningCorpus
let required = PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
let reachedLimit = catalog.entries.count >= PolishStyleLimits.maximumUserPacks
let isActionAvailable = corpus.isReady && !reachedLimit
let canGenerate = isActionAvailable && !isGeneratingLearnedStyle
return VStack(alignment: .leading, spacing: Spacing.md) {
HStack(alignment: .top, spacing: Spacing.md) {
Image(systemName: "waveform")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(palette.accent)
.frame(width: 42, height: 42)
.background(
palette.accentMuted,
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
)
VStack(alignment: .leading, spacing: 3) {
Text("polishStyles.learn.title")
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
Text("polishStyles.learn.body")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
ProgressView(
value: Double(min(corpus.effectiveCharacterCount, required)),
total: Double(required)
)
.tint(palette.accent)
HStack {
Text(
AppL10n.format(
"polishStyles.learn.progress",
Int64(corpus.effectiveCharacterCount),
Int64(required)
)
)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
Spacer()
Text(
corpus.isReady
? AppL10n.string("polishStyles.learn.ready")
: AppL10n.format(
"polishStyles.learn.remaining",
Int64(corpus.remainingCharacterCount)
)
)
.font(TypeStyle.caption2)
.foregroundStyle(corpus.isReady ? palette.accent : palette.textTertiary)
}
Button {
generateLearnedStyle(from: corpus)
} label: {
HStack(spacing: Spacing.sm) {
if isGeneratingLearnedStyle {
ProgressView()
.controlSize(.small)
.tint(isActionAvailable ? palette.textOnAccent : palette.textSecondary)
} else {
Image(systemName: "sparkles")
}
Text(
isGeneratingLearnedStyle
? AppL10n.string("polishStyles.learn.generating")
: AppL10n.string("polishStyles.learn.action")
)
}
.font(TypeStyle.bodyEmph)
.foregroundStyle(
isActionAvailable ? palette.textOnAccent : palette.textSecondary
)
.frame(maxWidth: .infinity, minHeight: 48)
.background(
isActionAvailable ? palette.accent : palette.surfaceElevated,
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(
isActionAvailable ? Color.clear : palette.dividerStrong,
lineWidth: 0.5
)
)
.contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
}
.buttonStyle(.plain)
.disabled(!canGenerate)
Text(
reachedLimit
? AppL10n.string("polishStyles.learn.limit")
: AppL10n.string("polishStyles.learn.privacy")
)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(Spacing.lg)
.surfaceCard()
}
private func packGridSection(
title: LocalizedStringKey,
packs: [PolishStylePack]
) -> some View {
CardSection(title) {
LazyVGrid(columns: columns, spacing: Spacing.sm) {
ForEach(packs) { pack in
packCard(pack)
}
}
}
}
private func packCard(_ pack: PolishStylePack) -> some View {
let isSelected = pack.id == activeID
return ZStack(alignment: .topTrailing) {
Button {
activate(pack)
} label: {
VStack(alignment: .leading, spacing: Spacing.sm) {
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(2)
Spacer()
}
.frame(maxWidth: .infinity, minHeight: 96, alignment: .leading)
.padding(Spacing.md)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Button {
if pack.kind == .builtin {
viewingPack = pack
} else {
editingPack = pack
}
} label: {
CatalogCardChrome.editIcon(palette: palette)
}
.padding(Spacing.sm)
.buttonStyle(.plain)
.accessibilityLabel(Text("polishStyles.edit"))
if isSelected {
CatalogCardChrome.checkIcon(palette: palette)
}
}
.background(
isSelected ? palette.accentMuted : palette.surface,
in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(
isSelected ? palette.accent : palette.divider,
lineWidth: isSelected ? 1.5 : 0.5
)
)
.clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.contextMenu {
Button("polishStyles.duplicate") {
duplicate(pack)
}
if pack.kind == .user {
Button("common.delete", role: .destructive) {
delete(pack)
}
}
}
}
private func generateLearnedStyle(from corpus: PolishStyleLearningCorpus) {
guard corpus.isReady, !isGeneratingLearnedStyle else { return }
isGeneratingLearnedStyle = true
Task {
defer { isGeneratingLearnedStyle = false }
do {
let generated = try await PolishStyleLearningService(store: store)
.generateStyle(
from: corpus,
outputLanguage: config.uiLanguage
)
// Always let the user inspect and edit the learned prompt before
// it is saved, synced, or made active.
editingPack = generated
} catch {
errorMessage = localizedLearningError(error)
}
}
}
private func descriptionKey(for pack: PolishStylePack) -> LocalizedStringKey {
guard pack.kind == .builtin else { return "polishStyles.custom.description" }
switch pack.id {
case "builtin.structured": return "polishStyles.structured.description"
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"
}
}
private func reload() {
catalog = store.polishStyleCatalog
activeID = store.activePolishStyleId
}
private func activate(_ pack: PolishStylePack) {
store.setActivePolishStyleId(pack.id)
activeID = pack.id
Task {
try? await AppCloudSync.shared.settingsSyncService.pushLocalIfEnabled()
}
}
private func save(_ pack: PolishStylePack) {
do {
try catalog.upsert(pack)
store.setPolishStyleCatalog(catalog)
store.setActivePolishStyleId(pack.id)
activeID = pack.id
Task {
try? await PolishStyleCloudSync.shared.pushLocalIfEnabled(catalog)
try? await AppCloudSync.shared.settingsSyncService.pushLocalIfEnabled()
}
} catch {
errorMessage = localized(error)
}
}
private func duplicate(_ pack: PolishStylePack) {
guard catalog.entries.count < PolishStyleLimits.maximumUserPacks else {
errorMessage = AppL10n.string("polishStyles.error.limit")
return
}
editingPack = PolishStylePack(
name: String(
format: AppL10n.string("polishStyles.copyName"),
pack.displayName(language: config.uiLanguage)
),
prompt: pack.prompt,
allowsAddedEmoji: pack.allowsAddedEmoji
)
}
private static func makeDraftPack() -> PolishStylePack {
PolishStylePack(
name: "",
prompt: PolishStylePackCatalog.newUserPromptTemplate
)
}
private func delete(_ pack: PolishStylePack) {
guard pack.kind == .user else { return }
catalog.recordDeletion(of: pack.id)
store.setPolishStyleCatalog(catalog)
if activeID == pack.id {
activeID = PolishStylePackCatalog.defaultID
store.setActivePolishStyleId(activeID)
}
Task {
try? await PolishStyleCloudSync.shared.pushLocalIfEnabled(catalog)
try? await AppCloudSync.shared.settingsSyncService.pushLocalIfEnabled()
}
}
private func localizedLearningError(_ error: Error) -> String {
switch error as? PolishStyleLearningError {
case .insufficientCorpus:
return AppL10n.string("polishStyles.learn.error.insufficient")
case .invalidResponse:
return AppL10n.string("polishStyles.learn.error.invalidResponse")
case .promptTooLong:
return AppL10n.string("polishStyles.learn.error.promptTooLong")
case .requestTooLarge:
return AppL10n.string("polishStyles.learn.error.requestTooLarge")
case nil:
return AppL10n.string("polishStyles.learn.error.request")
}
}
private func localized(_ error: Error) -> String {
switch error as? PolishStyleValidationError {
case .emptyName: return AppL10n.string("polishStyles.error.emptyName")
case .emptyPrompt: return AppL10n.string("polishStyles.error.emptyPrompt")
case .tooManyUserPacks: return AppL10n.string("polishStyles.error.limit")
case .promptTooLong: return AppL10n.string("polishStyles.error.promptTooLong")
case .builtinIsImmutable: return AppL10n.string("polishStyles.error.builtin")
case nil: return AppL10n.string("polishStyles.error.generic")
}
}
}
private struct PolishStylePromptDetailSheet: View {
let pack: PolishStylePack
let language: AppUILanguage
@Environment(\.dismiss) private var dismiss
@Environment(\.themePalette) private var palette
var body: some View {
NavigationStack {
ScrollView {
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))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("common.done") { dismiss() }
}
}
}
}
}
private struct PolishStyleEditorSheet: View {
let pack: PolishStylePack
let isNew: Bool
let onSave: (PolishStylePack) -> Void
@Environment(\.dismiss) private var dismiss
@Environment(\.themePalette) private var palette
@State private var name: String
@State private var prompt: String
@State private var allowsAddedEmoji: Bool
init(
pack: PolishStylePack,
isNew: Bool,
onSave: @escaping (PolishStylePack) -> Void
) {
self.pack = pack
self.isNew = isNew
self.onSave = onSave
_name = State(initialValue: pack.name)
_prompt = State(initialValue: pack.prompt)
_allowsAddedEmoji = State(initialValue: pack.allowsAddedEmoji)
}
var body: some View {
NavigationStack {
Form {
Section("polishStyles.editor.name") {
TextField("polishStyles.editor.namePlaceholder", text: $name)
}
Section {
Toggle("polishStyles.editor.allowsAddedEmoji", isOn: $allowsAddedEmoji)
} footer: {
Text("polishStyles.editor.allowsAddedEmoji.hint")
}
Section {
TextEditor(text: $prompt)
.font(.body.monospaced())
.frame(minHeight: 320)
.onChange(of: prompt) { _, newValue in
// Paste-only custom prompts that declare emoji opt-in
// should flip the toggle so post-processing keeps them.
if !allowsAddedEmoji,
PolishStylePack.promptDeclaresAddedEmojiOptIn(newValue) {
allowsAddedEmoji = true
}
}
} header: {
HStack {
Text("polishStyles.editor.prompt")
Spacer()
Text("\(prompt.count)/\(PolishStyleLimits.maximumPromptCharacters)")
.foregroundStyle(
prompt.count > PolishStyleLimits.maximumPromptCharacters
? palette.danger
: palette.textTertiary
)
}
} footer: {
Text("polishStyles.editor.hint")
}
}
.navigationTitle(isNew ? "polishStyles.add" : "polishStyles.edit")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("common.cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("common.save") {
let result = PolishStylePack(
id: pack.id,
name: name,
prompt: prompt,
allowsAddedEmoji: allowsAddedEmoji
|| PolishStylePack.promptDeclaresAddedEmojiOptIn(prompt),
kind: .user,
createdAt: pack.createdAt
)
onSave(result)
dismiss()
}
.disabled(
name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| prompt.count > PolishStyleLimits.maximumPromptCharacters
)
}
}
}
}
}