463 lines
17 KiB
Swift
463 lines
17 KiB
Swift
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 label–control 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"
|
||
}
|
||
}
|
||
}
|