1346 lines
51 KiB
Swift
1346 lines
51 KiB
Swift
import AppKit
|
|
import Charts
|
|
import SwiftUI
|
|
import MacToolsPluginKit
|
|
|
|
private enum ActivityBarComponentLayout {
|
|
static let cardCornerRadius = PluginComponentPanelLayoutMetrics.cardCornerRadius
|
|
}
|
|
|
|
private enum ActivityBarTrendMode: String, CaseIterable {
|
|
case codingTools = "Coding Tools"
|
|
case input = "Input"
|
|
|
|
func label(localization: PluginLocalization) -> String {
|
|
switch self {
|
|
case .codingTools:
|
|
localization.string("component.trendMode.codingTools", defaultValue: "Coding Tools")
|
|
case .input:
|
|
localization.string("component.trendMode.input", defaultValue: "Input")
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum ActivityBarChartRange: String, CaseIterable {
|
|
case oneDay = "1d"
|
|
case sevenDays = "7d"
|
|
case fourteenDays = "14d"
|
|
case thirtyDays = "30d"
|
|
|
|
var dayCount: Int {
|
|
switch self {
|
|
case .oneDay:
|
|
return 1
|
|
case .sevenDays:
|
|
return 7
|
|
case .fourteenDays:
|
|
return 14
|
|
case .thirtyDays:
|
|
return 30
|
|
}
|
|
}
|
|
|
|
var label: String { rawValue }
|
|
}
|
|
|
|
private enum ActivityBarFunFact {
|
|
static let secondsPerClick = 0.50
|
|
static let keysPerPage = 550
|
|
|
|
static func forDay(_ stats: ActivityBarDailyStats, localization: PluginLocalization) -> String? {
|
|
var facts: [String] = []
|
|
|
|
if stats.keystrokes > 0 {
|
|
let pages = Double(stats.keystrokes) / Double(keysPerPage)
|
|
if pages >= 1 {
|
|
let formatted = ActivityBarFormatting.decimal(stats.keystrokes)
|
|
let fullPages = Int(pages.rounded())
|
|
facts.append(
|
|
localization.format(
|
|
"component.funFact.typedPages",
|
|
defaultValue: "You typed %@ keys today. That's about writing %d full pages.",
|
|
formatted,
|
|
fullPages
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
if stats.pointerClicks > 0 {
|
|
let clickMins = (Double(stats.pointerClicks) * secondsPerClick) / 60
|
|
if clickMins >= 1 {
|
|
let formatted = ActivityBarFormatting.decimal(stats.pointerClicks)
|
|
let minutes = Int(clickMins.rounded())
|
|
facts.append(
|
|
localization.format(
|
|
"component.funFact.clickMinutes",
|
|
defaultValue: "You clicked %@ times. That's like tapping your desk for about %d minutes.",
|
|
formatted,
|
|
minutes
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
guard !facts.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
let hash = stats.date.utf8.reduce(5381) { (($0 << 5) &+ $0) &+ Int($1) }
|
|
return facts[abs(hash) % facts.count]
|
|
}
|
|
}
|
|
|
|
struct ActivityBarComponentView: View {
|
|
private static let claudeColor = Color(red: 0xCB / 255.0, green: 0x64 / 255.0, blue: 0x41 / 255.0)
|
|
private static let cursorColor = Color.primary
|
|
private static let codexColor = Color(red: 0x10 / 255.0, green: 0xA3 / 255.0, blue: 0x7F / 255.0)
|
|
static let visibleCodingTools: [ActivityBarCodingTool] = [.claudeCode, .cursor, .codex]
|
|
|
|
private static func codingToolColor(_ tool: ActivityBarCodingTool) -> Color {
|
|
switch tool {
|
|
case .claudeCode:
|
|
return claudeColor
|
|
case .cursor:
|
|
return cursorColor
|
|
case .codex:
|
|
return codexColor
|
|
}
|
|
}
|
|
|
|
private static func codingToolLegendLabel(_ tool: ActivityBarCodingTool) -> String {
|
|
switch tool {
|
|
case .claudeCode:
|
|
return "Claude"
|
|
case .cursor:
|
|
return "Cursor"
|
|
case .codex:
|
|
return "Codex"
|
|
}
|
|
}
|
|
|
|
@ObservedObject var controller: ActivityBarController
|
|
let localization: PluginLocalization
|
|
let dismiss: () -> Void
|
|
|
|
@State private var hoveredDate: String?
|
|
@State private var hoveredScreenTimeDate: String?
|
|
@State private var expandedAppName: String?
|
|
@State private var selectedDateOffset = 0
|
|
@State private var trendMode: ActivityBarTrendMode = .input
|
|
@AppStorage("activity-bar.stats-expanded") private var statsExpanded = true
|
|
@AppStorage("activity-bar.chart-range") private var chartRange = ActivityBarChartRange.sevenDays
|
|
|
|
init(
|
|
controller: ActivityBarController,
|
|
localization: PluginLocalization = PluginLocalization(bundle: .main),
|
|
dismiss: @escaping () -> Void = {}
|
|
) {
|
|
self.controller = controller
|
|
self.localization = localization
|
|
self.dismiss = dismiss
|
|
}
|
|
|
|
private var selectedDate: Date {
|
|
Calendar.current.date(byAdding: .day, value: selectedDateOffset, to: Date()) ?? Date()
|
|
}
|
|
|
|
private var selectedDateKey: String {
|
|
dateKey(for: selectedDate)
|
|
}
|
|
|
|
private var selectedInputStats: ActivityBarDailyStats {
|
|
controller.inputStats.stats(for: selectedDateKey)
|
|
}
|
|
|
|
private var selectedCodingStats: ActivityBarCodingDailyStats {
|
|
controller.codingStats.stats(for: selectedDateKey)
|
|
}
|
|
|
|
private var isViewingToday: Bool {
|
|
selectedDateOffset == 0
|
|
}
|
|
|
|
private var canGoBack: Bool {
|
|
let keys = Set(controller.inputStats.sortedDateKeys + controller.codingStats.sortedDateKeys)
|
|
guard let earliest = keys.sorted().first else {
|
|
return false
|
|
}
|
|
|
|
return selectedDateKey > earliest
|
|
}
|
|
|
|
private var hasCodingToolsData: Bool {
|
|
controller.codingStats.days.values.contains { day in
|
|
hasCodingStats(visibleCodingAggregateStats(in: day))
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
headerBar
|
|
todayStats
|
|
|
|
if showsAISection {
|
|
divider
|
|
aiSection
|
|
}
|
|
|
|
divider
|
|
topAppsSection
|
|
|
|
divider
|
|
statsDisclosure
|
|
if statsExpanded {
|
|
if trendMode == .codingTools {
|
|
codingToolsChart
|
|
} else {
|
|
weeklyChart
|
|
divider
|
|
screenTimeSection
|
|
}
|
|
}
|
|
|
|
divider
|
|
footerBar
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
.background(ActivityBarComponentBackground(cornerRadius: ActivityBarComponentLayout.cardCornerRadius))
|
|
.clipShape(
|
|
RoundedRectangle(
|
|
cornerRadius: ActivityBarComponentLayout.cardCornerRadius,
|
|
style: .continuous
|
|
)
|
|
)
|
|
.animation(.easeInOut(duration: 0.2), value: statsExpanded)
|
|
.animation(.easeInOut(duration: 0.2), value: chartRange)
|
|
.animation(.easeInOut(duration: 0.2), value: trendMode)
|
|
.animation(.easeInOut(duration: 0.15), value: selectedDateOffset)
|
|
.onAppear {
|
|
controller.refresh()
|
|
trendMode = hasCodingToolsData ? .codingTools : .input
|
|
}
|
|
}
|
|
|
|
private var headerBar: some View {
|
|
HStack {
|
|
Text(localization.string("component.title", defaultValue: "Activity Bar"))
|
|
.font(.title3.bold())
|
|
.lineLimit(1)
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
HStack(spacing: 4) {
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
|
selectedDateOffset -= 1
|
|
expandedAppName = nil
|
|
}
|
|
} label: {
|
|
Image(systemName: "chevron.left")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(.primary.opacity(canGoBack ? 0.55 : 0.2))
|
|
.frame(width: 22, height: 22)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(!canGoBack)
|
|
|
|
Text(displayDateString)
|
|
.font(.body)
|
|
.foregroundStyle(.primary.opacity(0.55))
|
|
.lineLimit(1)
|
|
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
|
selectedDateOffset += 1
|
|
expandedAppName = nil
|
|
}
|
|
} label: {
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(.primary.opacity(isViewingToday ? 0.2 : 0.55))
|
|
.frame(width: 22, height: 22)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(isViewingToday)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 14)
|
|
.padding(.bottom, 10)
|
|
}
|
|
|
|
private var todayStats: some View {
|
|
let day = selectedInputStats
|
|
|
|
return VStack(spacing: 8) {
|
|
HStack(spacing: 10) {
|
|
statCell(
|
|
icon: "keyboard",
|
|
value: ActivityBarFormatting.decimal(day.keystrokes),
|
|
label: localization.string("component.metric.keystrokes", defaultValue: "Keystrokes")
|
|
)
|
|
statCell(
|
|
icon: "cursorarrow.click.2",
|
|
value: ActivityBarFormatting.decimal(day.pointerClicks),
|
|
label: localization.string("component.metric.clicks", defaultValue: "Clicks")
|
|
)
|
|
}
|
|
|
|
HStack(spacing: 10) {
|
|
statCell(
|
|
icon: "scroll",
|
|
value: ActivityBarFormatting.decimal(day.scrollEvents),
|
|
label: localization.string("component.metric.scrolls", defaultValue: "Scrolls")
|
|
)
|
|
statCell(
|
|
icon: "macwindow.on.rectangle",
|
|
value: ActivityBarFormatting.duration(day.screenTimeSeconds),
|
|
label: localization.string("component.metric.screenTime", defaultValue: "Screen Time")
|
|
)
|
|
}
|
|
|
|
if let fact = inputInsightText {
|
|
Text(fact)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(.primary.opacity(0.7))
|
|
.multilineTextAlignment(.center)
|
|
.lineLimit(2)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(
|
|
.blue.opacity(0.08),
|
|
in: RoundedRectangle(
|
|
cornerRadius: ActivityBarComponentLayout.cardCornerRadius,
|
|
style: .continuous
|
|
)
|
|
)
|
|
.padding(.horizontal, 4)
|
|
.padding(.top, 4)
|
|
}
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
private func statCell(icon: String, value: String, label: String, tint: Color = .blue) -> some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 26, height: 26)
|
|
.background(tint, in: Circle())
|
|
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(value)
|
|
.font(.system(size: 14, weight: .semibold).monospacedDigit())
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.7)
|
|
|
|
Text(label)
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.primary.opacity(0.5))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 8)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
.primary.opacity(0.04),
|
|
in: RoundedRectangle(
|
|
cornerRadius: ActivityBarComponentLayout.cardCornerRadius,
|
|
style: .continuous
|
|
)
|
|
)
|
|
}
|
|
|
|
private var inputInsightText: String? {
|
|
if let fact = ActivityBarFunFact.forDay(selectedInputStats, localization: localization) {
|
|
return fact
|
|
}
|
|
|
|
if isViewingToday, !controller.isTrackingEnabled {
|
|
return localization.string(
|
|
"component.inputInsight.enableTracking",
|
|
defaultValue: "Grant Input Monitoring and turn this on to start collecting local stats."
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private var showsAISection: Bool {
|
|
!codingToolRows(for: selectedCodingStats).isEmpty
|
|
|| (isViewingToday && controller.codingStats.activeSessionCount > 0)
|
|
}
|
|
|
|
private var aiSection: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(localization.string("component.ai.title", defaultValue: "Time AI Worked for You"))
|
|
.font(.headline)
|
|
.padding(.horizontal, 22)
|
|
.padding(.bottom, 2)
|
|
|
|
let rows = codingToolRows(for: selectedCodingStats)
|
|
if rows.isEmpty {
|
|
codingToolRow(
|
|
row: CodingToolDisplayRow(
|
|
id: "active-ai",
|
|
name: "Claude / Codex",
|
|
duration: 0,
|
|
detail: localization.format(
|
|
"component.ai.activeSessions",
|
|
defaultValue: "%d active",
|
|
controller.codingStats.activeSessionCount
|
|
),
|
|
tint: Self.codexColor,
|
|
systemImage: "terminal",
|
|
iconSize: 13
|
|
)
|
|
)
|
|
} else {
|
|
ForEach(rows) { row in
|
|
codingToolRow(row: row)
|
|
}
|
|
}
|
|
|
|
if isViewingToday {
|
|
aiWeeklyComparison
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
private func codingToolRow(row: CodingToolDisplayRow) -> some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: row.systemImage)
|
|
.font(.system(size: row.iconSize, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 26, height: 26)
|
|
.background(row.tint, in: Circle())
|
|
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(row.name)
|
|
.font(.body)
|
|
.lineLimit(1)
|
|
|
|
if let detail = row.detail {
|
|
Text(detail)
|
|
.font(.caption)
|
|
.foregroundStyle(.primary.opacity(0.55))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.75)
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
Text(ActivityBarFormatting.duration(row.duration))
|
|
.font(.body.weight(.semibold).monospacedDigit())
|
|
.foregroundStyle(.primary.opacity(0.55))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.75)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 9)
|
|
.background(
|
|
.primary.opacity(0.04),
|
|
in: RoundedRectangle(
|
|
cornerRadius: ActivityBarComponentLayout.cardCornerRadius,
|
|
style: .continuous
|
|
)
|
|
)
|
|
.padding(.horizontal, 16)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var aiWeeklyComparison: some View {
|
|
let avg = aiDailyAvgLastWeek
|
|
if avg > 0 {
|
|
let today = visibleCodingAggregateStats(in: controller.codingStats.today).durationSeconds
|
|
let ratio = today / avg
|
|
let aboveAvg = ratio >= 1
|
|
let pct = Int(((ratio - 1) * 100).rounded())
|
|
|
|
HStack(spacing: 6) {
|
|
Image(systemName: aboveAvg ? "arrow.up.right" : "arrow.down.right")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundStyle(aboveAvg ? .green : .primary.opacity(0.45))
|
|
|
|
if pct == 0 {
|
|
Text(
|
|
localization.format(
|
|
"component.ai.dailyAverage.same",
|
|
defaultValue: "On par with your daily avg (%@)",
|
|
shortDuration(avg)
|
|
)
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.primary.opacity(0.55))
|
|
} else {
|
|
let comparison = aboveAvg
|
|
? localization.format(
|
|
"component.ai.dailyAverage.above",
|
|
defaultValue: "%d%% above your daily avg (%@)",
|
|
abs(pct),
|
|
shortDuration(avg)
|
|
)
|
|
: localization.format(
|
|
"component.ai.dailyAverage.below",
|
|
defaultValue: "%d%% below your daily avg (%@)",
|
|
abs(pct),
|
|
shortDuration(avg)
|
|
)
|
|
Text(comparison)
|
|
.font(.caption)
|
|
.foregroundStyle(.primary.opacity(0.55))
|
|
}
|
|
}
|
|
.padding(.horizontal, 22)
|
|
.padding(.top, 4)
|
|
}
|
|
}
|
|
|
|
private var aiDailyAvgLastWeek: TimeInterval {
|
|
let today = dateKey(for: Date())
|
|
let days = controller.codingStats
|
|
.recentDays(count: 8)
|
|
.filter { $0.date != today && visibleCodingAggregateStats(in: $0).durationSeconds > 0 }
|
|
|
|
guard !days.isEmpty else {
|
|
return 0
|
|
}
|
|
|
|
return days
|
|
.map { visibleCodingAggregateStats(in: $0).durationSeconds }
|
|
.reduce(0, +) / Double(days.count)
|
|
}
|
|
|
|
private var topAppsSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(localization.string("component.topApps.title", defaultValue: "Top Apps by Screen Time"))
|
|
.font(.headline)
|
|
.padding(.horizontal, 6)
|
|
.padding(.bottom, 2)
|
|
|
|
let apps = Array(selectedInputStats.topApps.prefix(5))
|
|
if apps.isEmpty {
|
|
Text(
|
|
controller.isTrackingEnabled
|
|
? localization.string("component.topApps.empty", defaultValue: "No activity yet")
|
|
: localization.string("component.topApps.enableTracking", defaultValue: "Turn on tracking to rank your apps")
|
|
)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.primary.opacity(0.55))
|
|
.padding(.vertical, 4)
|
|
.padding(.horizontal, 6)
|
|
} else {
|
|
let maxTime = apps.first?.stats.screenTimeSeconds ?? 1
|
|
ForEach(apps, id: \.name) { app in
|
|
appRow(name: app.name, stats: app.stats, maxScreenTime: maxTime)
|
|
}
|
|
.onAppear {
|
|
if expandedAppName == nil {
|
|
expandedAppName = apps.first?.name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
private func appRow(name: String, stats: ActivityBarAppStats, maxScreenTime: Double) -> some View {
|
|
let isExpanded = expandedAppName == name
|
|
|
|
return VStack(spacing: 3) {
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
|
expandedAppName = isExpanded ? nil : name
|
|
}
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 8, weight: .bold))
|
|
.foregroundStyle(.primary.opacity(0.55))
|
|
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
|
|
|
Text(name)
|
|
.font(.body)
|
|
.lineLimit(1)
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
Text(ActivityBarFormatting.duration(stats.screenTimeSeconds))
|
|
.font(.body.monospacedDigit())
|
|
.foregroundStyle(.primary.opacity(0.55))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.75)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
GeometryReader { geo in
|
|
let ratio = CGFloat(stats.screenTimeSeconds) / CGFloat(Swift.max(maxScreenTime, 1))
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(.blue.opacity(0.25))
|
|
.frame(width: geo.size.width * ratio, height: 3)
|
|
}
|
|
.frame(height: 3)
|
|
|
|
if isExpanded {
|
|
HStack(spacing: 16) {
|
|
appDetailItem(icon: "keyboard", value: ActivityBarFormatting.decimal(stats.keystrokes))
|
|
appDetailItem(icon: "cursorarrow.click.2", value: ActivityBarFormatting.decimal(stats.pointerClicks))
|
|
appDetailItem(icon: "scroll", value: ActivityBarFormatting.decimal(stats.scrollEvents))
|
|
}
|
|
.padding(.top, 4)
|
|
.padding(.leading, 16)
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
}
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
}
|
|
|
|
private func appDetailItem(icon: String, value: String) -> some View {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.primary.opacity(0.55))
|
|
|
|
Text(value)
|
|
.font(.caption.monospacedDigit())
|
|
.foregroundStyle(.primary.opacity(0.55))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
private var statsDisclosure: some View {
|
|
HStack {
|
|
Button {
|
|
statsExpanded.toggle()
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
Text(localization.string("component.trends.title", defaultValue: "Trends"))
|
|
.font(.headline)
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.primary.opacity(0.55))
|
|
.rotationEffect(.degrees(statsExpanded ? 90 : 0))
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if statsExpanded {
|
|
Spacer(minLength: 8)
|
|
|
|
ForEach(ActivityBarTrendMode.allCases, id: \.self) { mode in
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
|
trendMode = mode
|
|
hoveredDate = nil
|
|
}
|
|
} label: {
|
|
Text(mode.label(localization: localization))
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(.primary.opacity(trendMode == mode ? 1 : 0.35))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
} else {
|
|
Spacer()
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
private var codingToolsChart: some View {
|
|
let days = controller.codingStats.recentDays(count: chartRange.dayCount, endingAt: selectedDate)
|
|
let compactMode = chartRange.dayCount > 7
|
|
let dateLabels = days.map { chartLabel($0.date) }
|
|
let hasData = days.contains { hasCodingStats(visibleCodingAggregateStats(in: $0)) }
|
|
let maxMinutes = days
|
|
.flatMap { day in
|
|
Self.visibleCodingTools.map { tool in
|
|
toolStats(tool, in: day).durationSeconds / 60
|
|
}
|
|
}
|
|
.max() ?? 0
|
|
let yUpperBound = Swift.max(maxMinutes * 1.15, 1)
|
|
|
|
return VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
rangePickerBar
|
|
Spacer(minLength: 8)
|
|
ForEach(Self.visibleCodingTools, id: \.rawValue) { tool in
|
|
legendDot(color: Self.codingToolColor(tool), label: Self.codingToolLegendLabel(tool))
|
|
}
|
|
}
|
|
.padding(.horizontal, 6)
|
|
|
|
ZStack {
|
|
Chart {
|
|
// `hasData ? days : []` keeps the content a plain ForEach (no
|
|
// `_ConditionalContent`, whose ChartContent conformance is macOS 27-only);
|
|
// an empty range draws nothing, exactly as the old `if hasData` did.
|
|
ForEach(hasData ? days : []) { day in
|
|
let d = chartLabel(day.date)
|
|
let isHovered = hoveredDate == d
|
|
|
|
ForEach(Self.visibleCodingTools, id: \.rawValue) { tool in
|
|
let label = Self.codingToolLegendLabel(tool)
|
|
let minutes = toolStats(tool, in: day).durationSeconds / 60
|
|
|
|
LineMark(x: .value("Date", d), y: .value("Minutes", minutes), series: .value("Tool", label))
|
|
.foregroundStyle(by: .value("Tool", label))
|
|
.interpolationMethod(.catmullRom)
|
|
}
|
|
|
|
// Unconditional marks; opacity/symbol size carry the hover state (the old
|
|
// if/else-if produced _ConditionalContent). The annotation body is a plain
|
|
// View, so its `if isHovered` is fine.
|
|
RuleMark(x: .value("Date", d))
|
|
.foregroundStyle(.gray.opacity(isHovered ? 0.3 : 0))
|
|
.lineStyle(StrokeStyle(dash: [4, 4]))
|
|
.annotation(position: .top, spacing: 0, overflowResolution: .init(x: .fit(to: .chart), y: .disabled)) {
|
|
if isHovered {
|
|
VStack(spacing: 2) {
|
|
Text(shortDate(day.date))
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.primary.opacity(0.55))
|
|
HStack(spacing: 6) {
|
|
ForEach(Self.visibleCodingTools, id: \.rawValue) { tool in
|
|
let minutes = toolStats(tool, in: day).durationSeconds / 60
|
|
Text(shortDuration(minutes * 60))
|
|
.foregroundStyle(Self.codingToolColor(tool))
|
|
}
|
|
}
|
|
.font(.system(size: 10).bold().monospacedDigit())
|
|
}
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 3)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 4))
|
|
.offset(y: 30)
|
|
}
|
|
}
|
|
|
|
ForEach(Self.visibleCodingTools, id: \.rawValue) { tool in
|
|
let minutes = toolStats(tool, in: day).durationSeconds / 60
|
|
chartPoint(
|
|
x: d,
|
|
y: minutes,
|
|
color: Self.codingToolColor(tool),
|
|
size: hoverPointSize(isHovered: isHovered, compactMode: compactMode)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.chartForegroundStyleScale([
|
|
"Claude": Self.claudeColor,
|
|
"Cursor": Self.cursorColor,
|
|
"Codex": Self.codexColor,
|
|
])
|
|
.chartLegend(.hidden)
|
|
.chartYScale(domain: 0...yUpperBound)
|
|
.chartYAxis {
|
|
if hasData {
|
|
AxisMarks(position: .leading) { value in
|
|
AxisGridLine()
|
|
AxisValueLabel {
|
|
if let minutes = value.as(Double.self) {
|
|
Text(talkAxisLabel(minutes))
|
|
.font(.system(size: 9))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.chartXAxis {
|
|
AxisMarks(values: .automatic) { value in
|
|
AxisValueLabel {
|
|
if let label = value.as(String.self), !compactMode || shouldShowXLabel(label, in: dateLabels) {
|
|
Text(label)
|
|
.font(.system(size: 9))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.chartOverlay { proxy in
|
|
if hasData {
|
|
chartHoverOverlay(proxy: proxy, labels: dateLabels, hoveredLabel: $hoveredDate)
|
|
}
|
|
}
|
|
|
|
if !hasData {
|
|
emptyChartMessage(localization.string("component.chart.emptyCoding", defaultValue: "No coding activity"))
|
|
}
|
|
}
|
|
.frame(height: 120)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
private var weeklyChart: some View {
|
|
let days = controller.inputStats.recentDays(count: chartRange.dayCount, endingAt: selectedDate)
|
|
let compactMode = chartRange.dayCount > 7
|
|
let dateLabels = days.map { chartLabel($0.date) }
|
|
let hasData = days.contains { $0.totalInputs > 0 }
|
|
let maxInputCount = days
|
|
.map { day in
|
|
Swift.max(day.keystrokes, Swift.max(day.pointerClicks, day.scrollEvents))
|
|
}
|
|
.max() ?? 0
|
|
let yUpperBound = Swift.max(Double(maxInputCount) * 1.15, 1)
|
|
|
|
return VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
rangePickerBar
|
|
Spacer(minLength: 8)
|
|
legendDot(color: .blue, label: localization.string("component.legend.keys", defaultValue: "Keys"))
|
|
legendDot(color: .orange, label: localization.string("component.legend.clicks", defaultValue: "Clicks"))
|
|
legendDot(color: .green, label: localization.string("component.legend.scrolls", defaultValue: "Scrolls"))
|
|
}
|
|
.padding(.horizontal, 6)
|
|
|
|
ZStack {
|
|
Chart {
|
|
// See codingToolsChart: `hasData ? days : []` avoids _ConditionalContent.
|
|
ForEach(hasData ? days : []) { day in
|
|
let d = chartLabel(day.date)
|
|
let isHovered = hoveredDate == d
|
|
|
|
LineMark(x: .value("Date", d), y: .value("Count", day.keystrokes), series: .value("Metric", "Keystrokes"))
|
|
.foregroundStyle(by: .value("Metric", "Keystrokes"))
|
|
.interpolationMethod(.catmullRom)
|
|
LineMark(x: .value("Date", d), y: .value("Count", day.pointerClicks), series: .value("Metric", "Clicks"))
|
|
.foregroundStyle(by: .value("Metric", "Clicks"))
|
|
.interpolationMethod(.catmullRom)
|
|
LineMark(x: .value("Date", d), y: .value("Count", day.scrollEvents), series: .value("Metric", "Scrolls"))
|
|
.foregroundStyle(by: .value("Metric", "Scrolls"))
|
|
.interpolationMethod(.catmullRom)
|
|
|
|
RuleMark(x: .value("Date", d))
|
|
.foregroundStyle(.gray.opacity(isHovered ? 0.3 : 0))
|
|
.lineStyle(StrokeStyle(dash: [4, 4]))
|
|
.annotation(position: .top, spacing: 0, overflowResolution: .init(x: .fit(to: .chart), y: .disabled)) {
|
|
if isHovered {
|
|
VStack(spacing: 2) {
|
|
Text(shortDate(day.date))
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.primary.opacity(0.55))
|
|
HStack(spacing: 6) {
|
|
Text("\(day.keystrokes)").foregroundStyle(.blue)
|
|
Text("\(day.pointerClicks)").foregroundStyle(.orange)
|
|
Text("\(day.scrollEvents)").foregroundStyle(.green)
|
|
}
|
|
.font(.system(size: 10).bold().monospacedDigit())
|
|
}
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 3)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 4))
|
|
.offset(y: 30)
|
|
}
|
|
}
|
|
|
|
chartPoint(x: d, y: Double(day.keystrokes), color: .blue, size: hoverPointSize(isHovered: isHovered, compactMode: compactMode))
|
|
chartPoint(x: d, y: Double(day.pointerClicks), color: .orange, size: hoverPointSize(isHovered: isHovered, compactMode: compactMode))
|
|
chartPoint(x: d, y: Double(day.scrollEvents), color: .green, size: hoverPointSize(isHovered: isHovered, compactMode: compactMode))
|
|
}
|
|
}
|
|
.chartForegroundStyleScale([
|
|
"Keystrokes": Color.blue,
|
|
"Clicks": Color.orange,
|
|
"Scrolls": Color.green,
|
|
])
|
|
.chartLegend(.hidden)
|
|
.chartYScale(domain: 0...yUpperBound)
|
|
.chartYAxis {
|
|
if hasData {
|
|
AxisMarks(position: .leading) { value in
|
|
AxisGridLine()
|
|
AxisValueLabel {
|
|
if let count = value.as(Double.self) {
|
|
Text(ActivityBarFormatting.count(Int(count)))
|
|
.font(.system(size: 9))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.chartXAxis {
|
|
AxisMarks(values: .automatic) { value in
|
|
AxisValueLabel {
|
|
if let label = value.as(String.self), !compactMode || shouldShowXLabel(label, in: dateLabels) {
|
|
Text(label)
|
|
.font(.system(size: 9))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.chartOverlay { proxy in
|
|
if hasData {
|
|
chartHoverOverlay(proxy: proxy, labels: dateLabels, hoveredLabel: $hoveredDate)
|
|
}
|
|
}
|
|
|
|
if !hasData {
|
|
emptyChartMessage(localization.string("component.chart.emptyInput", defaultValue: "No input activity"))
|
|
}
|
|
}
|
|
.frame(height: 120)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
private var screenTimeSection: some View {
|
|
let days = controller.inputStats.recentDays(count: chartRange.dayCount, endingAt: selectedDate)
|
|
let compactMode = chartRange.dayCount > 7
|
|
let labels = days.map { chartLabel($0.date) }
|
|
|
|
return VStack(alignment: .leading, spacing: 6) {
|
|
Text(localization.string("component.screenTime.title", defaultValue: "Screen Time"))
|
|
.font(.headline)
|
|
|
|
Chart {
|
|
ForEach(days) { day in
|
|
let d = chartLabel(day.date)
|
|
let isHovered = hoveredScreenTimeDate == d
|
|
BarMark(
|
|
x: .value("Date", d),
|
|
y: .value("Duration", day.screenTimeSeconds / 60)
|
|
)
|
|
.foregroundStyle(isHovered ? .blue.opacity(0.7) : .blue.opacity(0.4))
|
|
.cornerRadius(2)
|
|
.annotation(position: .top, spacing: 2) {
|
|
if isHovered, day.screenTimeSeconds > 0 {
|
|
Text(shortDuration(day.screenTimeSeconds))
|
|
.font(.system(size: 9).bold().monospacedDigit())
|
|
.foregroundStyle(.primary)
|
|
.padding(.horizontal, 5)
|
|
.padding(.vertical, 2)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 4))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.chartYAxis {
|
|
AxisMarks(position: .leading) { value in
|
|
AxisGridLine()
|
|
AxisValueLabel {
|
|
if let minutes = value.as(Double.self) {
|
|
Text(talkAxisLabel(minutes))
|
|
.font(.system(size: 9))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.chartXAxis {
|
|
AxisMarks(values: .automatic) { value in
|
|
AxisGridLine()
|
|
AxisValueLabel {
|
|
if let label = value.as(String.self), !compactMode || shouldShowXLabel(label, in: labels) {
|
|
Text(label)
|
|
.font(.system(size: 9))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.chartOverlay { proxy in
|
|
chartHoverOverlay(proxy: proxy, labels: labels, hoveredLabel: $hoveredScreenTimeDate)
|
|
}
|
|
.frame(height: 60)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
private var rangePickerBar: some View {
|
|
HStack(spacing: 0) {
|
|
ForEach(ActivityBarChartRange.allCases, id: \.self) { range in
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
|
chartRange = range
|
|
hoveredDate = nil
|
|
hoveredScreenTimeDate = nil
|
|
}
|
|
} label: {
|
|
Text(range.label)
|
|
.font(.caption.weight(.medium))
|
|
.padding(.horizontal, 7)
|
|
.padding(.vertical, 4)
|
|
.foregroundStyle(chartRange == range ? .white : .primary.opacity(0.55))
|
|
.background(
|
|
chartRange == range ? Color.blue : Color.clear,
|
|
in: RoundedRectangle(cornerRadius: 5, style: .continuous)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func legendDot(color: Color, label: String) -> some View {
|
|
HStack(spacing: 4) {
|
|
Circle()
|
|
.fill(color)
|
|
.frame(width: 7, height: 7)
|
|
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundStyle(.primary.opacity(0.55))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
private func emptyChartMessage(_ text: String) -> some View {
|
|
Text(text)
|
|
.font(.caption)
|
|
.foregroundStyle(.primary.opacity(0.4))
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
}
|
|
|
|
private func chartPoint(x: String, y: Double, color: Color, size: CGFloat) -> some ChartContent {
|
|
PointMark(x: .value("Date", x), y: .value("Value", y))
|
|
.foregroundStyle(color)
|
|
.symbolSize(size)
|
|
}
|
|
|
|
/// Hover-driven point size, kept unconditional (0 hides the mark on compact
|
|
/// non-hovered days) so the Chart body has no `_ConditionalContent` — whose
|
|
/// `ChartContent` conformance is macOS 27-only and fails the 27-SDK build at
|
|
/// a 14.0 deployment target.
|
|
private func hoverPointSize(isHovered: Bool, compactMode: Bool) -> CGFloat {
|
|
if isHovered { return 30 }
|
|
return compactMode ? 0 : 12
|
|
}
|
|
|
|
private func chartHoverOverlay(proxy: ChartProxy, labels: [String], hoveredLabel: Binding<String?>) -> some View {
|
|
GeometryReader { geo in
|
|
Rectangle()
|
|
.fill(.clear)
|
|
.contentShape(Rectangle())
|
|
.onContinuousHover { phase in
|
|
switch phase {
|
|
case .active(let location):
|
|
guard let plotFrame = proxy.plotFrame else {
|
|
return
|
|
}
|
|
|
|
let frame = geo[plotFrame]
|
|
let x = location.x - frame.origin.x
|
|
var closest: String?
|
|
var closestDist: CGFloat = .infinity
|
|
|
|
for label in labels {
|
|
if let position = proxy.position(forX: label) {
|
|
let distance = abs(position - x)
|
|
if distance < closestDist {
|
|
closestDist = distance
|
|
closest = label
|
|
}
|
|
}
|
|
}
|
|
|
|
hoveredLabel.wrappedValue = closest
|
|
case .ended:
|
|
hoveredLabel.wrappedValue = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var footerBar: some View {
|
|
HStack(spacing: 12) {
|
|
Button {
|
|
if controller.areHooksInstalled {
|
|
controller.uninstallHooks()
|
|
} else {
|
|
controller.installHooks()
|
|
}
|
|
} label: {
|
|
Text(hookActionTitle)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.primary.opacity(0.55))
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Button {
|
|
controller.openInputMonitoringSettings()
|
|
} label: {
|
|
Text(localization.string("component.footer.permissions", defaultValue: "Permissions"))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.primary.opacity(0.55))
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Spacer()
|
|
|
|
Button(action: dismiss) {
|
|
Text(localization.string("component.footer.close", defaultValue: "Close"))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.primary.opacity(0.55))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
private var divider: some View {
|
|
Divider()
|
|
.padding(.horizontal, 12)
|
|
}
|
|
|
|
private func codingToolRows(for day: ActivityBarCodingDailyStats) -> [CodingToolDisplayRow] {
|
|
let knownRows = Self.visibleCodingTools.compactMap { tool -> CodingToolDisplayRow? in
|
|
let stats = toolStats(tool, in: day)
|
|
guard hasCodingStats(stats) else {
|
|
return nil
|
|
}
|
|
|
|
return displayRow(for: tool, stats: stats)
|
|
}
|
|
|
|
if !knownRows.isEmpty {
|
|
return knownRows
|
|
}
|
|
|
|
let aggregateStats = ActivityBarProjectStats(
|
|
durationSeconds: day.durationSeconds,
|
|
wordCount: day.wordCount,
|
|
toolCallCount: day.toolCallCount
|
|
)
|
|
guard hasCodingStats(aggregateStats) else {
|
|
return []
|
|
}
|
|
|
|
return [
|
|
CodingToolDisplayRow(
|
|
id: "ai-tools",
|
|
name: "AI Tools",
|
|
duration: aggregateStats.durationSeconds,
|
|
detail: codingDetailText(for: aggregateStats),
|
|
tint: Self.codexColor,
|
|
systemImage: "terminal",
|
|
iconSize: 13
|
|
)
|
|
]
|
|
}
|
|
|
|
private func displayRow(for tool: ActivityBarCodingTool, stats: ActivityBarProjectStats) -> CodingToolDisplayRow {
|
|
switch tool {
|
|
case .claudeCode:
|
|
return CodingToolDisplayRow(
|
|
id: tool.rawValue,
|
|
name: tool.rawValue,
|
|
duration: stats.durationSeconds,
|
|
detail: codingDetailText(for: stats),
|
|
tint: Self.claudeColor,
|
|
systemImage: "sparkles",
|
|
iconSize: 12
|
|
)
|
|
case .cursor:
|
|
return CodingToolDisplayRow(
|
|
id: tool.rawValue,
|
|
name: tool.rawValue,
|
|
duration: stats.durationSeconds,
|
|
detail: codingDetailText(for: stats),
|
|
tint: Self.cursorColor,
|
|
systemImage: "cube.fill",
|
|
iconSize: 13
|
|
)
|
|
case .codex:
|
|
return CodingToolDisplayRow(
|
|
id: tool.rawValue,
|
|
name: tool.rawValue,
|
|
duration: stats.durationSeconds,
|
|
detail: codingDetailText(for: stats),
|
|
tint: Self.codexColor,
|
|
systemImage: "terminal",
|
|
iconSize: 13
|
|
)
|
|
}
|
|
}
|
|
|
|
private var hookActionTitle: String {
|
|
if controller.areHooksInstalled {
|
|
return localization.string("component.footer.uninstallHooks", defaultValue: "卸载 Hook")
|
|
}
|
|
|
|
return localization.string("component.footer.hooks", defaultValue: "Hooks")
|
|
}
|
|
|
|
private func toolStats(_ tool: ActivityBarCodingTool, in day: ActivityBarCodingDailyStats) -> ActivityBarProjectStats {
|
|
if let stats = day.perTool[tool.rawValue] {
|
|
return stats
|
|
}
|
|
|
|
if day.perTool.isEmpty, tool == .claudeCode {
|
|
return ActivityBarProjectStats(
|
|
durationSeconds: day.durationSeconds,
|
|
wordCount: day.wordCount,
|
|
toolCallCount: day.toolCallCount
|
|
)
|
|
}
|
|
|
|
return ActivityBarProjectStats()
|
|
}
|
|
|
|
private func visibleCodingAggregateStats(in day: ActivityBarCodingDailyStats) -> ActivityBarProjectStats {
|
|
let visibleToolStats = Self.visibleCodingTools.map { toolStats($0, in: day) }
|
|
if visibleToolStats.contains(where: { hasCodingStats($0) }) {
|
|
return visibleToolStats.reduce(ActivityBarProjectStats()) { result, stats in
|
|
ActivityBarProjectStats(
|
|
durationSeconds: result.durationSeconds + stats.durationSeconds,
|
|
wordCount: result.wordCount + stats.wordCount,
|
|
toolCallCount: result.toolCallCount + stats.toolCallCount
|
|
)
|
|
}
|
|
}
|
|
|
|
guard day.perTool.isEmpty else {
|
|
return ActivityBarProjectStats()
|
|
}
|
|
|
|
return ActivityBarProjectStats(
|
|
durationSeconds: day.durationSeconds,
|
|
wordCount: day.wordCount,
|
|
toolCallCount: day.toolCallCount
|
|
)
|
|
}
|
|
|
|
private func hasCodingStats(_ stats: ActivityBarProjectStats) -> Bool {
|
|
stats.durationSeconds > 0 || stats.wordCount > 0 || stats.toolCallCount > 0
|
|
}
|
|
|
|
private func codingDetailText(for stats: ActivityBarProjectStats) -> String {
|
|
localization.format(
|
|
"component.ai.detail",
|
|
defaultValue: "%@ words · %@ tools",
|
|
ActivityBarFormatting.decimal(stats.wordCount),
|
|
ActivityBarFormatting.decimal(stats.toolCallCount)
|
|
)
|
|
}
|
|
|
|
private var displayDateString: String {
|
|
if isViewingToday {
|
|
return localization.string("component.date.today", defaultValue: "Today")
|
|
}
|
|
|
|
if selectedDateOffset == -1 {
|
|
return localization.string("component.date.yesterday", defaultValue: "Yesterday")
|
|
}
|
|
|
|
return ActivityBarFormatting.monthDay(selectedDate)
|
|
}
|
|
|
|
private func shortDate(_ dateString: String) -> String {
|
|
let parts = dateString.split(separator: "-")
|
|
guard parts.count == 3,
|
|
let year = Int(parts[0]),
|
|
let month = Int(parts[1]),
|
|
let day = Int(parts[2]),
|
|
let date = Calendar.current.date(from: DateComponents(year: year, month: month, day: day))
|
|
else {
|
|
return dateString
|
|
}
|
|
|
|
return ActivityBarFormatting.monthDay(date)
|
|
}
|
|
|
|
private func chartLabel(_ dateString: String) -> String {
|
|
if chartRange.dayCount <= 7 {
|
|
return shortDate(dateString)
|
|
}
|
|
|
|
let parts = dateString.split(separator: "-")
|
|
guard parts.count == 3 else {
|
|
return dateString
|
|
}
|
|
|
|
let day = Int(parts[2]) ?? 0
|
|
if day == 1 {
|
|
return shortDate(dateString)
|
|
}
|
|
|
|
return "\(day)"
|
|
}
|
|
|
|
private func shouldShowXLabel(_ label: String, in allLabels: [String]) -> Bool {
|
|
guard let index = allLabels.firstIndex(of: label) else {
|
|
return false
|
|
}
|
|
|
|
let step = chartRange.dayCount <= 14 ? 2 : 5
|
|
return index % step == 0 || index == allLabels.count - 1
|
|
}
|
|
|
|
private func talkAxisLabel(_ minutes: Double) -> String {
|
|
if minutes >= 60 {
|
|
return "\(Int(minutes / 60))h"
|
|
}
|
|
|
|
return "\(Int(minutes))m"
|
|
}
|
|
|
|
private func shortDuration(_ seconds: Double) -> String {
|
|
let total = max(Int(seconds), 0)
|
|
let hours = total / 3_600
|
|
let minutes = (total % 3_600) / 60
|
|
let secs = total % 60
|
|
|
|
if hours > 0 {
|
|
return "\(hours)h \(minutes)m"
|
|
}
|
|
|
|
if minutes > 0 {
|
|
return "\(minutes)m \(secs)s"
|
|
}
|
|
|
|
return "\(secs)s"
|
|
}
|
|
|
|
private func dateKey(for date: Date) -> String {
|
|
let calendar = Calendar.current
|
|
let components = calendar.dateComponents([.year, .month, .day], from: date)
|
|
let year = components.year ?? 0
|
|
let month = components.month ?? 0
|
|
let day = components.day ?? 0
|
|
return String(format: "%04d-%02d-%02d", year, month, day)
|
|
}
|
|
|
|
private struct CodingToolDisplayRow: Identifiable {
|
|
let id: String
|
|
let name: String
|
|
let duration: TimeInterval
|
|
let detail: String?
|
|
let tint: Color
|
|
let systemImage: String
|
|
let iconSize: CGFloat
|
|
}
|
|
}
|
|
|
|
private struct ActivityBarComponentBackground: View {
|
|
let cornerRadius: CGFloat
|
|
|
|
var body: some View {
|
|
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
|
.fill(Color.primary.opacity(0.045))
|
|
}
|
|
}
|