// 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.badge.sparkles") .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) } }