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( _ 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( _ 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( 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] { config.dailyRefreshTimes.indices.map { index in Binding( 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" } } }