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:
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