bugger/Sources/Views/MenuBar/BugListPopover.swift

189 lines
6.0 KiB
Swift

import AppKit
import SwiftUI
struct BugListPopover: View {
@State private var bugStore = BugStore.shared
@State private var tokenManager = TokenManager.shared
@Environment(\.openSettings) private var openSettings
var body: some View {
VStack(spacing: 0) {
header
Divider()
content
Divider()
footer
}
.frame(minWidth: 340, idealWidth: 360, maxWidth: 400, minHeight: 280)
}
private var header: some View {
HStack {
Text("My Bugs")
.font(.headline)
Spacer()
if bugStore.isLoading {
ProgressView()
.scaleEffect(0.7)
.frame(width: 16, height: 16)
} else {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.caption2)
.foregroundStyle(.secondary)
if let lastUpdated = bugStore.lastUpdated {
TimelineView(.periodic(from: lastUpdated, by: 30)) { _ in
Text(formatted(lastUpdated))
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
Text("Never")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Button("Mark all seen") {
bugStore.markAllSeen()
AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count)
}
.font(.caption)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
@ViewBuilder
private var content: some View {
if !tokenManager.isAuthenticated {
OAuthSetupView()
} else if let error = bugStore.error {
ErrorStateView(error: error)
} else if bugStore.bugsSortedByPriority.isEmpty && !bugStore.isLoading {
EmptyStateView()
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(bugStore.bugsSortedByPriority) { bug in
BugRow(
bug: bug,
isUnseen: bugStore.unseenBugs.contains(bug.id)
)
.onTapGesture {
openInFeishu(bug)
bugStore.markSeen(bug.id)
AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count)
}
Divider().padding(.leading, 44)
}
}
}
}
}
private var footer: some View {
HStack {
Button(action: openFeishuTable) {
Label("Open in Feishu", systemImage: "arrow.up.forward.app")
}
Spacer()
Button("Refresh") {
Task { await PollerService.shared.fetchNow() }
}
Button {
openSettings()
// Bugger runs as an accessory (no Dock icon), so the Settings
// window would otherwise open behind whatever is frontmost.
// Activate on the next runloop pass, once the window exists,
// to bring it on top.
DispatchQueue.main.async {
NSRunningApplication.current.activate(options: [.activateAllWindows])
}
} label: {
Label("Settings", systemImage: "gear")
}
Button(action: { NSApp.terminate(nil) }) {
Label("Quit", systemImage: "power")
}
}
.padding(.horizontal)
.padding(.vertical, 6)
.buttonStyle(.plain)
.font(.caption)
}
private func openInFeishu(_ bug: Bug) {
guard let url = bug.feishuURL else { return }
NSWorkspace.shared.open(url)
AppDelegate.shared?.closePopover()
}
private func openFeishuTable() {
guard let config = AppStateService.shared.config,
let url = FeishuURLBuilder.tableURL(
baseDomain: config.feishuBaseDomain,
appToken: config.appToken
) else { return }
NSWorkspace.shared.open(url)
}
private func formatted(_ date: Date) -> String {
let seconds = Int(Date().timeIntervalSince(date))
switch seconds {
case ..<60: return "Just now"
case ..<3600:
let m = seconds / 60
return m == 1 ? "1m ago" : "\(m)m ago"
case ..<86400:
let h = seconds / 3600
return h == 1 ? "1h ago" : "\(h)h ago"
default:
let d = seconds / 86400
return d == 1 ? "1d ago" : "\(d)d ago"
}
}
}
struct EmptyStateView: View {
var body: some View {
VStack(spacing: 8) {
Image(systemName: "checkmark.circle")
.font(.largeTitle)
.foregroundStyle(.green)
Text("No active bugs")
.font(.headline)
Text("You're all clear!")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}
struct ErrorStateView: View {
let error: Error
var body: some View {
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundStyle(.orange)
Text("Failed to load bugs")
.font(.headline)
Text(error.localizedDescription)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Retry") {
Task { await PollerService.shared.fetchNow() }
}
.buttonStyle(.bordered)
.padding(.top, 4)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}