bugger/Sources/Views/Settings/SettingsView.swift

352 lines
14 KiB
Swift

import ServiceManagement
import SwiftUI
struct SettingsView: View {
@State private var config: AppConfig
@State private var isTestingConnection = false
@State private var connectionResult: String?
@State private var showAdvanced = false
init() {
_config = State(initialValue: AppStateService.shared.config ?? AppConfig())
}
var body: some View {
ScrollView {
VStack(spacing: 12) {
// Highlighted "Start here" card lives outside the Form so its
// Spacer + multi-element HStack don't disturb the Form's
// two-column label/control layout (which was pushing the
// Feishu Bitable fields off the right edge of the window).
HStack(spacing: 12) {
Image(systemName: "person.fill")
.font(.title2)
.foregroundStyle(.tint)
Text("Your Name")
.font(.headline)
TextField("e.g. Alice", text: $config.assigneeName)
.textFieldStyle(.roundedBorder)
Spacer()
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(.tint.opacity(0.08)))
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.tint.opacity(0.35), lineWidth: 1))
Form {
Section("Feishu Bitable") {
TextField("App Token (from Bitable URL)", text: $config.appToken)
TextField("Table ID", text: $config.tableId)
TextField("Feishu domain", text: $config.feishuBaseDomain)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Button("Test Connection") {
testConnection()
}
.disabled(isTestingConnection || !config.isConfigured)
if isTestingConnection {
ProgressView()
.scaleEffect(0.7)
}
}
if let connectionResult {
Text(connectionResult)
.font(.caption)
.foregroundStyle(connectionResult.contains("") ? .green : .red)
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
}
}
}
Section("Polling") {
Picker("Check every", selection: $config.pollIntervalSeconds) {
Text("1 minute").tag(60)
Text("5 minutes").tag(300)
Text("10 minutes").tag(600)
Text("30 minutes").tag(1_800)
Text("1 hour").tag(3_600)
Text("2 hours").tag(7_200)
Text("4 hours").tag(14_400)
Text("8 hours").tag(28_800)
Text("12 hours").tag(43_200)
Text("1 day").tag(86_400)
Divider()
Text("Daily schedule").tag(-1)
}
if config.pollIntervalSeconds == -1 {
VStack(alignment: .leading, spacing: 6) {
Text("Refresh at these times:")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(dailyTimeBindings.indices, id: \.self) { index in
HStack {
DatePicker("", selection: dailyTimeBindings[index],
displayedComponents: .hourAndMinute)
.labelsHidden()
Button(action: { removeDailyTime(at: index) }) {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.red)
}
.buttonStyle(.plain)
.disabled(config.dailyRefreshTimes.count <= 1)
}
}
Button(action: addDailyTime) {
Label("Add time", systemImage: "plus.circle")
.font(.caption)
}
.buttonStyle(.plain)
}
}
VStack(alignment: .leading, spacing: 4) {
Toggle("Refresh on launch", isOn: $config.refreshOnStart)
Text("Fetch bugs immediately when Bugger starts. Off = wait for the next scheduled poll.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Section("Real-time updates") {
VStack(alignment: .leading, spacing: 4) {
TextField("Subscribe base URL", text: $config.feishuAppBaseURL)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
Text("Base URL of the change-notification service, e.g. http://localhost:8000. When set, Bugger listens for change pushes and refreshes immediately — auto-refresh on change is enabled.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Section("Calibration") {
Toggle("Enable calibration check", isOn: $config.calibrationEnabled)
Text("Periodically verifies with the notification server that all your assigned bugs are in sync. Helps recover missed push notifications. Only works when a Subscribe base URL is set.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Section("Display") {
Toggle("Show floating widget", isOn: $config.showFloatingWidget)
.onChange(of: config.showFloatingWidget) { _, enabled in
if enabled {
AppDelegate.shared?.showFloatingWidget()
} else {
AppDelegate.shared?.hideFloatingWidget()
}
}
Toggle("Launch at login", isOn: $config.launchAtLogin)
}
Section {
DisclosureGroup(isExpanded: $showAdvanced) {
Text("Field Mappings")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
TextField("Title field", text: $config.fieldMappings.titleField)
TextField("Priority field", text: $config.fieldMappings.priorityField)
TextField("Status field", text: $config.fieldMappings.statusField)
TextField("Assignee field", text: $config.fieldMappings.assigneeField)
TextField("Reporter field", text: $config.fieldMappings.reporterField)
TextField("Customer field", text: $config.fieldMappings.customerField)
TextField("Created At field", text: $config.fieldMappings.createdAtField)
TextField("Updated At field", text: $config.fieldMappings.updatedAtField)
Text("Status Mappings")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
Text("Map your Feishu status values to standard Bugger statuses.")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(BugStatus.allCases.filter { $0 != .unknown }, id: \.self) { status in
statusMappingRow(for: status)
}
} label: {
Label("Advanced: field & status mappings", systemImage: "slider.horizontal.3")
}
}
HStack {
Button("Save") {
save()
}
.keyboardShortcut(.return)
Button("Disconnect Feishu") {
TokenManager.shared.clearTokens()
PollerService.shared.stop()
BitableEventService.shared.disconnect()
CalibrationService.shared.stop()
}
.foregroundStyle(.red)
}
} // Form
} // VStack
.padding()
}
.frame(minWidth: 500, idealWidth: 520, minHeight: 400, maxHeight: 1200)
}
// MARK: - Status Mapping Row
private func statusMappingRow(for status: BugStatus) -> some View {
let binding = Binding<String>(
get: {
// Collect all Feishu values that map to this status
config.fieldMappings.statusMappings
.filter { $0.value == status }
.keys
.sorted()
.joined(separator: ", ")
},
set: { newValue in
// Remove old mappings for this status
config.fieldMappings.statusMappings = config.fieldMappings.statusMappings
.filter { $0.value != status }
// Add new mappings from comma-separated input
let parts = newValue
.components(separatedBy: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
for part in parts {
config.fieldMappings.statusMappings[part] = status
}
}
)
return VStack(alignment: .leading, spacing: 2) {
HStack {
StatusPill(status: status)
Text(status.label)
.font(.caption)
.foregroundStyle(.secondary)
}
TextField(status.description, text: binding)
.font(.caption)
.textFieldStyle(.roundedBorder)
}
.padding(.vertical, 2)
}
// MARK: - Daily times
/// Reference date for time-only DatePickers
private var timeRef: Date {
Calendar.current.date(from: DateComponents(year: 2000, month: 1, day: 1))!
}
private var dailyTimeBindings: [Binding<Date>] {
config.dailyRefreshTimes.indices.map { index in
Binding<Date>(
get: {
let parts = config.dailyRefreshTimes[index].components(separatedBy: ":")
let hour = Int(parts.first ?? "") ?? 0
let minute = Int(parts.last ?? "") ?? 0
return Calendar.current.date(
bySettingHour: hour, minute: minute, second: 0, of: timeRef
) ?? timeRef
},
set: { newDate in
let comps = Calendar.current.dateComponents([.hour, .minute], from: newDate)
let h = String(format: "%02d", comps.hour ?? 0)
let m = String(format: "%02d", comps.minute ?? 0)
config.dailyRefreshTimes[index] = "\(h):\(m)"
}
)
}
}
private func addDailyTime() {
config.dailyRefreshTimes.append("09:00")
}
private func removeDailyTime(at index: Int) {
guard config.dailyRefreshTimes.count > 1 else { return }
config.dailyRefreshTimes.remove(at: index)
}
// MARK: - Actions
private func save() {
AppStateService.shared.saveConfig(config)
connectionResult = "Saved ✓"
if config.launchAtLogin {
try? SMAppService.mainApp.register()
} else {
try? SMAppService.mainApp.unregister()
}
if config.showFloatingWidget {
AppDelegate.shared?.showFloatingWidget()
} else {
AppDelegate.shared?.hideFloatingWidget()
}
Task {
PollerService.shared.restart(interval: TimeInterval(config.pollIntervalSeconds))
// (Re)establish the SSE stream using the (possibly new) subscribe URL.
// connect() is a no-op when the URL is empty, so clearing the field
// disables real-time push.
BitableEventService.shared.reconnect()
CalibrationService.shared.restart()
}
}
private func testConnection() {
isTestingConnection = true
connectionResult = nil
Task {
do {
let token = try await TokenManager.shared.getAccessToken()
let count = try await FeishuService().fetchRecordCount(
appToken: config.appToken,
tableId: config.tableId,
accessToken: token
)
connectionResult = "Connected ✓ (\(count) records in table)"
} catch {
connectionResult = "Failed: \(error.localizedDescription)"
}
isTestingConnection = false
}
}
}
// MARK: - BugStatus display helpers
extension BugStatus {
var label: String {
switch self {
case .open: "Open"
case .inProgress: "In Progress"
case .inReview: "In Review"
case .resolved: "Resolved"
case .closed: "Closed"
case .unknown: "Unknown"
}
}
var description: String {
switch self {
case .open: "New / Triage (e.g. Bug Triage, Open, 待处理)"
case .inProgress: "Being worked on (e.g. 开发进行中)"
case .inReview: "Under review / testing (e.g. 验收中)"
case .resolved: "Fixed & verified (e.g. 验收完毕, Fixed)"
case .closed: "Done / cancelled (e.g. Closed, 取消, Won't fix)"
case .unknown: "Unmapped"
}
}
}