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 ) }