Files
Kordant/iOS/KordantWidgets/WidgetViews.swift
Michael Freno e33ddf3002 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
2026-06-02 15:01:38 -04:00

564 lines
18 KiB
Swift

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