bugger/Sources/Views/Settings/SettingsView.swift

463 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import ServiceManagement
import SwiftUI
// MARK: - Constants
private let kLabelWidth: CGFloat = 130
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(alignment: .leading, spacing: 20) {
// MARK: Your Name card
yourNameCard
// MARK: Feishu Bitable
settingsSection("Feishu Bitable") {
labeledRow("App Token") {
TextField("from Bitable URL", text: $config.appToken)
.textFieldStyle(.roundedBorder)
}
labeledRow("Table ID") {
TextField("", text: $config.tableId)
.textFieldStyle(.roundedBorder)
}
labeledRow("Feishu domain") {
TextField("e.g. feishu.cn", text: $config.feishuBaseDomain)
.textFieldStyle(.roundedBorder)
}
connectionTestRow
}
// MARK: Polling
settingsSection("Polling") {
labeledRow("Check every") {
Picker("", 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)
}
.labelsHidden()
.fixedSize()
}
if config.pollIntervalSeconds == -1 {
dailyScheduleEditor
}
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)
}
}
// MARK: Real-time updates
settingsSection("Real-time updates") {
labeledRow("Subscribe URL") {
TextField("e.g. https://example.com", text: $config.feishuAppBaseURL)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
}
Text("Base URL of the change-notification service. 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)
}
// MARK: Calibration
settingsSection("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)
}
// MARK: Display
settingsSection("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)
}
// MARK: Advanced
advancedSection
// MARK: Actions
actionButtons
}
.frame(maxWidth: 700, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .center)
.padding(20)
}
.frame(minWidth: 520, idealWidth: 560, maxWidth: .infinity,
minHeight: 400, idealHeight: 600, maxHeight: .infinity)
}
// MARK: - Your Name Card
private var yourNameCard: some View {
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)
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(.tint.opacity(0.08)))
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.tint.opacity(0.35), lineWidth: 1))
}
// MARK: - Connection Test
private var connectionTestRow: some View {
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)
}
}
}
// MARK: - Daily Schedule Editor
private var dailyScheduleEditor: some View {
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)
}
.padding(.leading, kLabelWidth + 8)
}
// MARK: - Advanced Section
private var advancedSection: some View {
DisclosureGroup(isExpanded: $showAdvanced) {
VStack(alignment: .leading, spacing: 16) {
// Field Mappings
VStack(alignment: .leading, spacing: 8) {
Text("Field Mappings")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
labeledRow("Title field") {
TextField("", text: $config.fieldMappings.titleField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Priority field") {
TextField("", text: $config.fieldMappings.priorityField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Status field") {
TextField("", text: $config.fieldMappings.statusField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Assignee field") {
TextField("", text: $config.fieldMappings.assigneeField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Reporter field") {
TextField("", text: $config.fieldMappings.reporterField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Customer field") {
TextField("", text: $config.fieldMappings.customerField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Created At field") {
TextField("", text: $config.fieldMappings.createdAtField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Updated At field") {
TextField("", text: $config.fieldMappings.updatedAtField)
.textFieldStyle(.roundedBorder)
}
}
Divider()
// Status Mappings
VStack(alignment: .leading, spacing: 8) {
Text("Status Mappings")
.font(.subheadline)
.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)
}
}
}
.padding(.top, 8)
} label: {
Label("Advanced: field & status mappings", systemImage: "slider.horizontal.3")
}
}
// MARK: - Action Buttons
private var actionButtons: some View {
HStack(spacing: 12) {
Button("Save") {
save()
}
.keyboardShortcut(.return)
Button("Disconnect Feishu") {
TokenManager.shared.clearTokens()
PollerService.shared.stop()
BitableEventService.shared.disconnect()
CalibrationService.shared.stop()
}
.foregroundStyle(.red)
}
}
// MARK: - Layout Helpers
/// A consistently-aligned labelcontrol row.
private func labeledRow<Content: View>(
_ label: String,
@ViewBuilder content: () -> Content
) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(label)
.frame(width: kLabelWidth, alignment: .trailing)
.foregroundStyle(.secondary)
content()
}
}
/// A section with a left-aligned header and divider.
private func settingsSection<Content: View>(
_ title: String,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
.foregroundStyle(.primary)
Divider()
content()
}
}
// 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: 4) {
HStack(spacing: 6) {
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"
}
}
}