checkpoint before checking out main
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
// MonthlyUsageCalendar.swift
|
||||
// OSGKeyboard · HostSupport
|
||||
//
|
||||
// Current-month dictation heat map. Every date remains visible; the accent
|
||||
// circle's opacity communicates that day's character count relative to the
|
||||
// busiest day in the same month.
|
||||
|
||||
import SwiftUI
|
||||
#if canImport(OSGKeyboardShared)
|
||||
import OSGKeyboardShared
|
||||
#endif
|
||||
|
||||
public struct MonthlyUsageCalendar: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
public let points: [UsageStatisticsStore.DailyUsagePoint]
|
||||
public let language: AppUILanguage
|
||||
public var compact: Bool
|
||||
public var embedsInCard: Bool
|
||||
|
||||
public init(
|
||||
points: [UsageStatisticsStore.DailyUsagePoint],
|
||||
language: AppUILanguage,
|
||||
compact: Bool = false,
|
||||
embedsInCard: Bool = true
|
||||
) {
|
||||
self.points = points
|
||||
self.language = language
|
||||
self.compact = compact
|
||||
self.embedsInCard = embedsInCard
|
||||
}
|
||||
|
||||
private var displayLocale: Locale {
|
||||
Locale(identifier: language.resolvedLanguageCode())
|
||||
}
|
||||
|
||||
private var displayCalendar: Calendar {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.locale = displayLocale
|
||||
return calendar
|
||||
}
|
||||
|
||||
private var total: Int {
|
||||
points.reduce(0) { $0 + $1.value }
|
||||
}
|
||||
|
||||
private var maximumValue: Int {
|
||||
max(points.map(\.value).max() ?? 0, 1)
|
||||
}
|
||||
|
||||
private var monthTitle: String {
|
||||
guard let date = points.first?.date else { return "" }
|
||||
return date.formatted(
|
||||
.dateTime
|
||||
.year()
|
||||
.month(.wide)
|
||||
.locale(displayLocale)
|
||||
)
|
||||
}
|
||||
|
||||
private var weekdaySymbols: [String] {
|
||||
let symbols = displayCalendar.veryShortStandaloneWeekdaySymbols
|
||||
let startIndex = max(0, min(displayCalendar.firstWeekday - 1, symbols.count - 1))
|
||||
return Array(symbols[startIndex...] + symbols[..<startIndex])
|
||||
}
|
||||
|
||||
private var leadingEmptyCellCount: Int {
|
||||
guard let firstDate = points.first?.date else { return 0 }
|
||||
let weekday = displayCalendar.component(.weekday, from: firstDate)
|
||||
return (weekday - displayCalendar.firstWeekday + 7) % 7
|
||||
}
|
||||
|
||||
private var calendarCells: [UsageStatisticsStore.DailyUsagePoint?] {
|
||||
Array(repeating: nil, count: leadingEmptyCellCount) + points.map(Optional.some)
|
||||
}
|
||||
|
||||
private var cellDiameter: CGFloat {
|
||||
compact ? 28 : 32
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Group {
|
||||
if embedsInCard {
|
||||
UsageSurfaceCard(padding: Spacing.md) {
|
||||
calendarContent
|
||||
}
|
||||
} else {
|
||||
calendarContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var calendarContent: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
header
|
||||
weekdayHeader
|
||||
dayGrid
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(monthTitle.uppercased())
|
||||
.font(TypeStyle.caption2)
|
||||
.tracking(0.6)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Text(SharedL10n.string("stat.monthChart.caption", language: language))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
Spacer(minLength: Spacing.sm)
|
||||
Text(UsageStatisticsStore.formatCount(total, language: language))
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(palette.accent)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.contentTransition(.numericText())
|
||||
.animation(Motion.soft, value: total)
|
||||
}
|
||||
}
|
||||
|
||||
private var weekdayHeader: some View {
|
||||
LazyVGrid(columns: columns, spacing: 0) {
|
||||
ForEach(Array(weekdaySymbols.enumerated()), id: \.offset) { _, symbol in
|
||||
Text(symbol)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var dayGrid: some View {
|
||||
LazyVGrid(columns: columns, spacing: compact ? 3 : 5) {
|
||||
ForEach(Array(calendarCells.enumerated()), id: \.offset) { _, point in
|
||||
if let point {
|
||||
dayCell(point)
|
||||
} else {
|
||||
Color.clear
|
||||
.frame(width: cellDiameter, height: cellDiameter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var columns: [GridItem] {
|
||||
Array(
|
||||
repeating: GridItem(.flexible(minimum: cellDiameter), spacing: 4),
|
||||
count: 7
|
||||
)
|
||||
}
|
||||
|
||||
private func dayCell(_ point: UsageStatisticsStore.DailyUsagePoint) -> some View {
|
||||
let calendar = displayCalendar
|
||||
let day = calendar.component(.day, from: point.date)
|
||||
let isToday = calendar.isDateInToday(point.date)
|
||||
let isFuture = calendar.startOfDay(for: point.date) > calendar.startOfDay(for: Date())
|
||||
let opacity = fillOpacity(for: point.value, isFuture: isFuture)
|
||||
let usesLightText = opacity >= 0.48
|
||||
|
||||
return Text(day.formatted())
|
||||
.font(.system(size: compact ? 12 : 13, weight: isToday ? .semibold : .medium, design: .rounded))
|
||||
.foregroundStyle(
|
||||
isFuture
|
||||
? palette.textTertiary.opacity(0.45)
|
||||
: (usesLightText ? palette.textOnAccent : palette.textPrimary)
|
||||
)
|
||||
.frame(width: cellDiameter, height: cellDiameter)
|
||||
.background(palette.accent.opacity(opacity), in: Circle())
|
||||
.overlay {
|
||||
if isToday {
|
||||
Circle()
|
||||
.stroke(palette.accent, lineWidth: 1.5)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityElement()
|
||||
.accessibilityLabel(
|
||||
Text(point.date.formatted(.dateTime.month().day()))
|
||||
)
|
||||
.accessibilityValue(
|
||||
Text(UsageStatisticsStore.formatCount(point.value, language: language))
|
||||
)
|
||||
}
|
||||
|
||||
private func fillOpacity(for value: Int, isFuture: Bool) -> Double {
|
||||
guard value > 0, !isFuture else { return 0 }
|
||||
let ratio = min(max(Double(value) / Double(maximumValue), 0), 1)
|
||||
return 0.14 + sqrt(ratio) * 0.62
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Monthly usage") {
|
||||
let calendar = Calendar.current
|
||||
let start = calendar.date(
|
||||
from: calendar.dateComponents([.year, .month], from: Date())
|
||||
) ?? Date()
|
||||
let points = (0..<31).compactMap { offset -> UsageStatisticsStore.DailyUsagePoint? in
|
||||
guard let date = calendar.date(byAdding: .day, value: offset, to: start) else {
|
||||
return nil
|
||||
}
|
||||
let value = offset.isMultiple(of: 5) ? offset * 120 : 0
|
||||
return UsageStatisticsStore.DailyUsagePoint(date: date, value: value)
|
||||
}
|
||||
|
||||
ThemedRoot {
|
||||
MonthlyUsageCalendar(
|
||||
points: points,
|
||||
language: .auto
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user