- Rename application from MenuWhisper to Tell me with new domain com.fmartingr.tellme - Implement comprehensive preferences window with 6 tabs (General, Models, Text Insertion, Interface, Advanced, Permissions) - Add full English/Spanish localization for all UI elements - Create functional onboarding flow with model download capability - Implement preview dialog for transcription editing - Add settings export/import functionality - Fix HUD content display issues and add comprehensive permission checking - Enhance build scripts and app bundle creation for proper localization support
557 lines
No EOL
21 KiB
Swift
557 lines
No EOL
21 KiB
Swift
import SwiftUI
|
|
import CorePermissions
|
|
import CoreModels
|
|
import CoreSTT
|
|
|
|
class OnboardingWindowController: NSWindowController {
|
|
private let permissionManager: PermissionManager
|
|
private let modelManager: ModelManager
|
|
private let whisperEngine: WhisperCPPEngine
|
|
private var onboardingView: OnboardingView?
|
|
|
|
init(permissionManager: PermissionManager, modelManager: ModelManager, whisperEngine: WhisperCPPEngine, onComplete: @escaping () -> Void) {
|
|
self.permissionManager = permissionManager
|
|
self.modelManager = modelManager
|
|
self.whisperEngine = whisperEngine
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 650, height: 550),
|
|
styleMask: [.titled, .closable, .miniaturizable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
|
|
super.init(window: window)
|
|
|
|
window.title = NSLocalizedString("onboarding.title", comment: "Welcome to Tell me")
|
|
window.center()
|
|
window.level = .floating
|
|
|
|
onboardingView = OnboardingView(
|
|
permissionManager: permissionManager,
|
|
modelManager: modelManager,
|
|
whisperEngine: whisperEngine,
|
|
onComplete: { [weak self] in
|
|
print("Onboarding complete, closing window...")
|
|
onComplete()
|
|
self?.close()
|
|
}
|
|
)
|
|
|
|
window.contentView = NSHostingView(rootView: onboardingView!)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
}
|
|
|
|
struct OnboardingView: View {
|
|
@ObservedObject var permissionManager: PermissionManager
|
|
@ObservedObject var modelManager: ModelManager
|
|
let whisperEngine: WhisperCPPEngine
|
|
let onComplete: () -> Void
|
|
|
|
@State private var currentStep = 0
|
|
@State private var isDownloading = false
|
|
@State private var downloadError: String?
|
|
private let totalSteps = 4
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Header
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "mic.circle.fill")
|
|
.foregroundColor(.blue)
|
|
.font(.system(size: 50))
|
|
|
|
Text(NSLocalizedString("onboarding.title", comment: "Welcome to Tell me"))
|
|
.font(.title)
|
|
.fontWeight(.bold)
|
|
|
|
Text(NSLocalizedString("onboarding.subtitle", comment: "Offline speech-to-text for macOS"))
|
|
.font(.title3)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.top, 50)
|
|
.padding(.bottom, 30)
|
|
.padding(.horizontal, 50)
|
|
|
|
// Progress indicator
|
|
HStack(spacing: 8) {
|
|
ForEach(0..<totalSteps, id: \.self) { step in
|
|
Circle()
|
|
.fill(step <= currentStep ? Color.blue : Color.gray.opacity(0.3))
|
|
.frame(width: 8, height: 8)
|
|
}
|
|
}
|
|
.padding(.bottom, 24)
|
|
|
|
// Content area
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
switch currentStep {
|
|
case 0:
|
|
WelcomeStep()
|
|
case 1:
|
|
PermissionsStep(permissionManager: permissionManager)
|
|
case 2:
|
|
ModelsStep(
|
|
modelManager: modelManager,
|
|
whisperEngine: whisperEngine,
|
|
isDownloading: $isDownloading,
|
|
downloadError: $downloadError
|
|
)
|
|
case 3:
|
|
CompletionStep(hasModel: modelManager.activeModel != nil)
|
|
default:
|
|
WelcomeStep()
|
|
}
|
|
}
|
|
.padding(.horizontal, 50)
|
|
}
|
|
.frame(height: 280)
|
|
|
|
// Navigation buttons (fixed at bottom)
|
|
HStack(spacing: 12) {
|
|
Button(NSLocalizedString("onboarding.buttons.skip", comment: "Skip Setup")) {
|
|
onComplete()
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.controlSize(.regular)
|
|
.foregroundColor(.secondary)
|
|
|
|
if currentStep > 0 {
|
|
Button(NSLocalizedString("onboarding.buttons.back", comment: "Back")) {
|
|
currentStep = max(0, currentStep - 1)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.regular)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if currentStep < totalSteps - 1 {
|
|
Button(NSLocalizedString("onboarding.buttons.next", comment: "Next")) {
|
|
currentStep = min(totalSteps - 1, currentStep + 1)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.regular)
|
|
} else {
|
|
Button(NSLocalizedString("onboarding.buttons.get_started", comment: "Get Started")) {
|
|
onComplete()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.regular)
|
|
}
|
|
}
|
|
.padding(.horizontal, 50)
|
|
.padding(.top, 30)
|
|
.padding(.bottom, 50)
|
|
.background(Color(NSColor.windowBackgroundColor))
|
|
}
|
|
.frame(width: 650, height: 550)
|
|
}
|
|
}
|
|
|
|
struct WelcomeStep: View {
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text(NSLocalizedString("onboarding.what_is", comment: "What is Tell me?"))
|
|
.font(.title3)
|
|
.fontWeight(.semibold)
|
|
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
FeatureRow(
|
|
icon: "mic.fill",
|
|
title: NSLocalizedString("onboarding.feature.offline", comment: "Offline Speech Recognition"),
|
|
description: NSLocalizedString("onboarding.feature.offline_desc", comment: "Offline description")
|
|
)
|
|
|
|
FeatureRow(
|
|
icon: "keyboard",
|
|
title: NSLocalizedString("onboarding.feature.hotkey", comment: "Global Hotkey"),
|
|
description: NSLocalizedString("onboarding.feature.hotkey_desc", comment: "Hotkey description")
|
|
)
|
|
|
|
FeatureRow(
|
|
icon: "lock.shield",
|
|
title: NSLocalizedString("onboarding.feature.privacy", comment: "Privacy First"),
|
|
description: NSLocalizedString("onboarding.feature.privacy_desc", comment: "Privacy description")
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct FeatureRow: View {
|
|
let icon: String
|
|
let title: String
|
|
let description: String
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: icon)
|
|
.foregroundColor(.blue)
|
|
.frame(width: 20)
|
|
.font(.title3)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title)
|
|
.font(.body)
|
|
.fontWeight(.medium)
|
|
Text(description)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PermissionsStep: View {
|
|
@ObservedObject var permissionManager: PermissionManager
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text(NSLocalizedString("onboarding.permissions.title", comment: "Permissions Required"))
|
|
.font(.headline)
|
|
|
|
Text(NSLocalizedString("onboarding.permissions.description", comment: "Permissions description"))
|
|
.font(.body)
|
|
.foregroundColor(.secondary)
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
PermissionStepRow(
|
|
title: "Microphone",
|
|
description: "To capture your speech for transcription",
|
|
status: permissionManager.microphoneStatus,
|
|
onGrant: {
|
|
permissionManager.requestMicrophonePermission { _ in }
|
|
}
|
|
)
|
|
|
|
PermissionStepRow(
|
|
title: "Accessibility",
|
|
description: "To insert transcribed text into applications",
|
|
status: permissionManager.accessibilityStatus,
|
|
onGrant: {
|
|
permissionManager.openSystemSettings(for: .accessibility)
|
|
}
|
|
)
|
|
|
|
PermissionStepRow(
|
|
title: "Input Monitoring",
|
|
description: "To register global keyboard shortcuts",
|
|
status: permissionManager.inputMonitoringStatus,
|
|
onGrant: {
|
|
permissionManager.openSystemSettings(for: .inputMonitoring)
|
|
}
|
|
)
|
|
}
|
|
|
|
Button("Refresh Permission Status") {
|
|
permissionManager.checkAllPermissions()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PermissionStepRow: View {
|
|
let title: String
|
|
let description: String
|
|
let status: PermissionStatus
|
|
let onGrant: () -> Void
|
|
|
|
var body: some View {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(title)
|
|
.font(.body)
|
|
.fontWeight(.medium)
|
|
Text(description)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
HStack(spacing: 8) {
|
|
Circle()
|
|
.fill(statusColor)
|
|
.frame(width: 8, height: 8)
|
|
|
|
Text(statusText)
|
|
.font(.caption)
|
|
.foregroundColor(statusColor)
|
|
|
|
if status != .granted {
|
|
Button("Grant") {
|
|
onGrant()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(8)
|
|
}
|
|
|
|
private var statusColor: Color {
|
|
switch status {
|
|
case .granted: return .green
|
|
case .denied: return .red
|
|
case .notDetermined, .restricted: return .orange
|
|
}
|
|
}
|
|
|
|
private var statusText: String {
|
|
switch status {
|
|
case .granted: return "Granted"
|
|
case .denied: return "Denied"
|
|
case .notDetermined: return "Not Set"
|
|
case .restricted: return "Restricted"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ModelsStep: View {
|
|
@ObservedObject var modelManager: ModelManager
|
|
let whisperEngine: WhisperCPPEngine
|
|
@Binding var isDownloading: Bool
|
|
@Binding var downloadError: String?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text(NSLocalizedString("onboarding.models.title", comment: "Download a Speech Model"))
|
|
.font(.headline)
|
|
|
|
Text(NSLocalizedString("onboarding.models.description", comment: "Models description"))
|
|
.font(.body)
|
|
.foregroundColor(.secondary)
|
|
|
|
// Model recommendation
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(NSLocalizedString("onboarding.models.recommended", comment: "Recommended: Whisper Tiny"))
|
|
.font(.body)
|
|
.fontWeight(.semibold)
|
|
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("whisper-tiny")
|
|
.font(.body)
|
|
.fontWeight(.medium)
|
|
Text(NSLocalizedString("onboarding.models.tiny_description", comment: "Tiny model description"))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if let tinyModel = getTinyModel() {
|
|
if tinyModel.isDownloaded {
|
|
HStack {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.green)
|
|
Text(NSLocalizedString("onboarding.models.downloaded", comment: "Downloaded"))
|
|
.font(.caption)
|
|
.foregroundColor(.green)
|
|
}
|
|
} else if isDownloading {
|
|
HStack {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
Text(NSLocalizedString("onboarding.models.downloading", comment: "Downloading..."))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
} else {
|
|
Button("Download") {
|
|
downloadTinyModel()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(Color.blue.opacity(0.1))
|
|
.cornerRadius(12)
|
|
|
|
// Error display
|
|
if let error = downloadError {
|
|
HStack {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.foregroundColor(.red)
|
|
Text(String(format: NSLocalizedString("onboarding.models.download_failed", comment: "Download failed"), error))
|
|
.font(.caption)
|
|
.foregroundColor(.red)
|
|
}
|
|
.padding(12)
|
|
.background(Color.red.opacity(0.1))
|
|
.cornerRadius(8)
|
|
}
|
|
|
|
// Additional info
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Image(systemName: "info.circle")
|
|
.foregroundColor(.blue)
|
|
Text(NSLocalizedString("onboarding.models.info1", comment: "Models info 1"))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
HStack {
|
|
Image(systemName: "wifi.slash")
|
|
.foregroundColor(.green)
|
|
Text(NSLocalizedString("onboarding.models.info2", comment: "Models info 2"))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func getTinyModel() -> ModelInfo? {
|
|
return modelManager.availableModels.first { $0.name == "whisper-tiny" }
|
|
}
|
|
|
|
private func downloadTinyModel() {
|
|
guard let tinyModel = getTinyModel() else { return }
|
|
|
|
isDownloading = true
|
|
downloadError = nil
|
|
|
|
Task {
|
|
do {
|
|
try await modelManager.downloadModel(tinyModel) { progress in
|
|
// We don't need to track progress value for spinner
|
|
print("Download progress: \(progress.progress * 100)%")
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
isDownloading = false
|
|
|
|
// Set as active model
|
|
modelManager.setActiveModel(tinyModel)
|
|
|
|
// Load the model
|
|
Task {
|
|
do {
|
|
if let modelPath = modelManager.getModelPath(for: tinyModel) {
|
|
try await whisperEngine.loadModel(at: modelPath)
|
|
}
|
|
} catch {
|
|
print("Failed to load model: \(error)")
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
DispatchQueue.main.async {
|
|
isDownloading = false
|
|
downloadError = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CompletionStep: View {
|
|
let hasModel: Bool
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
if hasModel {
|
|
Text(NSLocalizedString("onboarding.completion.ready_title", comment: "You're All Set!"))
|
|
.font(.headline)
|
|
.foregroundColor(.green)
|
|
|
|
Text(NSLocalizedString("onboarding.completion.ready_desc", comment: "Ready description"))
|
|
.font(.body)
|
|
.foregroundColor(.secondary)
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(alignment: .top) {
|
|
Image(systemName: "1.circle.fill")
|
|
.foregroundColor(.blue)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(NSLocalizedString("onboarding.completion.step1", comment: "Step 1"))
|
|
.fontWeight(.medium)
|
|
Text(NSLocalizedString("onboarding.completion.step1_desc", comment: "Step 1 description"))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
HStack(alignment: .top) {
|
|
Image(systemName: "2.circle.fill")
|
|
.foregroundColor(.blue)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(NSLocalizedString("onboarding.completion.step2", comment: "Step 2"))
|
|
.fontWeight(.medium)
|
|
Text(NSLocalizedString("onboarding.completion.step2_desc", comment: "Step 2 description"))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
HStack(alignment: .top) {
|
|
Image(systemName: "3.circle.fill")
|
|
.foregroundColor(.blue)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(NSLocalizedString("onboarding.completion.step3", comment: "Step 3"))
|
|
.fontWeight(.medium)
|
|
Text(NSLocalizedString("onboarding.completion.step3_desc", comment: "Step 3 description"))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
Text(NSLocalizedString("onboarding.completion.incomplete_title", comment: "Setup Not Complete"))
|
|
.font(.headline)
|
|
.foregroundColor(.orange)
|
|
|
|
Text(NSLocalizedString("onboarding.completion.incomplete_desc", comment: "Incomplete description"))
|
|
.font(.body)
|
|
.foregroundColor(.secondary)
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundColor(.orange)
|
|
Text(NSLocalizedString("onboarding.completion.no_model_warning", comment: "No model warning"))
|
|
.font(.body)
|
|
.fontWeight(.medium)
|
|
}
|
|
.padding(12)
|
|
.background(Color.orange.opacity(0.1))
|
|
.cornerRadius(8)
|
|
|
|
Text(NSLocalizedString("onboarding.completion.what_next", comment: "What to do next:"))
|
|
.font(.body)
|
|
.fontWeight(.semibold)
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(NSLocalizedString("onboarding.completion.next_step1", comment: "Next step 1"))
|
|
Text(NSLocalizedString("onboarding.completion.next_step2", comment: "Next step 2"))
|
|
Text(NSLocalizedString("onboarding.completion.next_step3", comment: "Next step 3"))
|
|
}
|
|
.font(.body)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
Text(NSLocalizedString("onboarding.completion.footer", comment: "Footer text"))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.padding(.top, 8)
|
|
}
|
|
}
|
|
} |