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:
29
iOS/KordantWidgets/Info.plist
Normal file
29
iOS/KordantWidgets/Info.plist
Normal 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>
|
||||
10
iOS/KordantWidgets/KordantWidgets.entitlements
Normal file
10
iOS/KordantWidgets/KordantWidgets.entitlements
Normal 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>
|
||||
163
iOS/KordantWidgets/KordantWidgets.swift
Normal file
163
iOS/KordantWidgets/KordantWidgets.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
iOS/KordantWidgets/PrivacyInfo.xcprivacy
Normal file
28
iOS/KordantWidgets/PrivacyInfo.xcprivacy
Normal 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>
|
||||
59
iOS/KordantWidgets/WidgetColors.swift
Normal file
59
iOS/KordantWidgets/WidgetColors.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
51
iOS/KordantWidgets/WidgetConfigurationIntent.swift
Normal file
51
iOS/KordantWidgets/WidgetConfigurationIntent.swift
Normal 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
|
||||
}
|
||||
563
iOS/KordantWidgets/WidgetViews.swift
Normal file
563
iOS/KordantWidgets/WidgetViews.swift
Normal 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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user