/* * Atoll (DynamicIsland) * Copyright (C) 2024-2026 Atoll Contributors * * Originally from boring.notch project * Modified and adapted for Atoll (DynamicIsland) * See NOTICE for details. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import AtollExtensionKit import SwiftUI import Defaults struct TabModel: Identifiable { 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 @Default(.showCalendar) private var showCalendar @Default(.showMirror) private var showMirror @Default(.showStandardMediaControls) private var showStandardMediaControls @Default(.enableMinimalisticUI) private var enableMinimalisticUI @Namespace var animation private var tabs: [TabModel] { var tabsArray: [TabModel] = [] if homeTabVisible { tabsArray.append(TabModel(label: "Home", icon: "house.fill", view: .home)) } if Defaults[.dynamicShelf] { tabsArray.append(TabModel(label: "Shelf", icon: "tray.fill", view: .shelf)) } if enableTimerFeature && timerDisplayMode == .tab { tabsArray.append(TabModel(label: "Timer", icon: "timer", view: .timer)) } // Stats tab only shown when stats feature is enabled if Defaults[.enableStatsFeature] { tabsArray.append(TabModel(label: "Stats", icon: "chart.xyaxis.line", view: .stats)) } if Defaults[.enableNotes] || (Defaults[.enableClipboardManager] && Defaults[.clipboardDisplayMode] == .separateTab) { let label = Defaults[.enableNotes] ? "Notes" : "Clipboard" let icon = Defaults[.enableNotes] ? "note.text" : "doc.on.clipboard" tabsArray.append(TabModel(label: label, icon: icon, view: .notes)) } if Defaults[.enableTerminalFeature] { tabsArray.append(TabModel(label: "Terminal", icon: "apple.terminal", view: .terminal)) } 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 let isSelected = isSelected(tab) let activeAccent = tab.accentColor ?? .white TabButton(label: tab.label, icon: tab.icon, selected: isSelected) { if tab.view == .extensionExperience { coordinator.selectedExtensionExperienceID = tab.experienceID } coordinator.currentView = tab.view } .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() } } } } .animation(.smooth(duration: 0.3), value: coordinator.currentView) .clipShape(Capsule()) .onAppear { ensureValidSelection(with: tabs) } } private var extensionTabsEnabled: Bool { enableThirdPartyExtensions && enableExtensionNotchExperiences && enableExtensionNotchTabs } private var extensionTabPayloads: [ExtensionNotchExperiencePayload] { extensionNotchExperienceManager.activeExperiences.filter { $0.descriptor.tab != nil } } private var homeTabVisible: Bool { if enableMinimalisticUI { return true } return showStandardMediaControls || showCalendar || showMirror } private func isSelected(_ tab: TabModel) -> Bool { if tab.view == .extensionExperience { return coordinator.currentView == .extensionExperience && coordinator.selectedExtensionExperienceID == tab.experienceID } return coordinator.currentView == tab.view } private func ensureValidSelection(with tabs: [TabModel]) { guard !tabs.isEmpty else { return } if tabs.contains(where: { isSelected($0) }) { return } guard let first = tabs.first else { return } if first.view == .extensionExperience { coordinator.selectedExtensionExperienceID = first.experienceID } else { coordinator.selectedExtensionExperienceID = nil } coordinator.currentView = first.view } } #Preview { DynamicIslandHeader().environmentObject(DynamicIslandViewModel()) }