7df5bbdaa0
Improve learned-style and clipboard skill presentation while keeping shared account clients buildable in the source-composed macOS target.
571 lines
22 KiB
Swift
571 lines
22 KiB
Swift
// MacPolishStylesView.swift
|
|
// OSGKeyboard · Mac
|
|
//
|
|
// macOS counterpart of the iOS polish-styles tab. Both surfaces edit the same
|
|
// Shared model and iCloud payload.
|
|
|
|
import SwiftUI
|
|
|
|
struct MacPolishStylesView: View {
|
|
@ObservedObject var viewModel: MacDictationViewModel
|
|
@ObservedObject private var history = SpeechHistoryStore.shared
|
|
@Environment(\.themePalette) private var palette
|
|
|
|
@State private var editingPack: PolishStylePack?
|
|
@State private var viewingPack: PolishStylePack?
|
|
@State private var errorMessage: String?
|
|
@State private var isGeneratingLearnedStyle = false
|
|
|
|
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
|
private var store: AppGroupStore { AppGroupStore(defaults: viewModel.defaults) }
|
|
private var catalog: PolishStyleCatalog {
|
|
_ = viewModel.polishStylesRevision
|
|
return store.polishStyleCatalog
|
|
}
|
|
private var activeID: String {
|
|
_ = viewModel.polishStylesRevision
|
|
return store.activePolishStyleId
|
|
}
|
|
/// At the default window (~540pt content), ~170pt min yields 3 columns;
|
|
/// narrower → 2, wider → 4+. Cards stretch equally (no max width).
|
|
private var columns: [GridItem] {
|
|
[
|
|
GridItem(
|
|
.adaptive(minimum: MacMetrics.polishStyleCardMinWidth),
|
|
spacing: Spacing.md,
|
|
alignment: .top
|
|
)
|
|
]
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
MacPageHeader(
|
|
title: MacL10n.string("mac.section.styles", language: lang),
|
|
subtitle: MacL10n.string("mac.styles.subtitle", language: lang)
|
|
) {
|
|
Button {
|
|
editingPack = PolishStylePack(
|
|
name: "",
|
|
prompt: PolishStylePackCatalog.newUserPromptTemplate
|
|
)
|
|
} label: {
|
|
Label(
|
|
MacL10n.string("mac.styles.add", language: lang),
|
|
systemImage: "plus"
|
|
)
|
|
}
|
|
.buttonStyle(MacHeaderActionButtonStyle())
|
|
.disabled(catalog.entries.count >= PolishStyleLimits.maximumUserPacks)
|
|
}
|
|
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
|
|
styleLearningCard
|
|
styleSection(
|
|
title: MacL10n.string("mac.styles.builtin", language: lang),
|
|
packs: PolishStylePackCatalog.BuiltinStyleGroup.practical.packs
|
|
)
|
|
styleSection(
|
|
title: MacL10n.string("mac.styles.fun", language: lang),
|
|
packs: PolishStylePackCatalog.BuiltinStyleGroup.fun.packs
|
|
)
|
|
if !catalog.entries.isEmpty {
|
|
styleSection(
|
|
title: MacL10n.string("mac.styles.custom", language: lang),
|
|
packs: catalog.entries
|
|
)
|
|
}
|
|
}
|
|
.padding(.horizontal, MacMetrics.pageHorizontalInset)
|
|
.padding(.bottom, Spacing.xl)
|
|
}
|
|
}
|
|
.background(palette.background)
|
|
.sheet(item: $editingPack) { pack in
|
|
MacPolishStyleEditor(
|
|
pack: pack,
|
|
isNew: !catalog.entries.contains(where: { $0.id == pack.id }),
|
|
language: lang
|
|
) { saved in
|
|
save(saved)
|
|
}
|
|
}
|
|
.sheet(item: $viewingPack) { pack in
|
|
MacPolishStylePromptDetailSheet(pack: pack, language: lang)
|
|
}
|
|
.alert(
|
|
MacL10n.string("mac.styles.error", language: lang),
|
|
isPresented: Binding(
|
|
get: { errorMessage != nil },
|
|
set: { if !$0 { errorMessage = nil } }
|
|
)
|
|
) {
|
|
Button(MacL10n.string("mac.done", language: lang)) { errorMessage = nil }
|
|
} message: {
|
|
Text(errorMessage ?? "")
|
|
}
|
|
.task {
|
|
await MacICloudSyncBootstrap.polishStyleSync.pullAndMergeIfEnabled()
|
|
viewModel.refreshPolishStyles()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .polishStylesDidSyncFromCloud)) { _ in
|
|
viewModel.refreshPolishStyles()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
|
|
viewModel.refreshPolishStyles()
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
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(MacL10n.string("mac.styles.learn.title", language: lang))
|
|
.font(TypeStyle.bodyEmph)
|
|
.foregroundStyle(palette.textPrimary)
|
|
Text(MacL10n.string("mac.styles.learn.body", language: lang))
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.textSecondary)
|
|
}
|
|
}
|
|
|
|
ProgressView(
|
|
value: Double(min(corpus.effectiveCharacterCount, required)),
|
|
total: Double(required)
|
|
)
|
|
.tint(palette.accent)
|
|
|
|
HStack {
|
|
Text(
|
|
MacL10n.format(
|
|
"mac.styles.learn.progress",
|
|
language: lang,
|
|
Int64(corpus.effectiveCharacterCount),
|
|
Int64(required)
|
|
)
|
|
)
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.textTertiary)
|
|
Spacer()
|
|
Text(
|
|
corpus.isReady
|
|
? MacL10n.string("mac.styles.learn.ready", language: lang)
|
|
: MacL10n.format(
|
|
"mac.styles.learn.remaining",
|
|
language: lang,
|
|
Int64(corpus.remainingCharacterCount)
|
|
)
|
|
)
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(corpus.isReady ? palette.accent : palette.textTertiary)
|
|
}
|
|
|
|
HStack {
|
|
Text(
|
|
MacL10n.string(
|
|
reachedLimit
|
|
? "mac.styles.learn.limit"
|
|
: "mac.styles.learn.privacy",
|
|
language: lang
|
|
)
|
|
)
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.textTertiary)
|
|
Spacer()
|
|
Button {
|
|
generateLearnedStyle(from: corpus)
|
|
} label: {
|
|
if isGeneratingLearnedStyle {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
}
|
|
Text(
|
|
MacL10n.string(
|
|
isGeneratingLearnedStyle
|
|
? "mac.styles.learn.generating"
|
|
: "mac.styles.learn.action",
|
|
language: lang
|
|
)
|
|
)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(palette.accent)
|
|
.disabled(!corpus.isReady || isGeneratingLearnedStyle || reachedLimit)
|
|
}
|
|
}
|
|
.padding(Spacing.lg)
|
|
.surfaceCard()
|
|
}
|
|
|
|
private func styleSection(title: String, packs: [PolishStylePack]) -> some View {
|
|
VStack(alignment: .leading, spacing: Spacing.sm) {
|
|
Text(title)
|
|
.font(MacSettingsType.sectionTitle)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.textCase(.uppercase)
|
|
|
|
LazyVGrid(columns: columns, alignment: .leading, spacing: Spacing.md) {
|
|
ForEach(packs) { pack in
|
|
styleCard(pack)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func styleCard(_ pack: PolishStylePack) -> some View {
|
|
MacPolishStyleCard(
|
|
name: pack.displayName(language: lang),
|
|
subtitle: subtitle(for: pack),
|
|
isSelected: pack.id == activeID,
|
|
isUserStyle: pack.kind == .user,
|
|
language: lang,
|
|
activate: {
|
|
activate(pack)
|
|
},
|
|
// Builtin → view prompt; custom → edit (matches iOS).
|
|
primaryAction: {
|
|
if pack.kind == .builtin {
|
|
viewingPack = pack
|
|
} else {
|
|
editingPack = pack
|
|
}
|
|
},
|
|
duplicate: {
|
|
editingPack = PolishStylePack(
|
|
name: "\(pack.displayName(language: lang)) \(MacL10n.string("mac.styles.copy", language: lang))",
|
|
prompt: pack.prompt,
|
|
allowsAddedEmoji: pack.allowsAddedEmoji
|
|
)
|
|
},
|
|
delete: {
|
|
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: lang)
|
|
// Review is mandatory before the learned prompt enters sync or
|
|
// becomes the active dictation personality.
|
|
editingPack = generated
|
|
} catch {
|
|
errorMessage = localizedLearningError(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func localizedLearningError(_ error: Error) -> String {
|
|
switch error as? PolishStyleLearningError {
|
|
case .insufficientCorpus:
|
|
return MacL10n.string("mac.styles.learn.error.insufficient", language: lang)
|
|
case .invalidResponse:
|
|
return MacL10n.string("mac.styles.learn.error.invalidResponse", language: lang)
|
|
case .promptTooLong:
|
|
return MacL10n.string("mac.styles.learn.error.promptTooLong", language: lang)
|
|
case .requestTooLarge:
|
|
return MacL10n.string("mac.styles.learn.error.requestTooLarge", language: lang)
|
|
case nil:
|
|
return MacL10n.string("mac.styles.learn.error.request", language: lang)
|
|
}
|
|
}
|
|
|
|
private func subtitle(for pack: PolishStylePack) -> String {
|
|
if pack.kind == .user {
|
|
return MacL10n.string("mac.styles.customDescription", language: lang)
|
|
}
|
|
return MacL10n.string("mac.styles.\(pack.id.dropFirst("builtin.".count))", language: lang)
|
|
}
|
|
|
|
private func activate(_ pack: PolishStylePack) {
|
|
store.setActivePolishStyleId(pack.id)
|
|
viewModel.refreshPolishStyles()
|
|
Task {
|
|
try? await MacICloudSyncBootstrap.settingsSync.pushLocalIfEnabled()
|
|
}
|
|
}
|
|
|
|
private func save(_ pack: PolishStylePack) {
|
|
var updated = catalog
|
|
do {
|
|
try updated.upsert(pack)
|
|
store.setPolishStyleCatalog(updated)
|
|
store.setActivePolishStyleId(pack.id)
|
|
viewModel.refreshPolishStyles()
|
|
Task {
|
|
try? await MacICloudSyncBootstrap.polishStyleSync.pushLocalIfEnabled(updated)
|
|
try? await MacICloudSyncBootstrap.settingsSync.pushLocalIfEnabled()
|
|
}
|
|
} catch {
|
|
errorMessage = MacL10n.string("mac.styles.validation", language: lang)
|
|
}
|
|
}
|
|
|
|
private func delete(_ pack: PolishStylePack) {
|
|
var updated = catalog
|
|
updated.recordDeletion(of: pack.id)
|
|
store.setPolishStyleCatalog(updated)
|
|
if activeID == pack.id {
|
|
store.setActivePolishStyleId(PolishStylePackCatalog.defaultID)
|
|
}
|
|
viewModel.refreshPolishStyles()
|
|
Task {
|
|
try? await MacICloudSyncBootstrap.polishStyleSync.pushLocalIfEnabled(updated)
|
|
try? await MacICloudSyncBootstrap.settingsSync.pushLocalIfEnabled()
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MacPolishStyleCard: View {
|
|
let name: String
|
|
let subtitle: String
|
|
let isSelected: Bool
|
|
let isUserStyle: Bool
|
|
let language: AppUILanguage
|
|
let activate: () -> Void
|
|
let primaryAction: () -> Void
|
|
let duplicate: () -> Void
|
|
let delete: () -> Void
|
|
|
|
@Environment(\.themePalette) private var palette
|
|
@State private var isHovering = false
|
|
|
|
private let shape = RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .topTrailing) {
|
|
Button(action: activate) {
|
|
VStack(alignment: .leading, spacing: Spacing.sm) {
|
|
Text(name)
|
|
.font(TypeStyle.bodyEmph)
|
|
.foregroundStyle(palette.textPrimary)
|
|
.lineLimit(1)
|
|
.padding(.trailing, 32)
|
|
|
|
Text(subtitle)
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.textTertiary)
|
|
.lineLimit(3)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 132, alignment: .leading)
|
|
.padding(Spacing.md)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
// Pencil opens the prompt (built-in, read-only) or the editor (custom).
|
|
Button(action: primaryAction) {
|
|
Image(systemName: "pencil")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(palette.textSecondary)
|
|
.frame(width: 30, height: 30)
|
|
.background(palette.background.opacity(isHovering ? 0.9 : 0.72), in: Circle())
|
|
}
|
|
.padding(Spacing.sm)
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(MacL10n.string("mac.styles.edit", language: language))
|
|
|
|
if isSelected {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 21, weight: .semibold))
|
|
.foregroundStyle(palette.accent)
|
|
.padding(Spacing.sm)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
.background(
|
|
isSelected ? palette.accentMuted : palette.surface,
|
|
in: shape
|
|
)
|
|
.overlay(
|
|
shape.stroke(
|
|
isSelected ? palette.accent : hoverBorder,
|
|
lineWidth: isSelected ? 1.5 : 0.5
|
|
)
|
|
)
|
|
.clipShape(shape)
|
|
.scaleEffect(isHovering ? 1.01 : 1)
|
|
.animation(Motion.quick, value: isHovering)
|
|
.animation(Motion.quick, value: isSelected)
|
|
.onHover { isHovering = $0 }
|
|
.contextMenu {
|
|
Button(MacL10n.string("mac.styles.copy", language: language), action: duplicate)
|
|
if isUserStyle {
|
|
Button(MacL10n.string("mac.delete", language: language), role: .destructive, action: delete)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var hoverBorder: Color {
|
|
isHovering ? palette.dividerStrong : palette.divider
|
|
}
|
|
}
|
|
|
|
/// Read-only prompt viewer for built-in styles (mirrors iOS).
|
|
private struct MacPolishStylePromptDetailSheet: View {
|
|
let pack: PolishStylePack
|
|
let language: AppUILanguage
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.themePalette) private var palette
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: Spacing.md) {
|
|
HStack {
|
|
Text(pack.displayName(language: language))
|
|
.font(TypeStyle.title2)
|
|
Spacer()
|
|
Button(MacL10n.string("mac.done", language: language)) { dismiss() }
|
|
.keyboardShortcut(.cancelAction)
|
|
}
|
|
|
|
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.medium, style: .continuous)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
|
.stroke(palette.divider, lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
.padding(Spacing.xl)
|
|
.frame(width: 680, height: 520)
|
|
.background(palette.background)
|
|
}
|
|
}
|
|
|
|
private struct MacPolishStyleEditor: View {
|
|
let pack: PolishStylePack
|
|
let isNew: Bool
|
|
let language: AppUILanguage
|
|
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,
|
|
language: AppUILanguage,
|
|
onSave: @escaping (PolishStylePack) -> Void
|
|
) {
|
|
self.pack = pack
|
|
self.isNew = isNew
|
|
self.language = language
|
|
self.onSave = onSave
|
|
_name = State(initialValue: pack.name)
|
|
_prompt = State(initialValue: pack.prompt)
|
|
_allowsAddedEmoji = State(initialValue: pack.allowsAddedEmoji)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: Spacing.md) {
|
|
Text(MacL10n.string(isNew ? "mac.styles.add" : "mac.styles.edit", language: language))
|
|
.font(TypeStyle.title2)
|
|
TextField(MacL10n.string("mac.styles.name", language: language), text: $name)
|
|
.textFieldStyle(.roundedBorder)
|
|
Toggle(
|
|
MacL10n.string("mac.styles.allowsAddedEmoji", language: language),
|
|
isOn: $allowsAddedEmoji
|
|
)
|
|
Text(MacL10n.string("mac.styles.allowsAddedEmoji.hint", language: language))
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.textTertiary)
|
|
HStack {
|
|
Text(MacL10n.string("mac.styles.prompt", language: language))
|
|
.font(MacSettingsType.sectionTitle)
|
|
Spacer()
|
|
Text("\(prompt.count)/\(PolishStyleLimits.maximumPromptCharacters)")
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(
|
|
prompt.count > PolishStyleLimits.maximumPromptCharacters
|
|
? palette.danger
|
|
: palette.textTertiary
|
|
)
|
|
}
|
|
TextEditor(text: $prompt)
|
|
.font(.body.monospaced())
|
|
.frame(minHeight: 320)
|
|
.padding(4)
|
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.medium)
|
|
.stroke(palette.divider, lineWidth: 1)
|
|
)
|
|
.onChange(of: prompt) { _, newValue in
|
|
if !allowsAddedEmoji,
|
|
PolishStylePack.promptDeclaresAddedEmojiOptIn(newValue) {
|
|
allowsAddedEmoji = true
|
|
}
|
|
}
|
|
Text(MacL10n.string("mac.styles.hint", language: language))
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.textTertiary)
|
|
HStack {
|
|
Spacer()
|
|
Button(MacL10n.string("mac.cancel", language: language)) { dismiss() }
|
|
Button(MacL10n.string("mac.save", language: language)) {
|
|
onSave(
|
|
PolishStylePack(
|
|
id: pack.id,
|
|
name: name,
|
|
prompt: prompt,
|
|
allowsAddedEmoji: allowsAddedEmoji
|
|
|| PolishStylePack.promptDeclaresAddedEmojiOptIn(prompt),
|
|
kind: .user,
|
|
createdAt: pack.createdAt
|
|
)
|
|
)
|
|
dismiss()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(
|
|
name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
|| prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
|| prompt.count > PolishStyleLimits.maximumPromptCharacters
|
|
)
|
|
}
|
|
}
|
|
.padding(Spacing.xl)
|
|
.frame(width: 680, height: 640)
|
|
.background(palette.background)
|
|
}
|
|
}
|