Files
OSGKeyboard/OSGKeyboardHostSupport/DesignSystem/MonthlyUsageCalendar.swift
Rocky 39002336c0
CI / Validate manifests (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
CI / iOS / Extension (push) Has been cancelled
CI / macOS (push) Has been cancelled
feat(app): add flow diagnostics and refine interface
Improve failure recovery visibility, strengthen account-session handling, and make the cross-platform interface more consistent.
2026-08-26 14:40:06 +08:00

216 lines
7.1 KiB
Swift

// MonthlyUsageCalendar.swift
// OSGKeyboard · HostSupport
//
// Current-month dictation heat map. Every date remains visible; the monochrome
// 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 usesContrastingText = 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)
: (usesContrastingText ? palette.background : palette.textPrimary)
)
.frame(width: cellDiameter, height: cellDiameter)
.background(palette.textPrimary.opacity(opacity), in: Circle())
.overlay {
if isToday {
Circle()
.stroke(palette.textPrimary, 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.05 + pow(ratio, 1.15) * 0.73
}
}
#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()
}
}