Update stuff

This commit is contained in:
Hariharan Mudaliar 2026-01-18 01:46:35 +05:30
commit 9214c61544
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
18 changed files with 985 additions and 118 deletions

View file

@ -16,7 +16,6 @@ struct ExtensionLiveActivityStandaloneView: View {
let payload: ExtensionLiveActivityPayload
let layout: ExtensionStandaloneLayout
let isHovering: Bool
let notchState: NotchState
private var descriptor: AtollLiveActivityDescriptor { payload.descriptor }
private var contentHeight: CGFloat { layout.contentHeight }
@ -35,10 +34,6 @@ struct ExtensionLiveActivityStandaloneView: View {
}
}
private var shouldSuppressCenterText: Bool {
layout.suppressingCenterText
}
var body: some View {
HStack(spacing: 0) {
ExtensionLeadingContentView(
@ -56,7 +51,9 @@ struct ExtensionLiveActivityStandaloneView: View {
.frame(width: layout.centerWidth, height: contentHeight)
.overlay(
Group {
if !shouldSuppressCenterText {
if layout.suppressingCenterText {
EmptyView()
} else {
ExtensionCenterContentView(
descriptor: descriptor,
accent: accentColor,
@ -75,10 +72,12 @@ struct ExtensionLiveActivityStandaloneView: View {
.frame(width: layout.trailingWidth, height: contentHeight)
}
.frame(width: layout.totalWidth, height: layout.outerHeight + (isHovering ? 8 : 0))
.transition(.asymmetric(
insertion: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.4, dampingFraction: 0.8)),
removal: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.3, dampingFraction: 0.9))
))
.transition(
.asymmetric(
insertion: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.4, dampingFraction: 0.8)),
removal: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.3, dampingFraction: 0.9))
)
)
.animation(.smooth(duration: 0.25), value: payload.id)
.onAppear {
logExtensionDiagnostics("Displaying extension live activity \(payload.descriptor.id) for \(payload.bundleIdentifier) as standalone view")
@ -106,7 +105,7 @@ struct ExtensionMusicWingView: View {
switch trailingRenderable {
case let .content(content):
if case .none = content {
EmptyView()
Spacer(minLength: 0)
} else {
ExtensionEdgeContentView(
content: content,
@ -126,7 +125,7 @@ struct ExtensionMusicWingView: View {
)
.frame(maxWidth: .infinity, alignment: .trailing)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
@ -239,10 +238,152 @@ private func logExtensionDiagnostics(_ message: String) {
Logger.log(message, category: .extensions)
}
struct ExtensionNotchExperienceTabView: View {
let payload: ExtensionNotchExperiencePayload
@Default(.enableExtensionNotchInteractiveWebViews) private var interactiveWebViewsEnabled
private var descriptor: AtollNotchExperienceDescriptor { payload.descriptor }
private var tabConfiguration: AtollNotchExperienceDescriptor.TabConfiguration? { descriptor.tab }
private var accentColor: Color { descriptor.accentColor.swiftUIColor }
private var allowInteractiveWebViews: Bool {
interactiveWebViewsEnabled && (tabConfiguration?.allowWebInteraction ?? false)
}
var body: some View {
Group {
if let tabConfiguration {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 18) {
header(for: tabConfiguration)
ForEach(Array(tabConfiguration.sections.enumerated()), id: \.offset) { index, section in
ExtensionNotchSectionView(
section: section,
accent: accentColor,
allowWebInteraction: allowInteractiveWebViews
)
.accessibilityIdentifier("extension-notch-section-\(payload.descriptor.id)-\(index)")
}
if let webDescriptor = tabConfiguration.webContent {
ExtensionWebContentView(descriptor: webDescriptor, allowInteraction: allowInteractiveWebViews)
.frame(height: webDescriptor.preferredHeight)
.frame(maxWidth: webDescriptor.maximumContentWidth ?? .infinity)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
if let footnote = tabConfiguration.footnote {
Text(footnote)
.font(.system(size: 11, weight: .regular))
.foregroundStyle(Color.white.opacity(0.65))
.lineLimit(2)
}
}
.padding(.vertical, 14)
.padding(.horizontal, 16)
}
} else {
Text("Extension tab unavailable")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white.opacity(0.6))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(tabBackground)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
}
@ViewBuilder
private func header(for configuration: AtollNotchExperienceDescriptor.TabConfiguration) -> some View {
HStack(spacing: 10) {
Group {
if let badgeIcon = configuration.badgeIcon {
ExtensionIconView(
descriptor: badgeIcon,
tint: accentColor,
size: CGSize(width: 32, height: 32),
cornerRadius: 10
)
} else {
Image(systemName: configuration.iconSymbolName ?? "puzzlepiece.extension")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(accentColor.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
VStack(alignment: .leading, spacing: 2) {
Text(configuration.title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(1)
}
Spacer(minLength: 0)
}
}
private var tabBackground: some View {
AnyView(
LinearGradient(
colors: [Color.white.opacity(0.04), accentColor.opacity(0.08)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
}
struct ExtensionNotchSectionView: View {
let section: AtollNotchContentSection
let accent: Color
let allowWebInteraction: Bool
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ExtensionNotchSectionHeader(section: section)
layoutContent
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.white.opacity(0.04))
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
}
@ViewBuilder
private var layoutContent: some View {
switch section.layout {
case .stack:
VStack(alignment: .leading, spacing: 10) {
elementViews
}
case .columns:
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], spacing: 12) {
elementViews
}
case .metrics:
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
elementViews
}
}
}
@ViewBuilder
private var elementViews: some View {
ForEach(Array(section.elements.enumerated()), id: \.offset) { index, element in
ExtensionWidgetElementView(
element: element,
accent: accent,
allowWebInteraction: allowWebInteraction
)
.accessibilityIdentifier("extension-notch-element-\(index)")
}
}
}
struct ExtensionInlineSneakPeekView: View {
let payload: ExtensionLiveActivityPayload?
let title: String
let subtitle: String
let subtitle: String?
let accentColor: Color
let notchHeight: CGFloat
let closedNotchWidth: CGFloat
@ -251,7 +392,6 @@ struct ExtensionInlineSneakPeekView: View {
private var descriptor: AtollLiveActivityDescriptor? { payload?.descriptor }
private var resolvedAccent: Color { descriptor?.accentColor.swiftUIColor ?? accentColor }
private var contentHeight: CGFloat {
max(0, notchHeight - (isHovering ? 0 : 12))
}
@ -274,11 +414,19 @@ struct ExtensionInlineSneakPeekView: View {
}
private var resolvedTitle: String {
title.isEmpty ? "Extension" : title
let descriptorTitle = descriptor?.title ?? "Extension"
let preferred = title.isEmpty ? descriptorTitle : title
return preferred.isEmpty ? "Extension" : preferred
}
private var resolvedSubtitle: String {
subtitle.trimmingCharacters(in: .whitespacesAndNewlines)
if let subtitle, !subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return subtitle.trimmingCharacters(in: .whitespacesAndNewlines)
}
if let descriptorSubtitle = descriptor?.subtitle, !descriptorSubtitle.isEmpty {
return descriptorSubtitle
}
return ""
}
var body: some View {
@ -291,16 +439,14 @@ struct ExtensionInlineSneakPeekView: View {
.frame(width: centerWidth, height: contentHeight)
.overlay(alignment: .bottomLeading) {
VStack(alignment: .leading, spacing: 2) {
if !resolvedTitle.isEmpty {
MarqueeText(
.constant(resolvedTitle),
font: .system(size: 12, weight: .semibold),
nsFont: .subheadline,
textColor: .white,
minDuration: 0.4,
frameWidth: marqueeFrameWidth
)
}
MarqueeText(
.constant(resolvedTitle),
font: .system(size: 12, weight: .semibold),
nsFont: .subheadline,
textColor: .white,
minDuration: 0.4,
frameWidth: marqueeFrameWidth
)
if !resolvedSubtitle.isEmpty {
Text(resolvedSubtitle)
.font(.system(size: 11, weight: .regular))
@ -338,11 +484,6 @@ struct ExtensionInlineSneakPeekView: View {
@ViewBuilder
private var trailingWing: some View {
spectrumPlaceholder
}
@ViewBuilder
private var spectrumPlaceholder: some View {
VStack {
Rectangle()
.fill(resolvedAccent.gradient)
@ -357,3 +498,77 @@ struct ExtensionInlineSneakPeekView: View {
.padding(.vertical, 6)
}
}
struct ExtensionNotchSectionHeader: View {
let section: AtollNotchContentSection
var body: some View {
VStack(alignment: .leading, spacing: 2) {
if let title = section.title {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
}
if let subtitle = section.subtitle {
Text(subtitle)
.font(.system(size: 11, weight: .regular))
.foregroundStyle(Color.white.opacity(0.7))
}
}
}
}
struct ExtensionMinimalisticExperienceView: View {
let payload: ExtensionNotchExperiencePayload
let albumArtNamespace: Namespace.ID
@Default(.enableExtensionNotchInteractiveWebViews) private var interactiveWebViewsEnabled
private var descriptor: AtollNotchExperienceDescriptor { payload.descriptor }
private var configuration: AtollNotchExperienceDescriptor.MinimalisticConfiguration? { descriptor.minimalistic }
private var accent: Color { descriptor.accentColor.swiftUIColor }
var body: some View {
Group {
if let configuration {
let hasWebContent = configuration.webContent != nil
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
if let headline = configuration.headline {
Text(headline)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
}
if let subtitle = configuration.subtitle {
Text(subtitle)
.font(.system(size: 12, weight: .regular))
.foregroundStyle(Color.white.opacity(0.75))
}
ForEach(Array(configuration.sections.enumerated()), id: \.offset) { index, section in
ExtensionNotchSectionView(
section: section,
accent: accent,
allowWebInteraction: interactiveWebViewsEnabled
)
.accessibilityIdentifier("extension-minimalistic-section-\(payload.descriptor.id)-\(index)")
}
if let webDescriptor = configuration.webContent {
ExtensionWebContentView(
descriptor: webDescriptor,
allowInteraction: interactiveWebViewsEnabled
)
.frame(height: webDescriptor.preferredHeight)
.frame(maxWidth: webDescriptor.maximumContentWidth ?? .infinity)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
.padding(.horizontal, 14)
.padding(.top, 10)
.padding(.bottom, hasWebContent ? 0 : 10)
}
} else {
MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace)
}
}
}
}

View file

@ -52,53 +52,12 @@ struct ExtensionLockScreenWidgetView: View {
}
}
@ViewBuilder
private func view(for element: AtollWidgetContentElement) -> 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) ?? accentColor,
size: CGSize(width: 28, height: 28),
cornerRadius: 8
)
case let .progress(indicator, value, color):
ExtensionProgressIndicatorView(
indicator: indicator,
progress: value,
accent: (color?.swiftUIColor) ?? accentColor,
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) ?? accentColor
)
.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)
.frame(height: webDescriptor.preferredHeight)
.frame(maxWidth: webDescriptor.maximumContentWidth ?? .infinity)
.allowsHitTesting(false)
}
ExtensionWidgetElementView(
element: element,
accent: accentColor,
allowWebInteraction: false
)
}
private func alignment(for element: AtollWidgetContentElement) -> Alignment {
@ -235,8 +194,62 @@ private func logWidgetDiagnostics(_ message: String) {
Logger.log(message, category: .extensions)
}
private struct ExtensionWebContentView: NSViewRepresentable {
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)
@ -246,7 +259,8 @@ private struct ExtensionWebContentView: NSViewRepresentable {
let configuration = WKWebViewConfiguration()
configuration.suppressesIncrementalRendering = false
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
let webView = NonInteractiveWKWebView(frame: .zero, configuration: configuration)
let webView = ConfigurableWKWebView(frame: .zero, configuration: configuration)
webView.allowInteraction = allowInteraction
webView.navigationDelegate = context.coordinator
webView.wantsLayer = true
applyConfiguration(descriptor, to: webView)
@ -259,6 +273,9 @@ private struct ExtensionWebContentView: NSViewRepresentable {
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
@ -277,13 +294,16 @@ private struct ExtensionWebContentView: NSViewRepresentable {
}
}
private final class NonInteractiveWKWebView: WKWebView {
private final class ConfigurableWKWebView: WKWebView {
var allowInteraction = false
override func hitTest(_ point: NSPoint) -> NSView? {
nil
guard allowInteraction else { return nil }
return super.hitTest(point)
}
override var acceptsFirstResponder: Bool {
false
allowInteraction
}
}

View file

@ -378,6 +378,7 @@ struct NotchHomeView: View {
@ObservedObject var webcamManager = WebcamManager.shared
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
let albumArtNamespace: Namespace.ID
var body: some View {
@ -392,8 +393,14 @@ struct NotchHomeView: View {
private var mainContent: some View {
HStack(alignment: .top, spacing: 20) {
if Defaults[.enableMinimalisticUI] {
// Minimalistic mode: Only show compact music player
MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace)
if let overridePayload = minimalisticOverridePayload {
ExtensionMinimalisticExperienceView(
payload: overridePayload,
albumArtNamespace: albumArtNamespace
)
} else {
MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace)
}
} else {
// Normal mode: Show full music player with optional calendar and webcam
MusicPlayerView(albumArtNamespace: albumArtNamespace)
@ -422,6 +429,10 @@ struct NotchHomeView: View {
.combined(with: .move(edge: .top)))
.blur(radius: vm.notchState == .closed ? 30 : 0)
}
private var minimalisticOverridePayload: ExtensionNotchExperiencePayload? {
extensionNotchExperienceManager.minimalisticReplacementPayload()
}
}
struct MusicSliderView: View {

View file

@ -44,7 +44,7 @@ struct ExtensionsSettingsView: View {
selectedEntry = nil
}
} message: { entry in
Text("Remove \(entry.appName) from the authorized extensions list? This will dismiss all active live activities and lock screen widgets from this app.")
Text("Remove \(entry.appName) from the authorized extensions list? This will dismiss all active live activities, lock screen widgets, and notch experiences from this app.")
}
}
@ -59,6 +59,21 @@ struct ExtensionsSettingsView: View {
Defaults.Toggle("Allow extension lock screen widgets", key: .enableExtensionLockScreenWidgets)
.settingsHighlight(id: highlightID("Allow extension lock screen widgets"))
Defaults.Toggle("Allow extension notch experiences", key: .enableExtensionNotchExperiences)
.settingsHighlight(id: highlightID("Allow extension notch experiences"))
if Defaults[.enableExtensionNotchExperiences] {
VStack(alignment: .leading, spacing: 8) {
Defaults.Toggle("Show extension tabs", key: .enableExtensionNotchTabs)
.tint(.accentColor)
Defaults.Toggle("Allow minimalistic overrides", key: .enableExtensionNotchMinimalisticOverrides)
.tint(.accentColor)
Defaults.Toggle("Allow interactive web content", key: .enableExtensionNotchInteractiveWebViews)
.tint(.accentColor)
}
.padding(.leading, 4)
}
Defaults.Toggle("Enable extension diagnostics logging", key: .extensionDiagnosticsLoggingEnabled)
.settingsHighlight(id: highlightID("Enable extension diagnostics logging"))
@ -67,7 +82,7 @@ struct ExtensionsSettingsView: View {
Text("Global Settings")
} footer: {
if Defaults[.enableThirdPartyExtensions] {
Text("Third-party apps using AtollExtensionKit can display live activities and lock screen widgets. You can manage individual app permissions below.")
Text("Third-party apps using AtollExtensionKit can display live activities, lock screen widgets, and dedicated notch experiences. Toggle features above or manage individual app permissions below.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
@ -148,6 +163,7 @@ private struct ExtensionEntryRow: View {
@ObservedObject private var authManager = ExtensionAuthorizationManager.shared
@ObservedObject private var liveActivityManager = ExtensionLiveActivityManager.shared
@ObservedObject private var widgetManager = ExtensionLockScreenWidgetManager.shared
@ObservedObject private var notchExperienceManager = ExtensionNotchExperienceManager.shared
let entry: ExtensionAuthorizationEntry
let onRemove: () -> Void
@ -275,8 +291,8 @@ private struct ExtensionEntryRow: View {
}
// Rate limits info
if let rateLimitRecord = authManager.rateLimitRecords.first(where: { $0.bundleIdentifier == entry.bundleIdentifier }),
!rateLimitRecord.activityTimestamps.isEmpty || !rateLimitRecord.widgetTimestamps.isEmpty {
if let rateLimitRecord = authManager.rateLimitRecords.first(where: { $0.bundleIdentifier == entry.bundleIdentifier }),
!rateLimitRecord.activityTimestamps.isEmpty || !rateLimitRecord.widgetTimestamps.isEmpty || !rateLimitRecord.notchExperienceTimestamps.isEmpty {
rateLimitInfo(record: rateLimitRecord)
Divider()
}
@ -324,6 +340,21 @@ private struct ExtensionEntryRow: View {
))
.font(.caption)
.disabled(!authManager.areLockScreenWidgetsEnabled)
Toggle("Notch Experiences", isOn: Binding(
get: { entry.allowedScopes.contains(.notchExperiences) },
set: { enabled in
var newScopes = entry.allowedScopes
if enabled {
newScopes.insert(.notchExperiences)
} else {
newScopes.remove(.notchExperiences)
}
authManager.updateAllowedScopes(bundleIdentifier: entry.bundleIdentifier, allowedScopes: newScopes)
}
))
.font(.caption)
.disabled(!authManager.areNotchExperiencesEnabled)
}
}
@ -353,6 +384,16 @@ private struct ExtensionEntryRow: View {
.font(.caption.monospacedDigit())
}
}
if !record.notchExperienceTimestamps.isEmpty {
VStack(alignment: .leading, spacing: 2) {
Text("Notch Experiences")
.font(.caption2)
.foregroundStyle(.secondary)
Text("\(record.notchExperienceTimestamps.count)")
.font(.caption.monospacedDigit())
}
}
}
Button("Reset Rate Limits") {
@ -420,6 +461,11 @@ private struct ExtensionEntryRow: View {
widgetManager.dismissAll(for: entry.bundleIdentifier)
}
.disabled(!hasWidgets)
Button("Reset Notch Experiences") {
notchExperienceManager.dismissAll(for: entry.bundleIdentifier)
}
.disabled(!hasNotchExperiences)
} label: {
Label("Reset", systemImage: "arrow.counterclockwise.circle")
}
@ -434,6 +480,10 @@ private struct ExtensionEntryRow: View {
private var hasWidgets: Bool {
widgetManager.activeWidgets.contains { $0.bundleIdentifier == entry.bundleIdentifier }
}
private var hasNotchExperiences: Bool {
notchExperienceManager.activeExperiences.contains { $0.bundleIdentifier == entry.bundleIdentifier }
}
private func infoRow(label: String, value: String) -> some View {
HStack {

View file

@ -4,23 +4,38 @@
//
// Created by Hugo Persson on 2024-08-25.
// Modified by Hariharan Mudaliar
import AtollExtensionKit
import SwiftUI
import Defaults
struct TabModel: Identifiable {
let id = UUID()
let id: String
let label: String
let icon: String
let view: NotchViews
let experienceID: String?
let accentColor: Color?
init(label: String, icon: String, view: NotchViews, experienceID: String? = nil, accentColor: Color? = nil) {
self.id = experienceID.map { "extension-\($0)" } ?? "system-\(view)-\(label)"
self.label = label
self.icon = icon
self.view = view
self.experienceID = experienceID
self.accentColor = accentColor
}
}
struct TabSelectionView: View {
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
@Default(.enableTimerFeature) var enableTimerFeature
@Default(.enableStatsFeature) var enableStatsFeature
@Default(.enableColorPickerFeature) var enableColorPickerFeature
@Default(.timerDisplayMode) var timerDisplayMode
@Default(.enableThirdPartyExtensions) private var enableThirdPartyExtensions
@Default(.enableExtensionNotchExperiences) private var enableExtensionNotchExperiences
@Default(.enableExtensionNotchTabs) private var enableExtensionNotchTabs
@Namespace var animation
private var tabs: [TabModel] {
@ -46,35 +61,73 @@ struct TabSelectionView: View {
let icon = Defaults[.enableNotes] ? "note.text" : "doc.on.clipboard"
tabsArray.append(TabModel(label: label, icon: icon, view: .notes))
}
if extensionTabsEnabled {
for payload in extensionTabPayloads {
guard let tab = payload.descriptor.tab else { continue }
let accent = payload.descriptor.accentColor.swiftUIColor
let iconName = tab.iconSymbolName ?? "puzzlepiece.extension"
tabsArray.append(
TabModel(
label: tab.title,
icon: iconName,
view: .extensionExperience,
experienceID: payload.descriptor.id,
accentColor: accent
)
)
}
}
return tabsArray
}
var body: some View {
HStack(spacing: 0) {
ForEach(tabs) { tab in
TabButton(label: tab.label, icon: tab.icon, selected: coordinator.currentView == tab.view) {
withAnimation(.smooth) {
coordinator.currentView = tab.view
let isSelected = isSelected(tab)
let activeAccent = tab.accentColor ?? .white
TabButton(label: tab.label, icon: tab.icon, selected: isSelected) {
withAnimation(.smooth) {
if tab.view == .extensionExperience {
coordinator.selectedExtensionExperienceID = tab.experienceID
}
coordinator.currentView = tab.view
}
.frame(height: 26)
.foregroundStyle(tab.view == coordinator.currentView ? .white : .gray)
.background {
if tab.view == coordinator.currentView {
Capsule()
.fill(coordinator.currentView == tab.view ? Color(nsColor: .secondarySystemFill) : Color.clear)
.matchedGeometryEffect(id: "capsule", in: animation)
} else {
Capsule()
.fill(coordinator.currentView == tab.view ? Color(nsColor: .secondarySystemFill) : Color.clear)
.matchedGeometryEffect(id: "capsule", in: animation)
.hidden()
}
}
.frame(height: 26)
.foregroundStyle(isSelected ? activeAccent : .gray)
.background {
if isSelected {
Capsule()
.fill((tab.accentColor ?? Color(nsColor: .secondarySystemFill)).opacity(0.25))
.shadow(color: (tab.accentColor ?? .clear).opacity(0.4), radius: 8)
.matchedGeometryEffect(id: "capsule", in: animation)
} else {
Capsule()
.fill(Color.clear)
.matchedGeometryEffect(id: "capsule", in: animation)
.hidden()
}
}
}
}
.clipShape(Capsule())
}
private var extensionTabsEnabled: Bool {
enableThirdPartyExtensions && enableExtensionNotchExperiences && enableExtensionNotchTabs
}
private var extensionTabPayloads: [ExtensionNotchExperiencePayload] {
extensionNotchExperienceManager.activeExperiences.filter { $0.descriptor.tab != nil }
}
private func isSelected(_ tab: TabModel) -> Bool {
if tab.view == .extensionExperience {
return coordinator.currentView == .extensionExperience
&& coordinator.selectedExtensionExperienceID == tab.experienceID
}
return coordinator.currentView == tab.view
}
}
#Preview {