feat: complete Tasks 21-28 — backend integration, security hardening, UI tests & CI

- Add Apple Sign-In backend (JWKS verification, account linking, session management)
- Implement push notification deep linking with NotificationDeepLinkRouter
- Add jailbreak detection, runtime integrity monitoring, secure enclave service
- Implement OAuth social login, token refresh, and secure logout flows
- Add image caching (memory/disk), optimizer, upload queue, async semaphore
- Implement notification analytics, type preferences, and category setup
- Expand UI test suite with UITestBase, accessibility, auth flow, performance tests
- Add CI pipeline for iOS UI tests (3 device sizes) and performance benchmarks
- Restructure Xcode project to manual groups with KordantWidgets target
- Add SwiftLint, Swift Collections/Algorithms/GoogleSignIn dependencies
- Update project.yml for XcodeGen with new targets and configurations
This commit is contained in:
2026-06-02 15:01:38 -04:00
parent ab0d4857db
commit e33ddf3002
49 changed files with 10472 additions and 421 deletions

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Kordant Widgets</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.frenocorp.kordant</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,163 @@
import SwiftUI
import WidgetKit
// MARK: - Widget Bundle
@main
struct KordantWidgets: WidgetBundle {
var body: some Widget {
ThreatScoreWidget() // systemSmall
AlertSummaryWidget() // systemMedium
FullDashboardWidget() // systemLarge
}
}
// MARK: - Timeline Provider
struct KordantWidgetProvider: IntentTimelineProvider {
typealias Entry = WidgetEntry
typealias Intent = KordantWidgetConfigurationIntent
func placeholder(in context: Context) -> WidgetEntry {
WidgetEntry(
date: Date(),
widgetData: .placeholder,
severityFilter: .all,
isPlaceholder: true
)
}
func getSnapshot(
for configuration: KordantWidgetConfigurationIntent,
in context: Context,
completion: @escaping (WidgetEntry) -> Void
) {
Task {
let data = WidgetDataManager.shared.load() ?? .placeholder
let entry = WidgetEntry(
date: Date(),
widgetData: data,
severityFilter: configuration.severityFilter,
isPlaceholder: context.isPreview
)
completion(entry)
}
}
func getTimeline(
for configuration: KordantWidgetConfigurationIntent,
in context: Context,
completion: @escaping (Timeline<WidgetEntry>) -> Void
) {
Task {
let data = WidgetDataManager.shared.load() ?? .unavailable
let entry = WidgetEntry(
date: Date(),
widgetData: data,
severityFilter: configuration.severityFilter,
isPlaceholder: false
)
// Widgets must refresh at most every 15 minutes per system policy.
let nextRefresh = Calendar.current.date(
byAdding: .minute,
value: 15,
to: Date()
) ?? Date().addingTimeInterval(900)
// If data is unavailable, retry sooner (5 minutes) to pick up
// initial data after the user opens the app.
let refreshDate = data == .unavailable
? Date().addingTimeInterval(300)
: nextRefresh
let timeline = Timeline(entries: [entry], policy: .after(refreshDate))
completion(timeline)
}
}
}
// MARK: - System Small: Threat Score Gauge
struct ThreatScoreWidget: Widget {
let kind: String = "com.frenocorp.kordant.widget.threatScore"
var body: some WidgetConfiguration {
IntentConfiguration(
kind: kind,
intent: KordantWidgetConfigurationIntent.self,
provider: KordantWidgetProvider()
) { entry in
KordantWidgetsEntryView(entry: entry)
.containerBackground(.widgetBackground, for: .widget)
}
.configurationDisplayName("Threat Score")
.description("Your current Kordant threat score at a glance.")
.supportedFamilies([.systemSmall])
.contentMarginsDisabled()
}
}
// MARK: - System Medium: Threat Score + Recent Alerts
struct AlertSummaryWidget: Widget {
let kind: String = "com.frenocorp.kordant.widget.alertSummary"
var body: some WidgetConfiguration {
IntentConfiguration(
kind: kind,
intent: KordantWidgetConfigurationIntent.self,
provider: KordantWidgetProvider()
) { entry in
KordantWidgetsEntryView(entry: entry)
.containerBackground(.widgetBackground, for: .widget)
}
.configurationDisplayName("Alert Summary")
.description("Your threat score with up to 2 recent alerts.")
.supportedFamilies([.systemMedium])
.contentMarginsDisabled()
}
}
// MARK: - System Large: Full Dashboard
struct FullDashboardWidget: Widget {
let kind: String = "com.frenocorp.kordant.widget.fullDashboard"
var body: some WidgetConfiguration {
IntentConfiguration(
kind: kind,
intent: KordantWidgetConfigurationIntent.self,
provider: KordantWidgetProvider()
) { entry in
KordantWidgetsEntryView(entry: entry)
.containerBackground(.widgetBackground, for: .widget)
}
.configurationDisplayName("Security Dashboard")
.description("Full dashboard with threat score, alerts, stats, and quick actions.")
.supportedFamilies([.systemLarge])
.contentMarginsDisabled()
}
}
// MARK: - Entry View Router
struct KordantWidgetsEntryView: View {
let entry: WidgetEntry
@Environment(\.widgetFamily) var family
var body: some View {
Group {
switch family {
case .systemSmall:
SmallWidgetView(entry: entry)
case .systemMedium:
MediumWidgetView(entry: entry)
case .systemLarge:
LargeWidgetView(entry: entry)
@unknown default:
SmallWidgetView(entry: entry)
}
}
}
}

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
Privacy Manifest for Kordant Widget Extension
The widget extension only reads/writes shared data via App Group UserDefaults.
-->
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<!-- User Defaults API - used to read widget data from shared App Group container -->
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>79D5.1</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,59 @@
import SwiftUI
/// Color definitions for the widget extension.
/// Mirrors the main app's design system values so widgets match the app look.
extension Color {
// MARK: - Brand Colors
static let brandPrimary = Color(red: 79 / 255, green: 70 / 255, blue: 229 / 255)
static let brandPrimaryLight = Color(red: 129 / 255, green: 140 / 255, blue: 248 / 255)
static let brandAccent = Color(red: 6 / 255, green: 182 / 255, blue: 212 / 255)
static let brandAccentLight = Color(red: 103 / 255, green: 232 / 255, blue: 249 / 255)
// MARK: - Semantic Colors
static let success = Color(red: 6 / 255, green: 182 / 255, blue: 212 / 255)
static let warning = Color(red: 245 / 255, green: 158 / 255, blue: 11 / 255)
static let error = Color(red: 239 / 255, green: 68 / 255, blue: 68 / 255)
// MARK: - Backgrounds (adapt to light/dark)
static let widgetBackground = Color(UIColor { tc in
tc.userInterfaceStyle == .dark ? UIColor(hex: "#111827") : UIColor(hex: "#fafbfc")
})
static let widgetSecondaryBackground = Color(UIColor { tc in
tc.userInterfaceStyle == .dark ? UIColor(hex: "#1f2937") : UIColor(hex: "#f3f4f6")
})
static let widgetTertiaryBackground = Color(UIColor { tc in
tc.userInterfaceStyle == .dark ? UIColor(hex: "#374151") : UIColor(hex: "#e5e7eb")
})
// MARK: - Text Colors (adapt to light/dark)
static let widgetTextPrimary = Color(UIColor { tc in
tc.userInterfaceStyle == .dark ? UIColor(hex: "#f9fafb") : UIColor(hex: "#111827")
})
static let widgetTextSecondary = Color(UIColor { tc in
tc.userInterfaceStyle == .dark ? UIColor(hex: "#d1d5db") : UIColor(hex: "#6b7280")
})
static let widgetTextTertiary = Color(UIColor { tc in
tc.userInterfaceStyle == .dark ? UIColor(hex: "#9ca3af") : UIColor(hex: "#9ca3af")
})
}
// MARK: - UIColor Hex Helper
extension UIColor {
convenience init(hex: String) {
var sanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
sanitized = sanitized.replacingOccurrences(of: "#", with: "")
var rgb: UInt64 = 0
Scanner(string: sanitized).scanHexInt64(&rgb)
self.init(
red: CGFloat((rgb & 0xFF0000) >> 16) / 255,
green: CGFloat((rgb & 0x00FF00) >> 8) / 255,
blue: CGFloat(rgb & 0x0000FF) / 255,
alpha: 1
)
}
}

View File

@@ -0,0 +1,51 @@
import AppIntents
import WidgetKit
/// Severity filter for widget alert display.
enum AlertSeverityFilter: String, AppEnum {
case all
case critical
case high
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Alert Severity"
static var caseDisplayRepresentations: [AlertSeverityFilter: DisplayRepresentation] = [
.all: DisplayRepresentation(
title: "All Alerts",
subtitle: "Show alerts of all severity levels"
),
.critical: DisplayRepresentation(
title: "Critical Only",
subtitle: "Only show critical alerts"
),
.high: DisplayRepresentation(
title: "High & Critical",
subtitle: "Only show high and critical alerts"
),
]
/// Returns whether an alert with the given severity string passes this filter.
func matches(severity: String) -> Bool {
switch self {
case .all:
return true
case .critical:
return severity == "critical"
case .high:
return severity == "critical" || severity == "high"
}
}
}
/// Configuration intent for Kordant widgets.
/// Allows users to filter alerts by severity from the widget gallery.
struct KordantWidgetConfigurationIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Widget Configuration"
static var description: LocalizedStringResource = "Choose which alerts to display on your widget."
@Parameter(
title: "Severity Filter",
default: .all
)
var severityFilter: AlertSeverityFilter
}

View File

@@ -0,0 +1,563 @@
import SwiftUI
import WidgetKit
// MARK: - Entry
struct WidgetEntry: TimelineEntry {
let date: Date
let widgetData: WidgetData
let severityFilter: AlertSeverityFilter
let isPlaceholder: Bool
}
// MARK: - Shared Helpers
extension WidgetData {
var threatPercentage: Int { Int(threatScore * 100) }
}
extension WidgetAlert {
var severityColor: Color {
switch severityEnum {
case .critical: return .error
case .high: return .warning
case .medium: return .warning
case .low: return .success
}
}
var icon: String {
switch typeEnum {
case .exposure: return "eye.fill"
case .breach: return "lock.shield.fill"
case .login: return "person.fill"
case .voiceMatch: return "waveform"
case .removal: return "trash.fill"
}
}
var deepLink: URL {
URL(string: "kordant://alerts/\(id)")!
}
}
extension ThreatLevel {
var color: Color {
switch self {
case .low: return .success
case .medium: return .warning
case .high: return .warning
case .critical: return .error
}
}
var gradient: LinearGradient {
switch self {
case .low:
return LinearGradient(colors: [.success, .success.opacity(0.7)], startPoint: .leading, endPoint: .trailing)
case .medium, .high:
return LinearGradient(colors: [.warning, .warning.opacity(0.7)], startPoint: .leading, endPoint: .trailing)
case .critical:
return LinearGradient(colors: [.error, .error.opacity(0.7)], startPoint: .leading, endPoint: .trailing)
}
}
}
// MARK: - Threat Score Gauge (reusable across all widget sizes)
struct ThreatScoreGauge: View {
let score: Double
let percentage: Int
let level: ThreatLevel
var showLabel: Bool = true
var gaugeSize: CGFloat = 100
var body: some View {
ZStack {
// Background ring
Circle()
.stroke(Color.widgetTertiaryBackground, lineWidth: 10)
// Score ring
Circle()
.trim(from: 0, to: score)
.stroke(
level.gradient,
style: StrokeStyle(lineWidth: 10, lineCap: .round)
)
.rotationEffect(.degrees(-90))
// Center text
VStack(spacing: 1) {
Text("\(percentage)")
.font(.system(size: gaugeSize * 0.32, weight: .bold, design: .rounded))
.foregroundColor(.widgetTextPrimary)
.contentTransition(.numericText(value: Double(percentage)))
if showLabel {
Text("/ 100")
.font(.system(size: gaugeSize * 0.12, weight: .medium, design: .rounded))
.foregroundColor(.widgetTextTertiary)
}
}
}
.frame(width: gaugeSize, height: gaugeSize)
}
}
// MARK: - Alert Row (compact, for widget use)
struct WidgetAlertRow: View {
let alert: WidgetAlert
var body: some View {
Link(destination: alert.deepLink) {
HStack(spacing: 8) {
Image(systemName: alert.icon)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(alert.severityColor)
.frame(width: 24, height: 24)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(alert.severityColor.opacity(0.12))
)
VStack(alignment: .leading, spacing: 1) {
Text(alert.title)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.widgetTextPrimary)
.lineLimit(1)
Text(alert.message)
.font(.system(size: 11, weight: .regular))
.foregroundColor(.widgetTextSecondary)
.lineLimit(1)
}
Spacer(minLength: 4)
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.widgetTextTertiary)
}
.padding(.vertical, 4)
}
.buttonStyle(.plain)
}
}
// MARK: - No Data / Placeholder
struct WidgetNoDataView: View {
var body: some View {
VStack(spacing: 8) {
Image(systemName: "shield.slash")
.font(.title2)
.foregroundColor(.widgetTextTertiary)
Text("Open Kordant")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.widgetTextPrimary)
Text("to see your threat score")
.font(.system(size: 11))
.foregroundColor(.widgetTextSecondary)
.multilineTextAlignment(.center)
}
.padding()
}
}
// MARK: - Last Updated Footer
struct LastUpdatedFooter: View {
let date: Date
var body: some View {
Text("Updated \(date, style: .relative)")
.font(.system(size: 9, weight: .regular))
.foregroundColor(.widgetTextTertiary)
}
}
// MARK: - Small Widget View
struct SmallWidgetView: View {
let entry: WidgetEntry
var body: some View {
if entry.isPlaceholder || entry.widgetData == .unavailable {
WidgetNoDataView()
.widgetURL(URL(string: "kordant://dashboard")!)
} else {
VStack(spacing: 6) {
HStack {
Image(systemName: "shield.fill")
.font(.system(size: 10, weight: .bold))
.foregroundColor(.brandPrimary)
Text("Kordant")
.font(.system(size: 11, weight: .bold))
.foregroundColor(.brandPrimary)
Spacer()
}
Spacer()
ThreatScoreGauge(
score: entry.widgetData.threatScore,
percentage: entry.widgetData.threatPercentage,
level: entry.widgetData.threatLevel,
showLabel: true,
gaugeSize: 100
)
Spacer()
Text("THREAT SCORE")
.font(.system(size: 9, weight: .bold))
.foregroundColor(.widgetTextTertiary)
.tracking(1.5)
}
.padding()
.widgetURL(URL(string: "kordant://dashboard")!)
}
}
}
// MARK: - Medium Widget View
struct MediumWidgetView: View {
let entry: WidgetEntry
private var filteredAlerts: [WidgetAlert] {
entry.widgetData.recentAlerts.filter { entry.severityFilter.matches(severity: $0.severity) }
}
var body: some View {
if entry.isPlaceholder || entry.widgetData == .unavailable {
WidgetNoDataView()
.widgetURL(URL(string: "kordant://dashboard")!)
} else {
HStack(spacing: 12) {
// Left: Gauge
VStack(spacing: 4) {
ThreatScoreGauge(
score: entry.widgetData.threatScore,
percentage: entry.widgetData.threatPercentage,
level: entry.widgetData.threatLevel,
showLabel: true,
gaugeSize: 80
)
Text("\(entry.widgetData.alertCount) alert\(entry.widgetData.alertCount == 1 ? "" : "s")")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.widgetTextSecondary)
}
.widgetURL(URL(string: "kordant://dashboard")!)
// Right: Alert list
VStack(alignment: .leading, spacing: 0) {
HStack {
Image(systemName: "shield.fill")
.font(.system(size: 10, weight: .bold))
.foregroundColor(.brandPrimary)
Text("Kordant")
.font(.system(size: 11, weight: .bold))
.foregroundColor(.brandPrimary)
Spacer()
}
.padding(.bottom, 6)
if filteredAlerts.isEmpty {
VStack(spacing: 4) {
Spacer()
Image(systemName: "checkmark.circle.fill")
.font(.title3)
.foregroundColor(.success)
Text("No alerts to show")
.font(.system(size: 12))
.foregroundColor(.widgetTextSecondary)
Spacer()
}
.frame(maxWidth: .infinity)
} else {
ForEach(filteredAlerts.prefix(2)) { alert in
WidgetAlertRow(alert: alert)
}
if filteredAlerts.count > 2 {
Link(destination: URL(string: "kordant://alerts")!) {
HStack(spacing: 4) {
Text("+\(filteredAlerts.count - 2) more")
.font(.system(size: 11, weight: .medium))
.foregroundColor(.brandPrimary)
Image(systemName: "chevron.right")
.font(.system(size: 8))
.foregroundColor(.brandPrimary)
}
.padding(.top, 2)
}
.buttonStyle(.plain)
}
}
Spacer(minLength: 0)
LastUpdatedFooter(date: entry.widgetData.lastUpdated)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding()
}
}
}
// MARK: - Large Widget View
struct LargeWidgetView: View {
let entry: WidgetEntry
private var filteredAlerts: [WidgetAlert] {
entry.widgetData.recentAlerts.filter { entry.severityFilter.matches(severity: $0.severity) }
}
var body: some View {
if entry.isPlaceholder || entry.widgetData == .unavailable {
WidgetNoDataView()
.widgetURL(URL(string: "kordant://dashboard")!)
} else {
VStack(spacing: 12) {
// Header
HStack {
Image(systemName: "shield.fill")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.brandPrimary)
Text("Kordant")
.font(.system(size: 15, weight: .bold))
.foregroundColor(.brandPrimary)
Spacer()
LastUpdatedFooter(date: entry.widgetData.lastUpdated)
}
// Score + Stats row
HStack(spacing: 16) {
ThreatScoreGauge(
score: entry.widgetData.threatScore,
percentage: entry.widgetData.threatPercentage,
level: entry.widgetData.threatLevel,
showLabel: true,
gaugeSize: 80
)
.widgetURL(URL(string: "kordant://dashboard")!)
VStack(alignment: .leading, spacing: 8) {
StatRow(
icon: "bell.fill",
count: entry.widgetData.unreadCount,
label: "Unread",
color: .error
)
StatRow(
icon: "eye.fill",
count: entry.widgetData.exposureCount,
label: "Exposures",
color: .warning
)
StatRow(
icon: "exclamationmark.triangle.fill",
count: entry.widgetData.criticalCount,
label: "Critical",
color: .error
)
}
Spacer()
}
// Divider
Rectangle()
.fill(Color.widgetTertiaryBackground)
.frame(height: 1)
// Alerts section header
HStack {
Text("Recent Alerts")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.widgetTextPrimary)
Spacer()
if entry.widgetData.alertCount > 0 {
Link(destination: URL(string: "kordant://alerts")!) {
HStack(spacing: 3) {
Text("See all")
.font(.system(size: 11, weight: .medium))
.foregroundColor(.brandPrimary)
}
}
.buttonStyle(.plain)
}
}
// Alert list
if filteredAlerts.isEmpty {
HStack {
Spacer()
VStack(spacing: 4) {
Image(systemName: "checkmark.circle.fill")
.font(.title2)
.foregroundColor(.success)
Text("No alerts matching filter")
.font(.system(size: 12))
.foregroundColor(.widgetTextSecondary)
}
Spacer()
}
} else {
ForEach(filteredAlerts.prefix(4)) { alert in
WidgetAlertRow(alert: alert)
}
if filteredAlerts.count > 4 {
Link(destination: URL(string: "kordant://alerts")!) {
HStack(spacing: 4) {
Text("+\(filteredAlerts.count - 4) more alerts")
.font(.system(size: 11, weight: .medium))
.foregroundColor(.brandPrimary)
Image(systemName: "chevron.right")
.font(.system(size: 8))
.foregroundColor(.brandPrimary)
}
.padding(.top, 2)
}
.buttonStyle(.plain)
}
}
Spacer(minLength: 0)
// Quick action buttons
HStack(spacing: 8) {
QuickActionLink(
icon: "arrow.clockwise.shield",
label: "Scan",
url: URL(string: "kordant://dashboard")!,
color: .brandPrimary
)
QuickActionLink(
icon: "bell.badge",
label: "Alerts",
url: URL(string: "kordant://alerts")!,
color: .error
)
QuickActionLink(
icon: "person.badge.shield",
label: "Profile",
url: URL(string: "kordant://account")!,
color: .brandAccent
)
QuickActionLink(
icon: "gearshape",
label: "Settings",
url: URL(string: "kordant://settings")!,
color: .widgetTextSecondary
)
}
}
.padding()
}
}
}
// MARK: - Supporting Views
struct StatRow: View {
let icon: String
let count: Int
let label: String
let color: Color
var body: some View {
HStack(spacing: 6) {
Image(systemName: icon)
.font(.system(size: 10, weight: .bold))
.foregroundColor(color)
.frame(width: 20, height: 20)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(color.opacity(0.12))
)
VStack(alignment: .leading, spacing: 0) {
Text("\(count)")
.font(.system(size: 14, weight: .bold, design: .rounded))
.foregroundColor(.widgetTextPrimary)
Text(label)
.font(.system(size: 9, weight: .regular))
.foregroundColor(.widgetTextSecondary)
}
}
}
}
struct QuickActionLink: View {
let icon: String
let label: String
let url: URL
let color: Color
var body: some View {
Link(destination: url) {
VStack(spacing: 4) {
Image(systemName: icon)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.white)
.frame(width: 36, height: 36)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(color)
)
Text(label)
.font(.system(size: 9, weight: .medium))
.foregroundColor(.widgetTextSecondary)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
}
}
// MARK: - Previews
#Preview("Small Widget", as: .systemSmall) {
KordantWidgetsEntryView(entry: .preview)
} timeline: {
WidgetEntry.preview
}
#Preview("Medium Widget", as: .systemMedium) {
KordantWidgetsEntryView(entry: .preview)
} timeline: {
WidgetEntry.preview
}
#Preview("Large Widget", as: .systemLarge) {
KordantWidgetsEntryView(entry: .preview)
} timeline: {
WidgetEntry.preview
}
extension WidgetEntry {
static let preview = WidgetEntry(
date: Date(),
widgetData: .placeholder,
severityFilter: .all,
isPlaceholder: false
)
}