Atoll/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift
2026-01-22 15:57:17 +05:30

452 lines
16 KiB
Swift

import SwiftUI
import Defaults
import AtollExtensionKit
import WebKit
import AppKit
struct ExtensionLockScreenWidgetView: View {
let payload: ExtensionLockScreenWidgetPayload
private var descriptor: AtollLockScreenWidgetDescriptor { payload.descriptor }
private var accentColor: Color { descriptor.accentColor.swiftUIColor }
private var appearance: AtollWidgetAppearanceOptions? { descriptor.appearance }
var body: some View {
let shape = RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
ZStack {
backgroundLayer(shape: shape)
contentView
.padding(resolvedContentInsets)
}
.frame(width: descriptor.size.width, height: descriptor.size.height)
.clipShape(shape)
.overlay(borderOverlay(shape: shape))
.shadow(color: shadowColor, radius: shadowRadius, x: shadowOffset.width, y: shadowOffset.height)
.onAppear {
logWidgetDiagnostics("Rendering extension lock screen widget \(payload.descriptor.id) for \(payload.bundleIdentifier)")
}
.onDisappear {
logWidgetDiagnostics("Lock screen widget \(payload.descriptor.id) removed")
}
}
@ViewBuilder
private var contentView: some View {
switch descriptor.layoutStyle {
case .inline, .card, .custom:
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(descriptor.content.enumerated()), id: \.offset) { index, element in
view(for: element)
.frame(maxWidth: .infinity, alignment: alignment(for: element))
.accessibilityIdentifier("extension-widget-element-\(payload.id)-\(index)")
}
}
case .circular:
ZStack {
ForEach(Array(descriptor.content.enumerated()), id: \.offset) { index, element in
view(for: element)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.accessibilityIdentifier("extension-widget-element-\(payload.id)-\(index)")
}
}
}
}
private func view(for element: AtollWidgetContentElement) -> some View {
ExtensionWidgetElementView(
element: element,
accent: accentColor,
allowWebInteraction: false
)
}
private func alignment(for element: AtollWidgetContentElement) -> Alignment {
switch element {
case let .text(_, _, _, alignment):
return alignment.swiftUI
default:
return .leading
}
}
private var resolvedContentInsets: EdgeInsets {
if let custom = appearance?.contentInsets {
return EdgeInsets(top: custom.top, leading: custom.leading, bottom: custom.bottom, trailing: custom.trailing)
}
switch descriptor.layoutStyle {
case .circular:
return EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
default:
return EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)
}
}
@ViewBuilder
private func backgroundLayer(shape: RoundedRectangle) -> some View {
if descriptor.material == .liquid {
liquidGlassBackground(shape: shape)
} else if shouldUseGlassHighlight {
if #available(macOS 26.0, *) {
ZStack {
shape
.fill(Color.clear)
.glassEffect(
.clear.interactive(),
in: .rect(cornerRadius: descriptor.cornerRadius)
)
.allowsHitTesting(false)
.accessibilityHidden(true)
tintOverlay(shape: shape)
}
} else {
ZStack {
shape.fill(AnyShapeStyle(.regularMaterial))
tintOverlay(shape: shape)
}
}
} else {
ZStack {
shape.fill(baseMaterialStyle)
tintOverlay(shape: shape)
}
}
}
@ViewBuilder
private func liquidGlassBackground(shape: RoundedRectangle) -> some View {
LiquidGlassBackground(variant: requestedLiquidGlassVariant, cornerRadius: descriptor.cornerRadius) {
Color.black.opacity(0.08)
}
.overlay {
tintOverlay(shape: shape)
}
.allowsHitTesting(false)
.accessibilityHidden(true)
}
private var baseMaterialStyle: AnyShapeStyle {
switch descriptor.material {
case .frosted:
return AnyShapeStyle(.ultraThinMaterial)
case .liquid:
return AnyShapeStyle(.regularMaterial)
case .solid:
return AnyShapeStyle(resolvedSolidFillColor.opacity(0.95))
case .semiTransparent:
return AnyShapeStyle(resolvedSolidFillColor.opacity(0.35))
case .clear:
return AnyShapeStyle(Color.clear)
}
}
private var resolvedSolidFillColor: Color {
if let tint = appearance?.tintColor?.swiftUIColor {
return tint
}
return accentColor
}
@ViewBuilder
private func tintOverlay(shape: RoundedRectangle) -> some View {
Group {
if shouldApplyTintOverlay, let overlay = tintedOverlayColor {
shape
.fill(overlay)
.allowsHitTesting(false)
}
}
}
private var tintedOverlayColor: Color? {
guard let descriptor = appearance, let tint = descriptor.tintColor, descriptor.tintOpacity > 0 else {
return nil
}
return tint.swiftUIColor.opacity(descriptor.tintOpacity)
}
private var shouldApplyTintOverlay: Bool {
guard appearance?.tintColor != nil else { return false }
switch descriptor.material {
case .solid, .semiTransparent:
return false
default:
return true
}
}
private var shouldUseGlassHighlight: Bool {
(appearance?.enableGlassHighlight ?? false) && descriptor.material != .liquid
}
private var requestedLiquidGlassVariant: LiquidGlassVariant {
guard let requested = appearance?.liquidGlassVariant?.rawValue else {
return LiquidGlassVariant.defaultVariant
}
return LiquidGlassVariant.clamped(requested)
}
@ViewBuilder
private func borderOverlay(shape: RoundedRectangle) -> some View {
if let border = appearance?.border {
shape
.stroke(border.color.swiftUIColor.opacity(border.opacity), lineWidth: border.width)
} else {
shape
.stroke(Color.white.opacity(0.04), lineWidth: 1)
}
}
private var shadowColor: Color {
guard let shadow = appearance?.shadow else { return .clear }
return shadow.color.swiftUIColor.opacity(shadow.opacity)
}
private var shadowRadius: CGFloat {
appearance?.shadow?.radius ?? 0
}
private var shadowOffset: CGSize {
appearance?.shadow?.offset ?? .zero
}
}
private func logWidgetDiagnostics(_ message: String) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
Logger.log(message, category: .extensions)
}
struct ExtensionWidgetElementView: View {
let element: AtollWidgetContentElement
let accent: Color
let allowWebInteraction: Bool
var body: some View {
switch element {
case let .text(text, font: font, color: color, alignment: _):
Text(text)
.font(font.swiftUIFont())
.foregroundStyle((color?.swiftUIColor) ?? Color.white.opacity(0.9))
.lineLimit(2)
case let .icon(iconDescriptor, tint):
ExtensionIconView(
descriptor: iconDescriptor,
tint: (tint?.swiftUIColor) ?? accent,
size: CGSize(width: 28, height: 28),
cornerRadius: 8
)
case let .progress(indicator, value, color):
ExtensionProgressIndicatorView(
indicator: indicator,
progress: value,
accent: (color?.swiftUIColor) ?? accent,
estimatedDuration: nil
)
case let .graph(data, color, size):
ExtensionGraphView(data: data, color: color.swiftUIColor, size: size)
.frame(width: size.width, height: size.height)
case let .gauge(value, minValue, maxValue, style, color):
ExtensionGaugeView(
value: value,
minValue: minValue,
maxValue: maxValue,
style: style,
accent: (color?.swiftUIColor) ?? accent
)
.frame(maxWidth: .infinity)
case let .spacer(height):
Color.clear
.frame(height: height)
case let .divider(color, thickness):
Rectangle()
.fill(color.swiftUIColor.opacity(0.4))
.frame(height: thickness)
case let .webView(webDescriptor):
ExtensionWebContentView(descriptor: webDescriptor, allowInteraction: allowWebInteraction)
.frame(height: webDescriptor.preferredHeight)
.frame(maxWidth: webDescriptor.maximumContentWidth ?? .infinity)
}
}
}
struct ExtensionWebContentView: NSViewRepresentable {
let descriptor: AtollWidgetWebContentDescriptor
let allowInteraction: Bool
func makeCoordinator() -> Coordinator {
Coordinator(descriptor: descriptor)
}
func makeNSView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.suppressesIncrementalRendering = false
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
let webView = ConfigurableWKWebView(frame: .zero, configuration: configuration)
webView.allowInteraction = allowInteraction
webView.navigationDelegate = context.coordinator
webView.wantsLayer = true
applyConfiguration(descriptor, to: webView)
context.coordinator.lastHTML = descriptor.html
webView.loadHTMLString(descriptor.html, baseURL: nil)
return webView
}
func updateNSView(_ webView: WKWebView, context: Context) {
context.coordinator.descriptor = descriptor
webView.navigationDelegate = context.coordinator
applyConfiguration(descriptor, to: webView)
if let configurableView = webView as? ConfigurableWKWebView {
configurableView.allowInteraction = allowInteraction
}
if context.coordinator.lastHTML != descriptor.html {
webView.loadHTMLString(descriptor.html, baseURL: nil)
context.coordinator.lastHTML = descriptor.html
}
}
private func applyConfiguration(_ descriptor: AtollWidgetWebContentDescriptor, to webView: WKWebView) {
webView.allowsBackForwardNavigationGestures = false
webView.allowsLinkPreview = false
if descriptor.isTransparent {
webView.setValue(false, forKey: "drawsBackground")
webView.layer?.backgroundColor = NSColor.clear.cgColor
} else {
let fallbackColor = descriptor.backgroundColor?.nsColor ?? NSColor.windowBackgroundColor
webView.layer?.backgroundColor = fallbackColor.cgColor
}
}
private final class ConfigurableWKWebView: WKWebView {
var allowInteraction = false
override func hitTest(_ point: NSPoint) -> NSView? {
guard allowInteraction else { return nil }
return super.hitTest(point)
}
override var acceptsFirstResponder: Bool {
allowInteraction
}
}
final class Coordinator: NSObject, WKNavigationDelegate {
var descriptor: AtollWidgetWebContentDescriptor
var lastHTML: String?
init(descriptor: AtollWidgetWebContentDescriptor) {
self.descriptor = descriptor
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
return
}
if isAllowed(url: url) {
decisionHandler(.allow)
} else {
logWidgetDiagnostics("Blocked external navigation to \(url.absoluteString)")
decisionHandler(.cancel)
}
}
private func isAllowed(url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased() else { return false }
if scheme == "about" || scheme == "data" {
return true
}
if (scheme == "http" || scheme == "https") {
guard descriptor.allowLocalhostRequests else {
return false
}
let host = url.host?.lowercased()
return host == "localhost" || host == "127.0.0.1"
}
return false
}
}
}
private struct ExtensionGraphView: View {
let data: [Double]
let color: Color
let size: CGSize
var body: some View {
GeometryReader { proxy in
let minValue = data.min() ?? 0
let maxValue = data.max() ?? 1
let range = max(maxValue - minValue, 0.0001)
let step = proxy.size.width / CGFloat(max(data.count - 1, 1))
Path { path in
for (index, value) in data.enumerated() {
let x = CGFloat(index) * step
let normalized = (value - minValue) / range
let y = proxy.size.height - (CGFloat(normalized) * proxy.size.height)
if index == 0 {
path.move(to: CGPoint(x: x, y: y))
} else {
path.addLine(to: CGPoint(x: x, y: y))
}
}
}
.stroke(color, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
}
.frame(width: size.width, height: size.height)
}
}
private struct ExtensionGaugeView: View {
let value: Double
let minValue: Double
let maxValue: Double
let style: AtollWidgetContentElement.GaugeStyle
let accent: Color
var body: some View {
switch style {
case .circular:
ZStack {
Circle()
.stroke(Color.white.opacity(0.15), lineWidth: 5)
Circle()
.trim(from: 0, to: CGFloat(normalizedValue))
.stroke(accent, style: StrokeStyle(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.smooth(duration: 0.3), value: normalizedValue)
Text("\(Int(normalizedValue * 100))%")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.white)
.monospacedDigit()
}
.frame(width: 54, height: 54)
case .linear:
GeometryReader { proxy in
Capsule()
.fill(Color.white.opacity(0.2))
.frame(height: 8)
.overlay(alignment: .leading) {
Capsule()
.fill(accent)
.frame(width: proxy.size.width * CGFloat(normalizedValue), height: 8)
.animation(.smooth(duration: 0.3), value: normalizedValue)
}
}
.frame(height: 8)
}
}
private var normalizedValue: Double {
guard maxValue > minValue else { return 0 }
return min(max((value - minValue) / (maxValue - minValue), 0), 1)
}
}
private extension AtollWidgetContentElement.TextAlignment {
var swiftUI: Alignment {
switch self {
case .leading: return .leading
case .center: return .center
case .trailing: return .trailing
}
}
}