Compare commits
2 Commits
a0b1fb8aa3
...
b5ad9c1ec3
| Author | SHA1 | Date |
|---|---|---|
|
|
b5ad9c1ec3 | |
|
|
7a2508a449 |
|
|
@ -8,6 +8,6 @@ struct BuggerApp: App {
|
|||
Settings {
|
||||
SettingsView()
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
.windowResizability(.contentMinSize)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,15 +58,12 @@ final class FeishuService {
|
|||
accessToken: String,
|
||||
userOpenId: String?
|
||||
) async throws -> [RecordItem] {
|
||||
let fieldNames = configFieldNames(config)
|
||||
|
||||
// Tier 1: search endpoint with open_id (Person fields)
|
||||
if let openId = userOpenId, !openId.isEmpty {
|
||||
do {
|
||||
let records = try await searchRecords(
|
||||
config: config,
|
||||
accessToken: accessToken,
|
||||
fieldNames: fieldNames,
|
||||
assigneeField: config.fieldMappings.assigneeField,
|
||||
openId: openId
|
||||
)
|
||||
|
|
@ -86,7 +83,6 @@ final class FeishuService {
|
|||
let records = try await listAllRecords(
|
||||
config: config,
|
||||
accessToken: accessToken,
|
||||
fieldNames: fieldNames,
|
||||
filter: filterStr
|
||||
)
|
||||
if !records.isEmpty {
|
||||
|
|
@ -98,31 +94,20 @@ final class FeishuService {
|
|||
BuggerLog.debug("FeishuService: tier 2 failed (\(error.localizedDescription)), falling back")
|
||||
}
|
||||
|
||||
// Tier 3: unfiltered list (current behavior)
|
||||
// Tier 3: unfiltered list (original behavior)
|
||||
BuggerLog.debug("FeishuService: tier 3 (unfiltered list)")
|
||||
return try await listAllRecords(
|
||||
config: config,
|
||||
accessToken: accessToken,
|
||||
fieldNames: fieldNames,
|
||||
filter: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the list of field names to request from the Feishu API, to reduce
|
||||
/// the per-record payload. Includes all mapped fields.
|
||||
private func configFieldNames(_ config: AppConfig) -> [String] {
|
||||
let m = config.fieldMappings
|
||||
return [m.titleField, m.priorityField, m.statusField, m.assigneeField,
|
||||
m.reporterField, m.customerField, m.createdAtField, m.updatedAtField]
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
// MARK: - List endpoint (GET /records)
|
||||
|
||||
private func listAllRecords(
|
||||
config: AppConfig,
|
||||
accessToken: String,
|
||||
fieldNames: [String],
|
||||
filter: String?
|
||||
) async throws -> [RecordItem] {
|
||||
var allRecords: [RecordItem] = []
|
||||
|
|
@ -140,8 +125,7 @@ final class FeishuService {
|
|||
tableId: config.tableId,
|
||||
pageToken: pageToken,
|
||||
accessToken: accessToken,
|
||||
filter: filter,
|
||||
fieldNames: fieldNames
|
||||
filter: filter
|
||||
)
|
||||
allRecords.append(contentsOf: page.items)
|
||||
pageToken = page.hasMore ? page.pageToken : nil
|
||||
|
|
@ -156,8 +140,7 @@ final class FeishuService {
|
|||
pageToken: String?,
|
||||
pageSize: Int = 500,
|
||||
accessToken: String,
|
||||
filter: String? = nil,
|
||||
fieldNames: [String]? = nil
|
||||
filter: String? = nil
|
||||
) async throws -> RecordListData {
|
||||
guard !appToken.isEmpty, !tableId.isEmpty else {
|
||||
throw FeishuError.invalidConfiguration("App token and table ID must not be empty.")
|
||||
|
|
@ -174,10 +157,6 @@ final class FeishuService {
|
|||
if let filter, !filter.isEmpty {
|
||||
queryItems.append(URLQueryItem(name: "filter", value: filter))
|
||||
}
|
||||
if let fieldNames, !fieldNames.isEmpty {
|
||||
let jsonArray = "[\(fieldNames.map { "\"\($0)\"" }.joined(separator: ","))]"
|
||||
queryItems.append(URLQueryItem(name: "field_names", value: jsonArray))
|
||||
}
|
||||
components.queryItems = queryItems
|
||||
|
||||
guard let url = components.url else {
|
||||
|
|
@ -221,7 +200,6 @@ final class FeishuService {
|
|||
private func searchRecords(
|
||||
config: AppConfig,
|
||||
accessToken: String,
|
||||
fieldNames: [String],
|
||||
assigneeField: String,
|
||||
openId: String
|
||||
) async throws -> [RecordItem] {
|
||||
|
|
@ -240,7 +218,6 @@ final class FeishuService {
|
|||
tableId: config.tableId,
|
||||
pageToken: pageToken,
|
||||
accessToken: accessToken,
|
||||
fieldNames: fieldNames,
|
||||
assigneeField: assigneeField,
|
||||
openId: openId
|
||||
)
|
||||
|
|
@ -256,7 +233,6 @@ final class FeishuService {
|
|||
tableId: String,
|
||||
pageToken: String?,
|
||||
accessToken: String,
|
||||
fieldNames: [String],
|
||||
assigneeField: String,
|
||||
openId: String
|
||||
) async throws -> RecordListData {
|
||||
|
|
@ -285,9 +261,6 @@ final class FeishuService {
|
|||
if let pageToken, !pageToken.isEmpty {
|
||||
body["page_token"] = pageToken
|
||||
}
|
||||
if !fieldNames.isEmpty {
|
||||
body["field_names"] = fieldNames
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import ServiceManagement
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private let kLabelWidth: CGFloat = 130
|
||||
|
||||
struct SettingsView: View {
|
||||
@State private var config: AppConfig
|
||||
@State private var isTestingConnection = false
|
||||
|
|
@ -13,96 +17,50 @@ struct SettingsView: View {
|
|||
|
||||
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))
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// MARK: Your Name card
|
||||
yourNameCard
|
||||
|
||||
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)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
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)
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
dailyScheduleEditor
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
|
|
@ -113,19 +71,21 @@ struct SettingsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
Section("Real-time updates") {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
TextField("Subscribe base URL", text: $config.feishuAppBaseURL)
|
||||
// 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, 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
Section("Calibration") {
|
||||
// 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)
|
||||
|
|
@ -133,7 +93,8 @@ struct SettingsView: View {
|
|||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Section("Display") {
|
||||
// MARK: Display
|
||||
settingsSection("Display") {
|
||||
Toggle("Show floating widget", isOn: $config.showFloatingWidget)
|
||||
.onChange(of: config.showFloatingWidget) { _, enabled in
|
||||
if enabled {
|
||||
|
|
@ -145,57 +106,207 @@ struct SettingsView: View {
|
|||
Toggle("Launch at login", isOn: $config.launchAtLogin)
|
||||
}
|
||||
|
||||
Section {
|
||||
DisclosureGroup(isExpanded: $showAdvanced) {
|
||||
Text("Field Mappings")
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.secondary)
|
||||
// MARK: Advanced
|
||||
advancedSection
|
||||
|
||||
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()
|
||||
// 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()
|
||||
}
|
||||
.frame(minWidth: 500, idealWidth: 520, minHeight: 400, maxHeight: 1200)
|
||||
}
|
||||
|
||||
// MARK: - Status Mapping Row
|
||||
|
|
@ -225,8 +336,8 @@ struct SettingsView: View {
|
|||
}
|
||||
)
|
||||
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
return VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
StatusPill(status: status)
|
||||
Text(status.label)
|
||||
.font(.caption)
|
||||
|
|
|
|||
Loading…
Reference in New Issue