// 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[.. 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() } }