- 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
564 lines
18 KiB
Swift
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
|
|
)
|
|
}
|