refactor(conversation-assistant): remove unused SwiftUI popover views
Delete MenuBarView.swift and MenuBarViewModel.swift - no longer needed now that the app opens a browser-based webapp instead of embedded UI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
8a31285265
commit
414c34ad0f
2 changed files with 0 additions and 526 deletions
|
|
@ -1,339 +0,0 @@
|
|||
import SwiftUI
|
||||
|
||||
struct MenuBarView: View {
|
||||
@StateObject private var viewModel = MenuBarViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Image(systemName: "bubble.left.and.bubble.right.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
.accessibilityHidden(true)
|
||||
Text("Conversation Assistant")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.windowBackgroundColor))
|
||||
|
||||
Divider()
|
||||
|
||||
if viewModel.isAuthenticated {
|
||||
authenticatedView
|
||||
} else {
|
||||
authenticationView
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Footer
|
||||
HStack {
|
||||
// Open Settings - use SettingsLink on macOS 14+, fallback to NSApp.sendAction
|
||||
if #available(macOS 14, *) {
|
||||
SettingsLink {
|
||||
Image(systemName: "gear")
|
||||
.accessibilityLabel("Settings")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
} else {
|
||||
Button {
|
||||
// Close popover first
|
||||
if let appDelegate = NSApp.delegate as? AppDelegate {
|
||||
appDelegate.closePopover()
|
||||
}
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
|
||||
} label: {
|
||||
Image(systemName: "gear")
|
||||
.accessibilityLabel("Settings")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(AppVersion.displayVersion)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Quit") {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(width: 320)
|
||||
}
|
||||
|
||||
private var authenticatedView: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Sync Status
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(viewModel.syncStatus.color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(viewModel.syncStatus.text)
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Full Disk Access Warning
|
||||
if viewModel.needsFullDiskAccess {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.shield.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(.orange)
|
||||
.accessibilityLabel("Warning")
|
||||
|
||||
Text("Full Disk Access Required")
|
||||
.font(.headline)
|
||||
|
||||
Text("Grant access to read iMessage database")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button("Open Settings") {
|
||||
viewModel.openFullDiskAccessSettings()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("Retry") {
|
||||
viewModel.retryConnection()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.orange.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.padding(.horizontal)
|
||||
} else {
|
||||
// Stats
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
StatRow(label: "Messages Synced", value: "\(viewModel.messageCount)")
|
||||
StatRow(label: "Conversations", value: "\(viewModel.conversationCount)")
|
||||
StatRow(label: "Last Sync", value: viewModel.lastSyncText)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Activity Log
|
||||
ActivityLogSection(entries: viewModel.activityLogEntries)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Sync Buttons
|
||||
HStack(spacing: 8) {
|
||||
Button(action: viewModel.triggerSync) {
|
||||
HStack {
|
||||
if viewModel.isSyncing && !viewModel.isResetting {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
}
|
||||
Text(viewModel.isSyncing && !viewModel.isResetting ? "Syncing..." : "Sync Now")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(viewModel.isSyncing || viewModel.isResetting || viewModel.needsFullDiskAccess)
|
||||
|
||||
Button(action: viewModel.forceSync) {
|
||||
HStack {
|
||||
if viewModel.isResetting {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
}
|
||||
Text(viewModel.isResetting ? "Resetting..." : "Force Sync")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(viewModel.isSyncing || viewModel.isResetting || viewModel.needsFullDiskAccess)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
|
||||
private var authenticationView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "link.badge.plus")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.accentColor)
|
||||
.accessibilityLabel("Connect device")
|
||||
|
||||
Text("Connect This Device")
|
||||
.font(.headline)
|
||||
|
||||
if viewModel.isRegistering {
|
||||
ProgressView("Registering...")
|
||||
} else if !viewModel.registrationCode.isEmpty {
|
||||
Text("Enter this code in your admin panel:")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(viewModel.registrationCode)
|
||||
.font(.system(size: 32, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.accentColor)
|
||||
.padding(.vertical, 8)
|
||||
.onTapGesture {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(viewModel.registrationCode, forType: .string)
|
||||
}
|
||||
|
||||
Text("Tap to copy • Waiting for verification...")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Button("Get New Code") {
|
||||
viewModel.refreshCode()
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
} else {
|
||||
Text("Unable to connect to server")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Button("Retry") {
|
||||
viewModel.registerDevice()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
|
||||
if let error = viewModel.authError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
struct StatRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActivityLogSection: View {
|
||||
let entries: [LogEntry]
|
||||
|
||||
/// Maximum entries to display
|
||||
private let maxDisplayed = 8
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("Activity")
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
|
||||
if entries.isEmpty {
|
||||
Text("No recent activity")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.horizontal)
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(entries.prefix(maxDisplayed)) { entry in
|
||||
ActivityLogRow(entry: entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 120)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActivityLogRow: View {
|
||||
let entry: LogEntry
|
||||
|
||||
private var timeText: String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter.localizedString(for: entry.timestamp, relativeTo: Date())
|
||||
}
|
||||
|
||||
private var iconColor: Color {
|
||||
switch entry.level {
|
||||
case .info: return .blue
|
||||
case .success: return .green
|
||||
case .warning: return .orange
|
||||
case .error: return .red
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Image(systemName: entry.level.icon)
|
||||
.font(.caption2)
|
||||
.foregroundColor(iconColor)
|
||||
.frame(width: 12)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text(entry.message)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(2)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(timeText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
enum SyncStatus {
|
||||
case idle
|
||||
case syncing
|
||||
case error(String)
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .idle: return .green
|
||||
case .syncing: return .orange
|
||||
case .error: return .red
|
||||
}
|
||||
}
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .idle: return "Connected"
|
||||
case .syncing: return "Syncing..."
|
||||
case .error(let message): return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MenuBarView()
|
||||
.frame(height: 400)
|
||||
}
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
class MenuBarViewModel: ObservableObject {
|
||||
@Published var isAuthenticated = false
|
||||
@Published var syncStatus: SyncStatus = .idle
|
||||
@Published var messageCount = 0
|
||||
@Published var conversationCount = 0
|
||||
@Published var lastSyncText = "Never"
|
||||
@Published var isSyncing = false
|
||||
@Published var isResetting = false
|
||||
@Published var needsFullDiskAccess = false
|
||||
@Published var syncErrorMessage: String?
|
||||
|
||||
@Published var registrationCode = ""
|
||||
@Published var isRegistering = false
|
||||
@Published var authError: String?
|
||||
|
||||
@Published var activityLogEntries: [LogEntry] = []
|
||||
|
||||
private let apiClient = APIClient.shared
|
||||
private let syncManager = SyncManager.shared
|
||||
private let activityLog = ActivityLog.shared
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var pollingTimer: Timer?
|
||||
|
||||
init() {
|
||||
isAuthenticated = apiClient.isAuthenticated
|
||||
|
||||
syncManager.$isSyncing
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] syncing in
|
||||
self?.isSyncing = syncing
|
||||
self?.syncStatus = syncing ? .syncing : .idle
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
syncManager.$lastSync
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] date in
|
||||
NSLog("MenuBarViewModel: received lastSync update: \(String(describing: date))")
|
||||
self?.updateLastSyncText(date)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
syncManager.$stats
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] stats in
|
||||
self?.messageCount = stats.messageCount
|
||||
self?.conversationCount = stats.conversationCount
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
syncManager.$syncError
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] error in
|
||||
switch error {
|
||||
case .none:
|
||||
self?.needsFullDiskAccess = false
|
||||
self?.syncErrorMessage = nil
|
||||
self?.syncStatus = .idle
|
||||
case .fullDiskAccessRequired:
|
||||
self?.needsFullDiskAccess = true
|
||||
self?.syncErrorMessage = error.message
|
||||
self?.syncStatus = .error(error.message)
|
||||
default:
|
||||
self?.needsFullDiskAccess = false
|
||||
self?.syncErrorMessage = error.message
|
||||
self?.syncStatus = .error(error.message)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
syncManager.$isResetting
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] resetting in
|
||||
self?.isResetting = resetting
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
activityLog.$entries
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] entries in
|
||||
self?.activityLogEntries = entries
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Register device on startup if not authenticated
|
||||
if !isAuthenticated {
|
||||
registerDevice()
|
||||
}
|
||||
}
|
||||
|
||||
func registerDevice() {
|
||||
isRegistering = true
|
||||
authError = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let (_, code) = try await apiClient.registerDevice()
|
||||
registrationCode = code
|
||||
startPollingForVerification()
|
||||
} catch {
|
||||
authError = error.localizedDescription
|
||||
}
|
||||
isRegistering = false
|
||||
}
|
||||
}
|
||||
|
||||
private func startPollingForVerification() {
|
||||
// Poll every 3 seconds to check if device has been verified
|
||||
pollingTimer?.invalidate()
|
||||
pollingTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
await self?.checkVerificationStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkVerificationStatus() async {
|
||||
do {
|
||||
let verified = try await apiClient.checkVerification()
|
||||
if verified {
|
||||
pollingTimer?.invalidate()
|
||||
pollingTimer = nil
|
||||
isAuthenticated = true
|
||||
registrationCode = ""
|
||||
syncManager.startSync()
|
||||
}
|
||||
} catch {
|
||||
// Silently continue polling
|
||||
}
|
||||
}
|
||||
|
||||
func refreshCode() {
|
||||
registrationCode = ""
|
||||
registerDevice()
|
||||
}
|
||||
|
||||
func triggerSync() {
|
||||
syncManager.syncNow()
|
||||
}
|
||||
|
||||
func forceSync() {
|
||||
syncManager.resetAndResync()
|
||||
}
|
||||
|
||||
func openFullDiskAccessSettings() {
|
||||
syncManager.openFullDiskAccessSettings()
|
||||
}
|
||||
|
||||
func retryConnection() {
|
||||
syncManager.retryConnection()
|
||||
}
|
||||
|
||||
/// Opens the Settings window, closing the popover first and activating the app
|
||||
func openSettings() {
|
||||
// Close the popover first
|
||||
if let appDelegate = NSApp.delegate as? AppDelegate {
|
||||
appDelegate.closePopover()
|
||||
}
|
||||
// Activate the app (required for menu bar apps to show settings)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
// Open settings window - macOS 14+ renamed to showSettingsWindow, older versions use showPreferencesWindow
|
||||
// Note: Using double-paren selector format to suppress warnings about private Objective-C selectors
|
||||
if #available(macOS 14, *) {
|
||||
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
|
||||
} else {
|
||||
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateLastSyncText(_ date: Date?) {
|
||||
guard let date = date else {
|
||||
NSLog("MenuBarViewModel: updateLastSyncText - date is nil, setting to 'Never'")
|
||||
lastSyncText = "Never"
|
||||
return
|
||||
}
|
||||
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
let text = formatter.localizedString(for: date, relativeTo: Date())
|
||||
NSLog("MenuBarViewModel: updateLastSyncText - date: \(date), text: \(text)")
|
||||
lastSyncText = text
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue