mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
558 lines
20 KiB
Swift
558 lines
20 KiB
Swift
//
|
|
// NotchStatsView.swift
|
|
// DynamicIsland
|
|
//
|
|
// Stats tab view for system performance monitoring
|
|
// Created by Hariharan Mudaliar
|
|
|
|
import SwiftUI
|
|
import Defaults
|
|
|
|
// Graph data protocol for unified interface
|
|
protocol GraphData {
|
|
var title: String { get }
|
|
var color: Color { get }
|
|
var icon: String { get }
|
|
var type: GraphType { get }
|
|
}
|
|
|
|
enum GraphType {
|
|
case single
|
|
case dual
|
|
}
|
|
|
|
// Single value graph data
|
|
struct SingleGraphData: GraphData {
|
|
let title: String
|
|
let value: String
|
|
let data: [Double]
|
|
let color: Color
|
|
let icon: String
|
|
let type: GraphType = .single
|
|
}
|
|
|
|
// Dual value graph data (for network/disk)
|
|
struct DualGraphData: GraphData {
|
|
let title: String
|
|
let positiveValue: String
|
|
let negativeValue: String
|
|
let positiveData: [Double]
|
|
let negativeData: [Double]
|
|
let positiveColor: Color
|
|
let negativeColor: Color
|
|
let color: Color // Primary color for the component
|
|
let icon: String
|
|
let type: GraphType = .dual
|
|
}
|
|
|
|
struct NotchStatsView: View {
|
|
@ObservedObject var statsManager = StatsManager.shared
|
|
@Default(.enableStatsFeature) var enableStatsFeature
|
|
@Default(.showCpuGraph) var showCpuGraph
|
|
@Default(.showMemoryGraph) var showMemoryGraph
|
|
@Default(.showGpuGraph) var showGpuGraph
|
|
@Default(.showNetworkGraph) var showNetworkGraph
|
|
@Default(.showDiskGraph) var showDiskGraph
|
|
|
|
var availableGraphs: [GraphData] {
|
|
var graphs: [GraphData] = []
|
|
|
|
if showCpuGraph {
|
|
graphs.append(SingleGraphData(
|
|
title: "CPU",
|
|
value: statsManager.cpuUsageString,
|
|
data: statsManager.cpuHistory,
|
|
color: .blue,
|
|
icon: "cpu"
|
|
))
|
|
}
|
|
|
|
if showMemoryGraph {
|
|
graphs.append(SingleGraphData(
|
|
title: "Memory",
|
|
value: statsManager.memoryUsageString,
|
|
data: statsManager.memoryHistory,
|
|
color: .green,
|
|
icon: "memorychip"
|
|
))
|
|
}
|
|
|
|
if showGpuGraph {
|
|
graphs.append(SingleGraphData(
|
|
title: "GPU",
|
|
value: statsManager.gpuUsageString,
|
|
data: statsManager.gpuHistory,
|
|
color: .purple,
|
|
icon: "display"
|
|
))
|
|
}
|
|
|
|
if showNetworkGraph {
|
|
graphs.append(DualGraphData(
|
|
title: "Network",
|
|
positiveValue: String(format: "↓%.1f MB/s", statsManager.networkDownload),
|
|
negativeValue: String(format: "↑%.1f MB/s", statsManager.networkUpload),
|
|
positiveData: statsManager.networkDownloadHistory,
|
|
negativeData: statsManager.networkUploadHistory,
|
|
positiveColor: .orange,
|
|
negativeColor: .red,
|
|
color: .orange,
|
|
icon: "network"
|
|
))
|
|
}
|
|
|
|
if showDiskGraph {
|
|
graphs.append(DualGraphData(
|
|
title: "Disk",
|
|
positiveValue: String(format: "R %.1f MB/s", statsManager.diskRead),
|
|
negativeValue: String(format: "W %.1f MB/s", statsManager.diskWrite),
|
|
positiveData: statsManager.diskReadHistory,
|
|
negativeData: statsManager.diskWriteHistory,
|
|
positiveColor: .cyan,
|
|
negativeColor: .yellow,
|
|
color: .cyan,
|
|
icon: "internaldrive"
|
|
))
|
|
}
|
|
|
|
return graphs
|
|
}
|
|
|
|
var dynamicNotchSize: CGSize {
|
|
let graphCount = availableGraphs.count
|
|
|
|
// Base size for 1-3 graphs
|
|
let baseWidth: CGFloat = 610
|
|
|
|
// If 4+ graphs, increase width to accommodate
|
|
if graphCount >= 4 {
|
|
let extraWidth: CGFloat = CGFloat(graphCount - 3) * 120
|
|
return CGSize(width: baseWidth + extraWidth, height: 200)
|
|
}
|
|
|
|
return CGSize(width: baseWidth, height: 200)
|
|
}
|
|
|
|
var gridColumns: [GridItem] {
|
|
let graphCount = availableGraphs.count
|
|
|
|
if graphCount <= 3 {
|
|
// Horizontal layout for 1-3 graphs
|
|
return Array(repeating: GridItem(.flexible(), spacing: 12), count: graphCount)
|
|
} else {
|
|
// Compressed grid layout for 4+ graphs
|
|
return Array(repeating: GridItem(.flexible(), spacing: 8), count: graphCount)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
if !enableStatsFeature {
|
|
// Disabled state
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "chart.line.uptrend.xyaxis")
|
|
.font(.system(size: 40))
|
|
.foregroundColor(.secondary)
|
|
|
|
Text("Stats Disabled")
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
|
|
Text("Enable stats monitoring in Settings to view system performance data.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.padding()
|
|
} else if availableGraphs.isEmpty {
|
|
// No graphs enabled state
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "chart.xyaxis.line")
|
|
.font(.system(size: 40))
|
|
.foregroundColor(.secondary)
|
|
|
|
Text("No Graphs Enabled")
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
|
|
Text("Enable graph visibility in Settings → Stats to view performance data.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.padding()
|
|
} else {
|
|
// Stats content with dynamic layout
|
|
ZStack(alignment: .topTrailing) {
|
|
VStack(spacing: 12) {
|
|
// Dynamic Stats Grid
|
|
LazyVGrid(columns: gridColumns, spacing: 12) {
|
|
ForEach(0..<availableGraphs.count, id: \.self) { index in
|
|
let graphData = availableGraphs[index]
|
|
|
|
if let singleData = graphData as? SingleGraphData {
|
|
StatCard(
|
|
title: singleData.title,
|
|
value: singleData.value,
|
|
data: singleData.data,
|
|
color: singleData.color,
|
|
icon: singleData.icon
|
|
)
|
|
} else if let dualData = graphData as? DualGraphData {
|
|
DualStatCard(
|
|
title: dualData.title,
|
|
positiveValue: dualData.positiveValue,
|
|
negativeValue: dualData.negativeValue,
|
|
positiveData: dualData.positiveData,
|
|
negativeData: dualData.negativeData,
|
|
positiveColor: dualData.positiveColor,
|
|
negativeColor: dualData.negativeColor,
|
|
icon: dualData.icon
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
.padding(16)
|
|
|
|
// Live indicator and controls in top-right corner
|
|
HStack(spacing: 8) {
|
|
// Control buttons
|
|
HStack(spacing: 4) {
|
|
if statsManager.isMonitoring {
|
|
Button("Stop") {
|
|
statsManager.stopMonitoring()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.foregroundColor(.red)
|
|
.controlSize(.mini)
|
|
} else {
|
|
Button("Start") {
|
|
statsManager.startMonitoring()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.mini)
|
|
}
|
|
|
|
Button("Clear") {
|
|
statsManager.clearHistory()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(statsManager.isMonitoring)
|
|
.controlSize(.mini)
|
|
}
|
|
.font(.caption2)
|
|
|
|
// Live indicator
|
|
HStack(spacing: 4) {
|
|
Circle()
|
|
.fill(statsManager.isMonitoring ? .green : .red)
|
|
.frame(width: 6, height: 6)
|
|
|
|
Text(statsManager.isMonitoring ? "Live" : "Off")
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.padding(.top, 8)
|
|
.padding(.trailing, 12)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: dynamicNotchSize.width, maxHeight: dynamicNotchSize.height)
|
|
.background(Color(NSColor.windowBackgroundColor).opacity(0.1))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.onAppear {
|
|
if enableStatsFeature && !statsManager.isMonitoring {
|
|
statsManager.startMonitoring()
|
|
}
|
|
}
|
|
.onDisappear {
|
|
// Keep monitoring running when tab is not visible
|
|
}
|
|
}
|
|
}
|
|
|
|
struct StatCard: View {
|
|
let title: String
|
|
let value: String
|
|
let data: [Double]
|
|
let color: Color
|
|
let icon: String
|
|
|
|
var body: some View {
|
|
VStack(spacing: 6) {
|
|
// Header - more compact
|
|
HStack(spacing: 4) {
|
|
Image(systemName: icon)
|
|
.foregroundColor(color)
|
|
.font(.caption2)
|
|
|
|
Text(title)
|
|
.font(.caption2)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(.secondary)
|
|
|
|
Spacer()
|
|
}
|
|
|
|
// Current value - larger and more prominent
|
|
Text(value)
|
|
.font(.title3)
|
|
.fontWeight(.bold)
|
|
.foregroundColor(.primary)
|
|
.frame(height: 22) // Match the height of dual values in DualStatCard
|
|
|
|
// Mini graph - use more height
|
|
MiniGraph(data: data, color: color)
|
|
.frame(height: 50)
|
|
}
|
|
.padding(10)
|
|
.background(Color(NSColor.controlBackgroundColor).opacity(0.5))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
struct DualStatCard: View {
|
|
let title: String
|
|
let positiveValue: String
|
|
let negativeValue: String
|
|
let positiveData: [Double]
|
|
let negativeData: [Double]
|
|
let positiveColor: Color
|
|
let negativeColor: Color
|
|
let icon: String
|
|
|
|
var body: some View {
|
|
VStack(spacing: 6) {
|
|
// Header
|
|
HStack(spacing: 4) {
|
|
Image(systemName: icon)
|
|
.foregroundColor(positiveColor)
|
|
.font(.caption2)
|
|
|
|
Text(title)
|
|
.font(.caption2)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(.secondary)
|
|
|
|
Spacer()
|
|
}
|
|
|
|
// Dual values - horizontal layout to match single value height
|
|
HStack(spacing: 8) {
|
|
Text(positiveValue)
|
|
.font(.caption)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(positiveColor)
|
|
|
|
Text("•")
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
|
|
Text(negativeValue)
|
|
.font(.caption)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(negativeColor)
|
|
}
|
|
.frame(height: 22) // Match the height of .title3 text in StatCard
|
|
|
|
// Dual quadrant graph
|
|
DualQuadrantGraph(
|
|
positiveData: positiveData,
|
|
negativeData: negativeData,
|
|
positiveColor: positiveColor,
|
|
negativeColor: negativeColor
|
|
)
|
|
.frame(height: 50)
|
|
}
|
|
.padding(10)
|
|
.background(Color(NSColor.controlBackgroundColor).opacity(0.5))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
struct MiniGraph: View {
|
|
let data: [Double]
|
|
let color: Color
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let maxValue = data.max() ?? 1.0
|
|
let normalizedData = maxValue > 0 ? data.map { $0 / maxValue } : data
|
|
|
|
Path { path in
|
|
guard !normalizedData.isEmpty else { return }
|
|
|
|
let stepX = geometry.size.width / CGFloat(normalizedData.count - 1)
|
|
|
|
for (index, value) in normalizedData.enumerated() {
|
|
let x = CGFloat(index) * stepX
|
|
let y = geometry.size.height * (1 - CGFloat(value))
|
|
|
|
if index == 0 {
|
|
path.move(to: CGPoint(x: x, y: y))
|
|
} else {
|
|
path.addLine(to: CGPoint(x: x, y: y))
|
|
}
|
|
}
|
|
}
|
|
.stroke(color, lineWidth: 2)
|
|
|
|
// Gradient fill
|
|
Path { path in
|
|
guard !normalizedData.isEmpty else { return }
|
|
|
|
let stepX = geometry.size.width / CGFloat(normalizedData.count - 1)
|
|
|
|
path.move(to: CGPoint(x: 0, y: geometry.size.height))
|
|
|
|
for (index, value) in normalizedData.enumerated() {
|
|
let x = CGFloat(index) * stepX
|
|
let y = geometry.size.height * (1 - CGFloat(value))
|
|
path.addLine(to: CGPoint(x: x, y: y))
|
|
}
|
|
|
|
path.addLine(to: CGPoint(x: geometry.size.width, y: geometry.size.height))
|
|
path.closeSubpath()
|
|
}
|
|
.fill(
|
|
LinearGradient(
|
|
gradient: Gradient(colors: [color.opacity(0.3), color.opacity(0.1)]),
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DualQuadrantGraph: View {
|
|
let positiveData: [Double]
|
|
let negativeData: [Double]
|
|
let positiveColor: Color
|
|
let negativeColor: Color
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let maxPositive = positiveData.max() ?? 1.0
|
|
let maxNegative = negativeData.max() ?? 1.0
|
|
let maxValue = max(maxPositive, maxNegative)
|
|
|
|
let normalizedPositive = maxValue > 0 ? positiveData.map { $0 / maxValue } : positiveData
|
|
let normalizedNegative = maxValue > 0 ? negativeData.map { $0 / maxValue } : negativeData
|
|
|
|
let centerY = geometry.size.height / 2
|
|
|
|
ZStack {
|
|
// Center dividing line
|
|
Path { path in
|
|
path.move(to: CGPoint(x: 0, y: centerY))
|
|
path.addLine(to: CGPoint(x: geometry.size.width, y: centerY))
|
|
}
|
|
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
|
|
|
|
// Positive quadrant (upper half)
|
|
Path { path in
|
|
guard !normalizedPositive.isEmpty else { return }
|
|
|
|
let stepX = geometry.size.width / CGFloat(normalizedPositive.count - 1)
|
|
|
|
for (index, value) in normalizedPositive.enumerated() {
|
|
let x = CGFloat(index) * stepX
|
|
let y = centerY - (centerY * CGFloat(value)) // Above center
|
|
|
|
if index == 0 {
|
|
path.move(to: CGPoint(x: x, y: y))
|
|
} else {
|
|
path.addLine(to: CGPoint(x: x, y: y))
|
|
}
|
|
}
|
|
}
|
|
.stroke(positiveColor, lineWidth: 2)
|
|
|
|
// Positive fill
|
|
Path { path in
|
|
guard !normalizedPositive.isEmpty else { return }
|
|
|
|
let stepX = geometry.size.width / CGFloat(normalizedPositive.count - 1)
|
|
|
|
path.move(to: CGPoint(x: 0, y: centerY))
|
|
|
|
for (index, value) in normalizedPositive.enumerated() {
|
|
let x = CGFloat(index) * stepX
|
|
let y = centerY - (centerY * CGFloat(value))
|
|
path.addLine(to: CGPoint(x: x, y: y))
|
|
}
|
|
|
|
path.addLine(to: CGPoint(x: geometry.size.width, y: centerY))
|
|
path.closeSubpath()
|
|
}
|
|
.fill(
|
|
LinearGradient(
|
|
gradient: Gradient(colors: [positiveColor.opacity(0.3), positiveColor.opacity(0.1)]),
|
|
startPoint: .top,
|
|
endPoint: .center
|
|
)
|
|
)
|
|
|
|
// Negative quadrant (lower half)
|
|
Path { path in
|
|
guard !normalizedNegative.isEmpty else { return }
|
|
|
|
let stepX = geometry.size.width / CGFloat(normalizedNegative.count - 1)
|
|
|
|
for (index, value) in normalizedNegative.enumerated() {
|
|
let x = CGFloat(index) * stepX
|
|
let y = centerY + (centerY * CGFloat(value)) // Below center
|
|
|
|
if index == 0 {
|
|
path.move(to: CGPoint(x: x, y: y))
|
|
} else {
|
|
path.addLine(to: CGPoint(x: x, y: y))
|
|
}
|
|
}
|
|
}
|
|
.stroke(negativeColor, lineWidth: 2)
|
|
|
|
// Negative fill
|
|
Path { path in
|
|
guard !normalizedNegative.isEmpty else { return }
|
|
|
|
let stepX = geometry.size.width / CGFloat(normalizedNegative.count - 1)
|
|
|
|
path.move(to: CGPoint(x: 0, y: centerY))
|
|
|
|
for (index, value) in normalizedNegative.enumerated() {
|
|
let x = CGFloat(index) * stepX
|
|
let y = centerY + (centerY * CGFloat(value))
|
|
path.addLine(to: CGPoint(x: x, y: y))
|
|
}
|
|
|
|
path.addLine(to: CGPoint(x: geometry.size.width, y: centerY))
|
|
path.closeSubpath()
|
|
}
|
|
.fill(
|
|
LinearGradient(
|
|
gradient: Gradient(colors: [negativeColor.opacity(0.3), negativeColor.opacity(0.1)]),
|
|
startPoint: .bottom,
|
|
endPoint: .center
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NotchStatsView()
|
|
.frame(width: 400, height: 300)
|
|
.background(Color.black)
|
|
}
|