feat(polish): add configurable style packs

Add shared prompt composition, custom style management, iCloud sync, and native iOS/macOS selection interfaces so users can keep a consistent writing voice across devices.
This commit is contained in:
Rocky
2026-07-26 12:34:43 +08:00
parent e6f99d2744
commit 937ce33f05
29 changed files with 2388 additions and 87 deletions
@@ -1,7 +1,7 @@
// MinimalTabBar.swift
// OSGKeyboard · Main App
//
// Bottom tab bar four icons, no labels.
// Bottom tab bar five icons, no labels.
// Capsule uses iOS 26 Liquid Glass (.regular.interactive) so content
// behind the dock refracts through on scroll.
@@ -12,6 +12,7 @@ enum AppTab: Int, CaseIterable {
case keyboard
case history
case dictionary
case styles
case settings
var icon: MaterialIconName {
@@ -19,6 +20,7 @@ enum AppTab: Int, CaseIterable {
case .keyboard: return .keyboard
case .history: return .menuBook
case .dictionary: return .menuBook // unused dictionary uses SF Symbol
case .styles: return .menuBook // unused styles uses SF Symbol
case .settings: return .settings
}
}
@@ -27,6 +29,7 @@ enum AppTab: Int, CaseIterable {
var sfSymbol: String? {
switch self {
case .dictionary: return "square.stack.3d.down.right.fill"
case .styles: return "text.badge.star"
default: return nil
}
}
@@ -36,6 +39,7 @@ enum AppTab: Int, CaseIterable {
case .keyboard: return "tab.keyboard"
case .history: return "tab.history"
case .dictionary: return "tab.dictionary"
case .styles: return "tab.styles"
case .settings: return "tab.settings"
}
}
@@ -48,6 +52,7 @@ enum AppTab: Int, CaseIterable {
case .keyboard: return "house"
case .history: return "clock.arrow.circlepath"
case .dictionary: return "character.book.closed"
case .styles: return "text.badge.star"
case .settings: return "gearshape"
}
}
+1
View File
@@ -87,6 +87,7 @@ struct HistoryView: View {
.textCase(.uppercase)
.tracking(0.5)
}
.listSectionMargins(.horizontal, Spacing.lg)
}
}
.listStyle(.insetGrouped)
+2
View File
@@ -18,6 +18,8 @@ struct MainTabContent: View {
HistoryView()
case .dictionary:
PersonalDictionaryView()
case .styles:
PolishStylesView()
case .settings:
SettingsView(presentation: .tab)
}
+393
View File
@@ -0,0 +1,393 @@
// 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 SwiftUI
import OSGKeyboardShared
@MainActor
struct PolishStylesView: View {
@Environment(\.themePalette) private var palette
@ObservedObject private var config = ProviderConfig.shared
@State private var catalog = AppGroupStore().polishStyleCatalog
@State private var activeID = AppGroupStore().activePolishStyleId
@State private var editingPack: PolishStylePack?
@State private var viewingPack: PolishStylePack?
@State private var showEditor = false
@State private var errorMessage: String?
private let store = AppGroupStore()
private let columns = [
GridItem(.flexible(), spacing: Spacing.md),
GridItem(.flexible(), spacing: Spacing.md),
]
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.xl) {
packGridSection(
title: "polishStyles.builtin.section",
packs: PolishStylePackCatalog.builtins
)
if !catalog.entries.isEmpty {
packGridSection(
title: "polishStyles.custom.section",
packs: PolishStylePackCatalog.all(userCatalog: catalog)
.filter { $0.kind == .user }
)
}
}
.padding(.horizontal, Spacing.md)
.padding(.top, Spacing.sm)
.padding(.bottom, Spacing.xl)
}
.background(palette.background)
.tabBarScrollBottomPadding()
.navigationTitle("polishStyles.title")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
editingPack = nil
showEditor = true
} label: {
Image(systemName: "plus")
}
.disabled(catalog.entries.count >= PolishStyleLimits.maximumUserPacks)
.accessibilityLabel(Text("polishStyles.add"))
}
}
}
.sheet(isPresented: $showEditor) {
PolishStyleEditorSheet(pack: editingPack) { pack in
save(pack)
}
}
.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 func packGridSection(
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) {
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) {
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)
Text(descriptionKey(for: pack))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.lineLimit(3)
Spacer()
}
.frame(maxWidth: .infinity, minHeight: 132, alignment: .leading)
.padding(Spacing.md)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Button {
if pack.kind == .builtin {
viewingPack = pack
} else {
editingPack = pack
showEditor = true
}
} label: {
Image(systemName: pack.kind == .builtin ? "eye" : "pencil")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(palette.textSecondary)
.frame(width: 30, height: 30)
.background(palette.background.opacity(0.75), in: Circle())
}
.padding(Spacing.sm)
.buttonStyle(.plain)
.accessibilityLabel(
Text(pack.kind == .builtin ? "polishStyles.viewPrompt" : "polishStyles.edit")
)
if isSelected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 21, weight: .semibold))
.foregroundStyle(palette.accent)
.background(Color.white, in: Circle())
.padding(Spacing.sm)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
.allowsHitTesting(false)
}
}
.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 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 {
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"
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
)
showEditor = true
}
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 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 {
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)
}
.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 onSave: (PolishStylePack) -> Void
@Environment(\.dismiss) private var dismiss
@Environment(\.themePalette) private var palette
@State private var name: String
@State private var prompt: String
init(pack: PolishStylePack?, onSave: @escaping (PolishStylePack) -> Void) {
self.pack = pack
self.onSave = onSave
_name = State(initialValue: pack?.name ?? "")
_prompt = State(initialValue: pack?.prompt ?? PolishStylePackCatalog.newUserPromptTemplate)
}
var body: some View {
NavigationStack {
Form {
Section("polishStyles.editor.name") {
TextField("polishStyles.editor.namePlaceholder", text: $name)
}
Section {
TextEditor(text: $prompt)
.font(.body.monospaced())
.frame(minHeight: 320)
} 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(pack == nil ? "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 ?? "user.\(UUID().uuidString.lowercased())",
name: name,
prompt: prompt,
kind: .user,
createdAt: pack?.createdAt ?? Date()
)
onSave(result)
dismiss()
}
.disabled(
name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| prompt.count > PolishStyleLimits.maximumPromptCharacters
)
}
}
}
}
}
+1 -1
View File
@@ -218,7 +218,7 @@ struct SettingsView: View {
private var dictionaryAndPolishSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.dictionaryAndPolish.title")
sectionHeader("settings.polishPreferences.title")
VStack(spacing: 0) {
polishIntensityPreferenceRows
+31
View File
@@ -179,6 +179,7 @@
"settings.about.title" = "About";
"settings.preferences.title" = "Preferences";
"settings.dictionaryAndPolish.title" = "Dictionary & polish";
"settings.polishPreferences.title" = "Polish preferences";
"settings.handedness.title" = "Handedness";
"settings.handedness.left" = "Left hand";
"settings.handedness.right" = "Right hand";
@@ -357,8 +358,38 @@
"tab.keyboard" = "Keyboard";
"tab.history" = "History";
"tab.dictionary" = "Dictionary";
"tab.styles" = "Styles";
"tab.settings" = "Settings";
/* Polish style packs */
"polishStyles.title" = "Polish styles";
"polishStyles.add" = "Add style";
"polishStyles.edit" = "Edit style";
"polishStyles.viewPrompt" = "View full prompt";
"polishStyles.duplicate" = "Duplicate";
"polishStyles.builtin.section" = "Built-in";
"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.chat.description" = "Short, natural messages without a formal tone.";
"polishStyles.custom.description" = "Custom complete writing personality";
"polishStyles.copyName" = "%@ Copy";
"polishStyles.editor.name" = "Name";
"polishStyles.editor.namePlaceholder" = "Style name";
"polishStyles.editor.prompt" = "Complete prompt";
"polishStyles.editor.hint" = "Use {{DICTIONARY}} where the personal dictionary should be inserted. System safety, rewrite intensity, and output rules are appended automatically.";
"polishStyles.error.title" = "Couldnt save style";
"polishStyles.error.emptyName" = "Enter a style name.";
"polishStyles.error.emptyPrompt" = "The prompt cannot be empty.";
"polishStyles.error.limit" = "You can save up to 8 custom styles.";
"polishStyles.error.promptTooLong" = "The prompt can contain up to 6,000 characters.";
"polishStyles.error.builtin" = "Built-in styles cannot be changed. Duplicate one to customize it.";
"polishStyles.error.generic" = "Try again.";
/* History */
"history.title" = "History";
"history.subtitle" = "Saved on this device only.";
@@ -179,6 +179,7 @@
"settings.about.title" = "关于";
"settings.preferences.title" = "偏好设置";
"settings.dictionaryAndPolish.title" = "词库与润色";
"settings.polishPreferences.title" = "润色偏好";
"settings.handedness.title" = "握持偏好";
"settings.handedness.left" = "左手";
"settings.handedness.right" = "右手";
@@ -356,8 +357,38 @@
"tab.keyboard" = "键盘";
"tab.history" = "历史";
"tab.dictionary" = "词库";
"tab.styles" = "风格";
"tab.settings" = "设置";
/* 润色风格包 */
"polishStyles.title" = "润色风格";
"polishStyles.add" = "添加风格";
"polishStyles.edit" = "编辑风格";
"polishStyles.viewPrompt" = "查看完整提示词";
"polishStyles.duplicate" = "创建副本";
"polishStyles.builtin.section" = "内置风格";
"polishStyles.custom.section" = "我的风格";
"polishStyles.intro.title" = "选择完整写作人格";
"polishStyles.intro.body" = "选中的风格会影响每次听写润色。开启设置同步后,自定义风格会通过 iCloud 保存。";
"polishStyles.light.description" = "修正识别错误与标点,尽量少改原话。";
"polishStyles.structured.description" = "将多个事项整理为清晰段落与列表。";
"polishStyles.formal.description" = "适合邮件和工作的专业、克制表达。";
"polishStyles.dating.description" = "自然会撩、有温度,也尊重对方边界。";
"polishStyles.chat.description" = "简短自然的聊天消息,避免公文腔。";
"polishStyles.custom.description" = "自定义完整写作人格";
"polishStyles.copyName" = "%@副本";
"polishStyles.editor.name" = "名称";
"polishStyles.editor.namePlaceholder" = "风格名称";
"polishStyles.editor.prompt" = "完整提示词";
"polishStyles.editor.hint" = "使用 {{DICTIONARY}} 指定个人词典的插入位置。系统会自动追加安全边界、润色力度和输出契约。";
"polishStyles.error.title" = "无法保存风格";
"polishStyles.error.emptyName" = "请输入风格名称。";
"polishStyles.error.emptyPrompt" = "提示词不能为空。";
"polishStyles.error.limit" = "最多可保存 8 个自定义风格。";
"polishStyles.error.promptTooLong" = "提示词最多可输入 6,000 个字符。";
"polishStyles.error.builtin" = "内置风格不能直接修改,请创建副本后自定义。";
"polishStyles.error.generic" = "请重试。";
/* History */
"history.title" = "历史";
"history.subtitle" = "仅保存在本机。";