diff --git a/DevLog/Widget/Heatmap/HeatmapWidgetSnapshot.swift b/DevLog/Widget/Heatmap/HeatmapWidgetSnapshot.swift index 530b5c56..0a387915 100644 --- a/DevLog/Widget/Heatmap/HeatmapWidgetSnapshot.swift +++ b/DevLog/Widget/Heatmap/HeatmapWidgetSnapshot.swift @@ -9,9 +9,14 @@ import Foundation struct HeatmapWidgetSnapshot: Codable, Equatable { let generatedAt: Date - let monthStart: Date + let quarterStart: Date let selectedActivityKindRawValues: [String] let maxCount: Int + let months: [WidgetHeatmapMonthSnapshot] +} + +struct WidgetHeatmapMonthSnapshot: Codable, Equatable { + let monthStart: Date let weeks: [WidgetHeatmapWeekSnapshot] } diff --git a/DevLog/Widget/Heatmap/HeatmapWidgetSnapshotFactory.swift b/DevLog/Widget/Heatmap/HeatmapWidgetSnapshotFactory.swift index 3172c440..3b62cf95 100644 --- a/DevLog/Widget/Heatmap/HeatmapWidgetSnapshotFactory.swift +++ b/DevLog/Widget/Heatmap/HeatmapWidgetSnapshotFactory.swift @@ -36,30 +36,40 @@ struct HeatmapWidgetSnapshotFactory { completedTodos: [Todo], deletedTodos: [Todo], selectedActivityKinds: Set, - monthStart: Date, + quarterStart: Date, now: Date = Date() ) -> HeatmapWidgetSnapshot { - let normalizedMonthStart = startOfMonth(for: monthStart) + let normalizedQuarterStart = startOfQuarter(for: quarterStart) + guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: normalizedQuarterStart) else { + return HeatmapWidgetSnapshot( + generatedAt: now, + quarterStart: normalizedQuarterStart, + selectedActivityKindRawValues: orderedActivityKinds(from: selectedActivityKinds).map(\.rawValue), + maxCount: 0, + months: [] + ) + } let dailyCountsByDate = makeDailyCountsByDate( createdTodos: createdTodos, completedTodos: completedTodos, deletedTodos: deletedTodos, - monthStart: normalizedMonthStart + quarterStart: normalizedQuarterStart, + nextQuarterStart: nextQuarterStart ) - let weeks = makeWeeks( - monthStart: normalizedMonthStart, + let months = makeMonths( + quarterStart: normalizedQuarterStart, dailyCountsByDate: dailyCountsByDate ) return HeatmapWidgetSnapshot( generatedAt: now, - monthStart: normalizedMonthStart, + quarterStart: normalizedQuarterStart, selectedActivityKindRawValues: orderedActivityKinds(from: selectedActivityKinds).map(\.rawValue), maxCount: maxCount( - from: weeks, + from: months, selectedActivityKinds: selectedActivityKinds ), - weeks: weeks + months: months ) } } @@ -69,7 +79,8 @@ private extension HeatmapWidgetSnapshotFactory { createdTodos: [Todo], completedTodos: [Todo], deletedTodos: [Todo], - monthStart: Date + quarterStart: Date, + nextQuarterStart: Date ) -> [Date: DailyCounts] { var dailyCountsByDate = [Date: DailyCounts]() @@ -77,7 +88,8 @@ private extension HeatmapWidgetSnapshotFactory { appendCount( activityKind: .created, occurredAt: todo.createdAt, - monthStart: monthStart, + quarterStart: quarterStart, + nextQuarterStart: nextQuarterStart, dailyCountsByDate: &dailyCountsByDate ) } @@ -87,7 +99,8 @@ private extension HeatmapWidgetSnapshotFactory { appendCount( activityKind: .completed, occurredAt: completedAt, - monthStart: monthStart, + quarterStart: quarterStart, + nextQuarterStart: nextQuarterStart, dailyCountsByDate: &dailyCountsByDate ) } @@ -97,7 +110,8 @@ private extension HeatmapWidgetSnapshotFactory { appendCount( activityKind: .deleted, occurredAt: deletedAt, - monthStart: monthStart, + quarterStart: quarterStart, + nextQuarterStart: nextQuarterStart, dailyCountsByDate: &dailyCountsByDate ) } @@ -108,10 +122,11 @@ private extension HeatmapWidgetSnapshotFactory { func appendCount( activityKind: ActivityKind, occurredAt: Date, - monthStart: Date, + quarterStart: Date, + nextQuarterStart: Date, dailyCountsByDate: inout [Date: DailyCounts] ) { - guard isDateInMonth(occurredAt, monthStart: monthStart) else { return } + guard quarterStart <= occurredAt && occurredAt < nextQuarterStart else { return } let dayStart = calendar.startOfDay(for: occurredAt) var dailyCounts = dailyCountsByDate[dayStart] ?? DailyCounts() @@ -119,6 +134,25 @@ private extension HeatmapWidgetSnapshotFactory { dailyCountsByDate[dayStart] = dailyCounts } + func makeMonths( + quarterStart: Date, + dailyCountsByDate: [Date: DailyCounts] + ) -> [WidgetHeatmapMonthSnapshot] { + let monthStarts = (0..<3).compactMap { + calendar.date(byAdding: .month, value: $0, to: quarterStart) + } + + return monthStarts.map { monthStart in + WidgetHeatmapMonthSnapshot( + monthStart: monthStart, + weeks: makeWeeks( + monthStart: monthStart, + dailyCountsByDate: dailyCountsByDate + ) + ) + } + } + func makeWeeks( monthStart: Date, dailyCountsByDate: [Date: DailyCounts] @@ -173,29 +207,21 @@ private extension HeatmapWidgetSnapshotFactory { return weeks } - func startOfMonth(for date: Date) -> Date { - guard let monthInterval = calendar.dateInterval(of: .month, for: date) else { - return calendar.startOfDay(for: date) - } - return monthInterval.start - } - - func isDateInMonth( - _ date: Date, - monthStart: Date - ) -> Bool { - calendar.isDate( - calendar.startOfDay(for: date), - equalTo: monthStart, - toGranularity: .month - ) + func startOfQuarter(for date: Date) -> Date { + let month = calendar.component(.month, from: date) + let startMonth = ((month - 1) / 3) * 3 + 1 + var components = calendar.dateComponents([.year], from: date) + components.month = startMonth + components.day = 1 + return calendar.date(from: components) ?? calendar.startOfDay(for: date) } func maxCount( - from weeks: [WidgetHeatmapWeekSnapshot], + from months: [WidgetHeatmapMonthSnapshot], selectedActivityKinds: Set ) -> Int { - weeks + months + .flatMap(\.weeks) .flatMap(\.days) .filter(\.isVisible) .map { day in diff --git a/DevLog/Widget/Heatmap/HeatmapWidgetSyncCoordinator.swift b/DevLog/Widget/Heatmap/HeatmapWidgetSyncCoordinator.swift index 0351815e..019065cb 100644 --- a/DevLog/Widget/Heatmap/HeatmapWidgetSyncCoordinator.swift +++ b/DevLog/Widget/Heatmap/HeatmapWidgetSyncCoordinator.swift @@ -31,16 +31,16 @@ final class HeatmapWidgetSyncCoordinator { selectedActivityKinds: Set, now: Date = Date() ) async { - let monthStart = startOfMonth(for: now) - guard let nextMonthStart = calendar.date(byAdding: .month, value: 1, to: monthStart) else { + let quarterStart = startOfQuarter(for: now) + guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: quarterStart) else { return } do { async let createdTodoPage = fetchTodosUseCase.execute( TodoQuery( - sortDateFrom: monthStart, - sortDateTo: nextMonthStart, + sortDateFrom: quarterStart, + sortDateTo: nextQuarterStart, includesDeleted: true, sortTarget: .createdAt, pageSize: 100, @@ -50,8 +50,8 @@ final class HeatmapWidgetSyncCoordinator { ) async let completedTodoPage = fetchTodosUseCase.execute( TodoQuery( - sortDateFrom: monthStart, - sortDateTo: nextMonthStart, + sortDateFrom: quarterStart, + sortDateTo: nextQuarterStart, includesDeleted: true, sortTarget: .completedAt, pageSize: 100, @@ -61,8 +61,8 @@ final class HeatmapWidgetSyncCoordinator { ) async let deletedTodoPage = fetchTodosUseCase.execute( TodoQuery( - sortDateFrom: monthStart, - sortDateTo: nextMonthStart, + sortDateFrom: quarterStart, + sortDateTo: nextQuarterStart, includesDeleted: true, sortTarget: .deletedAt, pageSize: 100, @@ -76,7 +76,7 @@ final class HeatmapWidgetSyncCoordinator { completedTodos: try await completedTodoPage.items, deletedTodos: try await deletedTodoPage.items, selectedActivityKinds: selectedActivityKinds, - monthStart: monthStart, + quarterStart: quarterStart, now: now ) @@ -94,11 +94,12 @@ final class HeatmapWidgetSyncCoordinator { } private extension HeatmapWidgetSyncCoordinator { - func startOfMonth(for date: Date) -> Date { - guard let monthInterval = calendar.dateInterval(of: .month, for: date) else { - return calendar.startOfDay(for: date) - } - - return monthInterval.start + func startOfQuarter(for date: Date) -> Date { + let month = calendar.component(.month, from: date) + let startMonth = ((month - 1) / 3) * 3 + 1 + var components = calendar.dateComponents([.year], from: date) + components.month = startMonth + components.day = 1 + return calendar.date(from: components) ?? calendar.startOfDay(for: date) } } diff --git a/DevLogWidget/Common/WidgetPlaceholderCard.swift b/DevLogWidget/Common/WidgetPlaceholderCard.swift index 18ffa723..59691f94 100644 --- a/DevLogWidget/Common/WidgetPlaceholderCard.swift +++ b/DevLogWidget/Common/WidgetPlaceholderCard.swift @@ -8,7 +8,6 @@ import SwiftUI struct WidgetPlaceholderCard: View { - let title: String let message: String var body: some View { @@ -18,11 +17,7 @@ struct WidgetPlaceholderCard: View { Text(message) .font(.caption) .foregroundStyle(.secondary) - } - .overlay(alignment: .topLeading) { - Text(title) - .font(.headline) - .padding(12) + .multilineTextAlignment(.center) } } } diff --git a/DevLogWidget/Heatmap/HeatmapWidget.swift b/DevLogWidget/Heatmap/HeatmapWidget.swift index 9da8f917..1faeec53 100644 --- a/DevLogWidget/Heatmap/HeatmapWidget.swift +++ b/DevLogWidget/Heatmap/HeatmapWidget.swift @@ -22,7 +22,7 @@ struct HeatmapWidget: Widget { .containerBackground(.fill.tertiary, for: .widget) } .configurationDisplayName("Heatmap") - .description("이번 달 활동 히트맵을 표시합니다.") - .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + .description("활동 히트맵을 표시합니다.") + .supportedFamilies([.systemSmall, .systemMedium]) } } diff --git a/DevLogWidget/Heatmap/HeatmapWidgetConfigurationIntent.swift b/DevLogWidget/Heatmap/HeatmapWidgetConfigurationIntent.swift index d6f3761f..159460e3 100644 --- a/DevLogWidget/Heatmap/HeatmapWidgetConfigurationIntent.swift +++ b/DevLogWidget/Heatmap/HeatmapWidgetConfigurationIntent.swift @@ -10,5 +10,5 @@ import WidgetKit struct HeatmapWidgetConfigurationIntent: WidgetConfigurationIntent { static var title: LocalizedStringResource = "Heatmap" - static var description = IntentDescription("이번 달 활동 히트맵을 표시합니다.") + static var description = IntentDescription("활동 히트맵을 표시합니다.") } diff --git a/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift b/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift index 502230c6..97a0d08f 100644 --- a/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift +++ b/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift @@ -13,12 +13,7 @@ struct HeatmapWidgetEntryView: View { @Environment(\.widgetFamily) private var widgetFamily var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text("이번 달 히트맵") - .font(.headline) - - Spacer() - + Group { if let snapshot = entry.snapshot { content(snapshot) } else { @@ -30,36 +25,73 @@ struct HeatmapWidgetEntryView: View { @ViewBuilder private func content(_ snapshot: HeatmapWidgetSnapshot) -> some View { - if widgetFamily == .systemSmall { + switch widgetFamily { + case .systemSmall: VStack(alignment: .leading, spacing: 4) { - Text("\(snapshot.maxCount)") - .font(.title) - .bold() - Text("이번 달 최대 활동 수") - .font(.caption) - .foregroundStyle(.secondary) + header(title: "이번 달 히트맵") + WidgetHeatmapGrid( + months: currentMonths(from: snapshot), + selectedActivityKindRawValues: snapshot.selectedActivityKindRawValues, + maxCount: snapshot.maxCount, + showsMonthTitles: false + ) + } + case .systemMedium: + VStack(alignment: .leading, spacing: 8) { + header(title: "이번 분기 히트맵") + WidgetHeatmapGrid( + months: snapshot.months, + selectedActivityKindRawValues: snapshot.selectedActivityKindRawValues, + maxCount: snapshot.maxCount, + showsMonthTitles: true + ) } - } else { - WidgetPlaceholderCard( - title: "이번 달 히트맵", - message: "저장된 주차 \(snapshot.weeks.count)개" - ) - .frame(maxWidth: .infinity) + default: + EmptyView() } } @ViewBuilder private var emptyState: some View { - if widgetFamily == .systemSmall { - Text("앱을 열어\n히트맵을 준비하세요") - .font(.caption) - .foregroundStyle(.secondary) - } else { - WidgetPlaceholderCard( - title: "이번 달 히트맵", - message: "데이터 연결 전" - ) - .frame(maxWidth: .infinity) + switch widgetFamily { + case .systemSmall: + VStack(alignment: .leading, spacing: 8) { + Text("이번 달 히트맵") + .font(.headline) + WidgetHeatmapPlaceholderGrid( + weekCounts: [5], + showsMonthTitles: false + ) + } + case .systemMedium: + VStack(alignment: .leading, spacing: 8) { + header(title: "이번 분기 히트맵") + WidgetHeatmapPlaceholderGrid( + weekCounts: [5, 5, 5], + showsMonthTitles: true + ) + } + default: + EmptyView() + } + } + + private func header(title: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(title) + .font(.headline) + .lineLimit(1) + Spacer(minLength: 0) } } + + private func currentMonths(from snapshot: HeatmapWidgetSnapshot) -> [WidgetHeatmapMonthSnapshot] { + if let currentMonth = snapshot.months.first(where: { + Calendar.current.isDate($0.monthStart, equalTo: snapshot.generatedAt, toGranularity: .month) + }) { + return [currentMonth] + } + + return Array(snapshot.months.prefix(1)) + } } diff --git a/DevLogWidget/Heatmap/HeatmapWidgetProvider.swift b/DevLogWidget/Heatmap/HeatmapWidgetProvider.swift index 5c4ab2b2..962017b1 100644 --- a/DevLogWidget/Heatmap/HeatmapWidgetProvider.swift +++ b/DevLogWidget/Heatmap/HeatmapWidgetProvider.swift @@ -25,7 +25,7 @@ struct HeatmapWidgetProvider: AppIntentTimelineProvider { ) async -> HeatmapWidgetEntry { let snapshot = try? store.loadHeatmapSnapshot() return .init( - date: snapshot?.generatedAt ?? .now, + date: .now, snapshot: snapshot ) } @@ -39,7 +39,7 @@ struct HeatmapWidgetProvider: AppIntentTimelineProvider { let snapshot = try? store.loadHeatmapSnapshot() let entries: [HeatmapWidgetEntry] = [ .init( - date: snapshot?.generatedAt ?? .now, + date: .now, snapshot: snapshot ) ] diff --git a/DevLogWidget/Heatmap/HeatmapWidgetSnapshot.swift b/DevLogWidget/Heatmap/HeatmapWidgetSnapshot.swift index 19429451..64eb37f8 100644 --- a/DevLogWidget/Heatmap/HeatmapWidgetSnapshot.swift +++ b/DevLogWidget/Heatmap/HeatmapWidgetSnapshot.swift @@ -9,9 +9,47 @@ import Foundation struct HeatmapWidgetSnapshot: Decodable, Equatable { let generatedAt: Date - let monthStart: Date + let quarterStart: Date let selectedActivityKindRawValues: [String] let maxCount: Int + let months: [WidgetHeatmapMonthSnapshot] + + private enum CodingKeys: String, CodingKey { + case generatedAt + case quarterStart + case monthStart + case selectedActivityKindRawValues + case maxCount + case months + case weeks + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + generatedAt = try container.decode(Date.self, forKey: .generatedAt) + selectedActivityKindRawValues = try container.decode([String].self, forKey: .selectedActivityKindRawValues) + maxCount = try container.decode(Int.self, forKey: .maxCount) + + if let quarterStart = try container.decodeIfPresent(Date.self, forKey: .quarterStart), + let months = try container.decodeIfPresent([WidgetHeatmapMonthSnapshot].self, forKey: .months) { + self.quarterStart = quarterStart + self.months = months + } else { + let monthStart = try container.decode(Date.self, forKey: .monthStart) + let weeks = try container.decode([WidgetHeatmapWeekSnapshot].self, forKey: .weeks) + self.quarterStart = monthStart + self.months = [ + WidgetHeatmapMonthSnapshot( + monthStart: monthStart, + weeks: weeks + ) + ] + } + } +} + +struct WidgetHeatmapMonthSnapshot: Decodable, Equatable { + let monthStart: Date let weeks: [WidgetHeatmapWeekSnapshot] } diff --git a/DevLogWidget/Heatmap/WidgetHeatmapGrid.swift b/DevLogWidget/Heatmap/WidgetHeatmapGrid.swift new file mode 100644 index 00000000..79e76f33 --- /dev/null +++ b/DevLogWidget/Heatmap/WidgetHeatmapGrid.swift @@ -0,0 +1,230 @@ +// +// WidgetHeatmapGrid.swift +// DevLogWidget +// +// Created by opfic on 4/28/26. +// + +import SwiftUI + +struct WidgetHeatmapGrid: View { + let months: [WidgetHeatmapMonthSnapshot] + let selectedActivityKindRawValues: [String] + let maxCount: Int + let showsMonthTitles: Bool + + var body: some View { + GeometryReader { proxy in + let layout = WidgetHeatmapLayout( + availableWidth: proxy.size.width, + availableHeight: proxy.size.height, + weekCounts: months.map(\.weeks.count), + showsMonthTitles: showsMonthTitles + ) + + HStack(alignment: .top, spacing: layout.weekdayLabelSpacing) { + WidgetHeatmapWeekdayLabels(layout: layout) + + HStack(alignment: .top, spacing: layout.monthSpacing) { + ForEach(months, id: \.monthStart) { month in + WidgetHeatmapMonthGrid( + month: month, + layout: layout, + selectedActivityKindRawValues: selectedActivityKindRawValues, + maxCount: maxCount, + showsMonthTitle: showsMonthTitles + ) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } +} + +struct WidgetHeatmapPlaceholderGrid: View { + let weekCounts: [Int] + let showsMonthTitles: Bool + + var body: some View { + GeometryReader { proxy in + let layout = WidgetHeatmapLayout( + availableWidth: proxy.size.width, + availableHeight: proxy.size.height, + weekCounts: weekCounts, + showsMonthTitles: showsMonthTitles + ) + + HStack(alignment: .top, spacing: layout.monthSpacing) { + ForEach(Array(weekCounts.enumerated()), id: \.offset) { _, weekCount in + WidgetHeatmapPlaceholderMonthGrid( + weekCount: weekCount, + layout: layout, + showsMonthTitle: showsMonthTitles + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } +} + +private struct WidgetHeatmapWeekdayLabels: View { + let layout: WidgetHeatmapLayout + private let orderedWeekdays = Array(1...7) + private let weekdayLabels = [ + 2: "월", + 4: "수", + 6: "금" + ] + + var body: some View { + VStack(alignment: .leading, spacing: layout.cellSpacing) { + ForEach(orderedWeekdays, id: \.self) { weekday in + weekdayLabel(for: weekday) + } + } + .padding(.top, layout.weekdayTopPadding) + } + + @ViewBuilder + private func weekdayLabel(for weekday: Int) -> some View { + if let label = weekdayLabels[weekday] { + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + .frame( + width: layout.weekdayLabelWidth, + height: layout.cellSize, + alignment: .leading + ) + } else { + Color.clear + .frame( + width: layout.weekdayLabelWidth, + height: layout.cellSize + ) + } + } +} + +private struct WidgetHeatmapMonthGrid: View { + let month: WidgetHeatmapMonthSnapshot + let layout: WidgetHeatmapLayout + let selectedActivityKindRawValues: [String] + let maxCount: Int + let showsMonthTitle: Bool + private let orderedWeekdays = Array(1...7) + + var body: some View { + VStack(alignment: .leading, spacing: layout.monthTitleSpacing) { + if showsMonthTitle { + Text(month.monthStart.formatted(.dateTime.month(.abbreviated))) + .frame(height: layout.cellSize) + .font(.caption) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: layout.cellSpacing) { + ForEach(orderedWeekdays, id: \.self) { weekday in + HStack(spacing: layout.cellSpacing) { + ForEach(month.weeks, id: \.id) { week in + let day = week.days.first { + Calendar.current.component(.weekday, from: $0.date) == weekday + } + + RoundedRectangle(cornerRadius: layout.cellCornerRadius) + .fill(fillColor(for: day)) + .frame(width: layout.cellSize, height: layout.cellSize) + } + } + } + } + } + } + + private func fillColor(for day: WidgetHeatmapDaySnapshot?) -> Color { + guard let day, day.isVisible else { return .clear } + + let count = dayCount(for: day) + if count == 0 { + return Color(.systemGray5) + } + + return Color.blue.opacity(opacity(for: count, max: maxCount)) + } + + private func dayCount(for day: WidgetHeatmapDaySnapshot) -> Int { + let selectedActivityKindRawValues = Set(selectedActivityKindRawValues) + var value = 0 + + if selectedActivityKindRawValues.contains(WidgetHeatmapActivityKind.created.rawValue) { + value += day.createdCount + } + + if selectedActivityKindRawValues.contains(WidgetHeatmapActivityKind.completed.rawValue) { + value += day.completedCount + } + + if selectedActivityKindRawValues.contains(WidgetHeatmapActivityKind.deleted.rawValue) { + value += day.deletedCount + } + + return value + } + + private func opacity(for count: Int, max: Int) -> Double { + guard 0 < count && 0 < max else { return 0 } + let ratio = Double(count) / Double(max) + return ceil(ratio * 10) / 10 + } +} + +private enum WidgetHeatmapActivityKind: String { + case created + case completed + case deleted +} + +private struct WidgetHeatmapPlaceholderMonthGrid: View { + let weekCount: Int + let layout: WidgetHeatmapLayout + let showsMonthTitle: Bool + private let orderedWeekdays = Array(1...7) + + var body: some View { + VStack(alignment: .leading, spacing: layout.monthTitleSpacing) { + if showsMonthTitle { + RoundedRectangle(cornerRadius: 3) + .fill(Color.secondary.opacity(0.18)) + .frame(width: layout.cellSize * 3, height: 8) + .frame(height: layout.cellSize, alignment: .leading) + } + + VStack(alignment: .leading, spacing: layout.cellSpacing) { + ForEach(orderedWeekdays, id: \.self) { weekday in + HStack(spacing: layout.cellSpacing) { + ForEach(0.. Double { + switch (weekday + weekIndex) % 4 { + case 0: + return 1 / 8 + case 1: + return 1 / 5 + case 2: + return 1 / 4 + default: + return 3 / 20 + } + } +} diff --git a/DevLogWidget/Heatmap/WidgetHeatmapLayout.swift b/DevLogWidget/Heatmap/WidgetHeatmapLayout.swift new file mode 100644 index 00000000..f5eff1e8 --- /dev/null +++ b/DevLogWidget/Heatmap/WidgetHeatmapLayout.swift @@ -0,0 +1,130 @@ +// +// WidgetHeatmapLayout.swift +// DevLogWidget +// +// Created by opfic on 4/28/26. +// + +import SwiftUI + +struct WidgetHeatmapLayout { + let cellSize: CGFloat + let cellSpacing: CGFloat + let monthSpacing: CGFloat + let monthTitleSpacing: CGFloat + let weekdayLabelSpacing: CGFloat = Self.baseWeekdayLabelSpacing + let weekdayLabelWidth: CGFloat = Self.baseWeekdayLabelWidth + let showsMonthTitles: Bool + + init( + availableWidth: CGFloat, + availableHeight: CGFloat, + weekCounts: [Int], + showsMonthTitles: Bool + ) { + self.showsMonthTitles = showsMonthTitles + self.monthTitleSpacing = Self.resolvedMonthTitleSpacing(showsMonthTitles: showsMonthTitles) + cellSize = Self.cellSize( + availableHeight: availableHeight, + showsMonthTitles: showsMonthTitles + ) + cellSpacing = Self.baseCellSpacing + monthSpacing = Self.monthSpacing( + availableWidth: availableWidth, + cellSize: cellSize, + weekCounts: weekCounts, + showsMonthTitles: showsMonthTitles + ) + } + + var weekdayTopPadding: CGFloat { + showsMonthTitles ? cellSize + monthTitleSpacing : 0 + } + + var cellCornerRadius: CGFloat { + max(2, cellSize * 0.2) + } + + private static let baseCellSpacing: CGFloat = 3 + private static let baseMonthSpacing: CGFloat = 10 + private static let maxMonthSpacing: CGFloat = 26 + private static let baseMonthTitleSpacing: CGFloat = 4 + private static let baseWeekdayLabelSpacing: CGFloat = 5 + private static let baseWeekdayLabelWidth: CGFloat = 14 + + private static func resolvedMonthTitleSpacing(showsMonthTitles: Bool) -> CGFloat { + // 월 제목을 표시하는 Medium에서만 제목과 셀 사이 간격을 확보한다. + showsMonthTitles ? baseMonthTitleSpacing : 0 + } + + private static func sanitizedWeekCounts(_ weekCounts: [Int]) -> [Int] { + // 빈 월이 있어도 0개 주차가 전체 컬럼 계산에 섞이지 않도록 제거한다. + weekCounts.filter { 0 < $0 } + } + + private static func totalColumns(in weekCounts: [Int]) -> Int { + // 모든 월의 주차 수를 더해 실제 셀 컬럼 수를 구한다. + max(weekCounts.reduce(0, +), 1) + } + + private static func totalColumnSpacings(in weekCounts: [Int]) -> Int { + // 각 월 내부에서 주차 컬럼 사이에 들어가는 spacing 개수를 합산한다. + weekCounts.reduce(0) { partialResult, count in + partialResult + max(count - 1, 0) + } + } + + private static func monthSpacingCount(in weekCounts: [Int]) -> Int { + // 월과 월 사이 간격 개수는 표시되는 월 개수보다 하나 적다. + max(weekCounts.count - 1, 0) + } + + private static func cellSize( + availableHeight: CGFloat, + showsMonthTitles: Bool + ) -> CGFloat { + // 히트맵은 세로 7일 행이 고정이므로 높이를 기준으로 셀 크기를 결정한다. + // Medium은 월 제목 1행을 같은 셀 높이로 추가해 총 8행 기준으로 계산한다. + let verticalCellCount = showsMonthTitles ? 8 : 7 + // 날짜 셀 7행 사이의 세로 spacing 6개와 월 제목 spacing을 높이에서 제외한다. + let verticalFixedHeight = resolvedMonthTitleSpacing(showsMonthTitles: showsMonthTitles) + + baseCellSpacing * CGFloat(max(7 - 1, 0)) + return max(0, availableHeight - verticalFixedHeight) / CGFloat(verticalCellCount) + } + + private static func extraWidth( + availableWidth: CGFloat, + cellSize: CGFloat, + weekCounts: [Int] + ) -> CGFloat { + // 셀 크기는 높이 기준으로 고정하고, 남는 가로폭만 월 간격 계산에 사용한다. + let sanitizedWeekCounts = sanitizedWeekCounts(weekCounts) + // 요일 라벨 영역, 전체 셀 컬럼, 월 내부 주차 spacing을 더해 기본 너비를 구한다. + let contentWidth = baseWeekdayLabelWidth + + baseWeekdayLabelSpacing + + cellSize * CGFloat(totalColumns(in: sanitizedWeekCounts)) + + baseCellSpacing * CGFloat(totalColumnSpacings(in: sanitizedWeekCounts)) + // 기본 너비보다 위젯이 넓을 때만 월 간격에 분배할 여유 폭이 생긴다. + return max(0, availableWidth - contentWidth) + } + + private static func monthSpacing( + availableWidth: CGFloat, + cellSize: CGFloat, + weekCounts: [Int], + showsMonthTitles: Bool + ) -> CGFloat { + // Medium의 3개월 사이 간격만 확장하고, 과한 벌어짐은 상한으로 제한한다. + let sanitizedWeekCounts = sanitizedWeekCounts(weekCounts) + let monthSpacingCount = monthSpacingCount(in: sanitizedWeekCounts) + // Small은 한 달만 표시하므로 월 간격이 필요 없다. + guard showsMonthTitles && 0 < monthSpacingCount else { return 0 } + let extraWidth = extraWidth( + availableWidth: availableWidth, + cellSize: cellSize, + weekCounts: sanitizedWeekCounts + ) + // 남는 폭을 월 사이 간격에 균등 분배하되 기본값과 상한 사이로 제한한다. + return min(max(extraWidth / CGFloat(monthSpacingCount), baseMonthSpacing), maxMonthSpacing) + } +} diff --git a/DevLogWidget/Today/TodayTodoWidget.swift b/DevLogWidget/Today/TodayTodoWidget.swift index 4de1f71e..d4df2f96 100644 --- a/DevLogWidget/Today/TodayTodoWidget.swift +++ b/DevLogWidget/Today/TodayTodoWidget.swift @@ -21,8 +21,8 @@ struct TodayTodoWidget: Widget { TodayTodoWidgetEntryView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } - .configurationDisplayName("Today Todo") .description("오늘 기준 Todo 목록을 표시합니다.") - .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + .configurationDisplayName("Today") + .supportedFamilies([.systemSmall, .systemMedium]) } } diff --git a/DevLogWidget/Today/TodayTodoWidgetConfigurationIntent.swift b/DevLogWidget/Today/TodayTodoWidgetConfigurationIntent.swift index 3320cfba..649d085e 100644 --- a/DevLogWidget/Today/TodayTodoWidgetConfigurationIntent.swift +++ b/DevLogWidget/Today/TodayTodoWidgetConfigurationIntent.swift @@ -9,6 +9,6 @@ import AppIntents import WidgetKit struct TodayTodoWidgetConfigurationIntent: WidgetConfigurationIntent { - static var title: LocalizedStringResource = "Today Todo" + static var title: LocalizedStringResource = "Today" static var description = IntentDescription("오늘 기준 Todo 목록을 표시합니다.") } diff --git a/DevLogWidget/Today/TodayTodoWidgetEntryView.swift b/DevLogWidget/Today/TodayTodoWidgetEntryView.swift index 386cae5e..9e4a949b 100644 --- a/DevLogWidget/Today/TodayTodoWidgetEntryView.swift +++ b/DevLogWidget/Today/TodayTodoWidgetEntryView.swift @@ -13,8 +13,8 @@ struct TodayTodoWidgetEntryView: View { @Environment(\.widgetFamily) private var widgetFamily var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Today Todo") + VStack(alignment: .leading) { + Text("Today") .font(.headline) Spacer() @@ -24,6 +24,8 @@ struct TodayTodoWidgetEntryView: View { } else { emptyState } + + Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } @@ -35,17 +37,31 @@ struct TodayTodoWidgetEntryView: View { VStack(alignment: .leading, spacing: 4) { Text("\(snapshot.totalCount)") .font(.system(size: 28, weight: .bold)) - Text(topItemTitle(from: snapshot)) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) + + if let item = displayedItems(from: snapshot).first { + todoRow(item) + } else { + Text("오늘은 할 일이 없어요.\n잠시 휴식을 취해보세요!") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + case .systemMedium: + let items = displayedItems(from: snapshot) + VStack(alignment: .leading, spacing: 6) { + if items.isEmpty { + Text("오늘은 할 일이 없어요.\n잠시 휴식을 취해보세요!") + .multilineTextAlignment(.center) + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(items, id: \.id) { item in + todoRow(item, lineLimit: 1) + } + } } - case .systemMedium, .systemLarge: - WidgetPlaceholderCard( - title: "Today Todo", - message: "저장된 할 일 \(snapshot.totalCount)개" - ) - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, alignment: .leading) default: EmptyView() } @@ -55,24 +71,82 @@ struct TodayTodoWidgetEntryView: View { private var emptyState: some View { switch widgetFamily { case .systemSmall: - Text("앱을 열어\nToday 위젯을 준비하세요") - .font(.caption) - .foregroundStyle(.secondary) - case .systemMedium, .systemLarge: - WidgetPlaceholderCard( - title: "Today Todo", - message: "데이터 연결 전" - ) - .frame(maxWidth: .infinity) + GeometryReader { proxy in + VStack(alignment: .leading, spacing: 4) { + placeholderTodoCount() + placeholderTodoRow(width: placeholderTodoRowWidth(in: proxy.size.width, at: 0)) + } + } + .frame(height: 56) + .frame(maxWidth: .infinity, alignment: .leading) + case .systemMedium: + GeometryReader { proxy in + VStack(alignment: .leading, spacing: 6) { + ForEach(0..<3, id: \.self) { index in + placeholderTodoRow(width: placeholderTodoRowWidth(in: proxy.size.width, at: index)) + } + } + } + .frame(height: 56) + .frame(maxWidth: .infinity, alignment: .leading) default: EmptyView() } } - private func topItemTitle(from snapshot: TodayWidgetSnapshot) -> String { - snapshot.sections + private func displayedItems(from snapshot: TodayWidgetSnapshot) -> [WidgetTodoSnapshotItem] { + Array(snapshot + .sections .flatMap(\.items) - .first? - .title ?? "할 일이 없습니다" + .prefix(3)) + } + + private func todoRow(_ item: WidgetTodoSnapshotItem, lineLimit: Int? = nil) -> some View { + HStack(spacing: 6) { + Text("#\(item.number)") + .font(.caption2) + .foregroundStyle(.secondary) + + if item.isPinned { + Image(systemName: "star.fill") + .font(.caption2) + .foregroundStyle(.orange) + } + + Text(item.title) + .font(.caption) + .lineLimit(lineLimit) + } + } + + private func placeholderTodoCount() -> some View { + RoundedRectangle(cornerRadius: 4) + .fill(Color.secondary.opacity(0.18)) + .frame(width: 22, height: 28) + } + + private func placeholderTodoRow(width: CGFloat) -> some View { + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 3) + .fill(Color.secondary.opacity(0.18)) + .frame(width: 22, height: 8) + + RoundedRectangle(cornerRadius: 3) + .fill(Color.secondary.opacity(0.18)) + .frame(width: width, height: 8) + } + } + + private func placeholderTodoRowWidth(in availableWidth: CGFloat, at index: Int) -> CGFloat { + let titleAreaWidth = max(availableWidth - 28, 0) + + switch index { + case 0: + return titleAreaWidth * 2 / 3 + case 1: + return titleAreaWidth / 2 + default: + return titleAreaWidth * 3 / 5 + } } } diff --git a/DevLogWidget/Today/TodayTodoWidgetProvider.swift b/DevLogWidget/Today/TodayTodoWidgetProvider.swift index cc9325f0..ac8884cf 100644 --- a/DevLogWidget/Today/TodayTodoWidgetProvider.swift +++ b/DevLogWidget/Today/TodayTodoWidgetProvider.swift @@ -25,7 +25,7 @@ struct TodayTodoWidgetProvider: AppIntentTimelineProvider { ) async -> TodayTodoWidgetEntry { let snapshot = try? store.loadTodaySnapshot() return .init( - date: snapshot?.generatedAt ?? .now, + date: .now, snapshot: snapshot ) } @@ -39,7 +39,7 @@ struct TodayTodoWidgetProvider: AppIntentTimelineProvider { let snapshot = try? store.loadTodaySnapshot() let entries: [TodayTodoWidgetEntry] = [ .init( - date: snapshot?.generatedAt ?? .now, + date: .now, snapshot: snapshot ) ] diff --git a/DevLog_Unit/Widget/HeatmapWidgetSnapshotFactoryTests.swift b/DevLog_Unit/Widget/HeatmapWidgetSnapshotFactoryTests.swift index 544c0298..0fd198e3 100644 --- a/DevLog_Unit/Widget/HeatmapWidgetSnapshotFactoryTests.swift +++ b/DevLog_Unit/Widget/HeatmapWidgetSnapshotFactoryTests.swift @@ -10,10 +10,12 @@ import Testing @testable import DevLog struct HeatmapWidgetSnapshotFactoryTests { - @Test("Heatmap 위젯 스냅샷은 이번 달 기준 주차와 일별 count를 만든다") - func heatmap_위젯_스냅샷은_이번_달_기준_주차와_일별_count를_만든다() throws { + @Test("Heatmap 위젯 스냅샷은 이번 분기 기준 월과 일별 count를 만든다") + func heatmap_위젯_스냅샷은_이번_분기_기준_월과_일별_count를_만든다() throws { let calendar = Calendar(identifier: .gregorian) - let monthStart = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 1))) + let quarterStart = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 1))) + let mayStart = try #require(calendar.date(from: DateComponents(year: 2026, month: 5, day: 1))) + let juneStart = try #require(calendar.date(from: DateComponents(year: 2026, month: 6, day: 1))) let aprilThirdDate = calendar.date(from: DateComponents(year: 2026, month: 4, day: 3))! let mayFirstDate = calendar.date(from: DateComponents(year: 2026, month: 5, day: 1))! let aprilFifteenthDate = calendar.date(from: DateComponents(year: 2026, month: 4, day: 15))! @@ -33,32 +35,38 @@ struct HeatmapWidgetSnapshotFactoryTests { completedTodos: [ makeTodo( id: "todo-completed-apr-03", - createdAt: monthStart, + createdAt: quarterStart, completedAt: aprilThirdDate ), makeTodo( id: "todo-completed-may-01", - createdAt: monthStart, + createdAt: quarterStart, completedAt: mayFirstDate ) ], deletedTodos: [ makeTodo( id: "todo-deleted-apr-15", - createdAt: monthStart, + createdAt: quarterStart, deletedAt: aprilFifteenthDate ) ], selectedActivityKinds: [.created, .completed], - monthStart: monthStart, - now: monthStart + quarterStart: quarterStart, + now: quarterStart ) - #expect(snapshot.monthStart == monthStart) + #expect(snapshot.quarterStart == quarterStart) #expect(snapshot.selectedActivityKindRawValues == ["created", "completed"]) #expect(snapshot.maxCount == 2) - #expect(snapshot.weeks.count == 5) - #expect(snapshot.weeks.flatMap(\.days).filter(\.isVisible).count == 30) + #expect(snapshot.months.count == 3) + #expect(snapshot.months.map(\.monthStart) == [ + quarterStart, + mayStart, + juneStart + ]) + #expect(snapshot.months[0].weeks.count == 5) + #expect(snapshot.months.flatMap(\.weeks).flatMap(\.days).filter(\.isVisible).count == 91) let aprilThird = try #require(day(for: DateComponents(year: 2026, month: 4, day: 3), in: snapshot, calendar: calendar)) #expect(aprilThird.createdCount == 1) @@ -70,12 +78,15 @@ struct HeatmapWidgetSnapshotFactoryTests { #expect(aprilFifteenth.createdCount == 0) #expect(aprilFifteenth.completedCount == 0) #expect(aprilFifteenth.deletedCount == 1) + + let mayFirst = try #require(day(for: DateComponents(year: 2026, month: 5, day: 1), in: snapshot, calendar: calendar)) + #expect(mayFirst.completedCount == 1) } @Test("Heatmap 위젯 스냅샷 maxCount는 선택된 activity kind만 기준으로 계산한다") func heatmap_위젯_스냅샷_maxCount는_선택된_activity_kind만_기준으로_계산한다() throws { let calendar = Calendar(identifier: .gregorian) - let monthStart = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 1))) + let quarterStart = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 1))) let targetDate = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 10))) let factory = HeatmapWidgetSnapshotFactory(calendar: calendar) @@ -88,23 +99,23 @@ struct HeatmapWidgetSnapshotFactoryTests { deletedTodos: [ makeTodo( id: "deleted-1", - createdAt: monthStart, + createdAt: quarterStart, deletedAt: targetDate ), makeTodo( id: "deleted-2", - createdAt: monthStart, + createdAt: quarterStart, deletedAt: targetDate ), makeTodo( id: "deleted-3", - createdAt: monthStart, + createdAt: quarterStart, deletedAt: targetDate ) ], selectedActivityKinds: [.deleted], - monthStart: monthStart, - now: monthStart + quarterStart: quarterStart, + now: quarterStart ) #expect(snapshot.selectedActivityKindRawValues == ["deleted"]) @@ -123,7 +134,8 @@ struct HeatmapWidgetSnapshotFactoryTests { guard let date = calendar.date(from: components) else { return nil } let targetDate = calendar.startOfDay(for: date) - return snapshot.weeks + return snapshot.months + .flatMap(\.weeks) .flatMap(\.days) .first { day in calendar.isDate(day.date, inSameDayAs: targetDate) diff --git a/docs/DevLog.drawio b/docs/DevLog.drawio new file mode 100644 index 00000000..2055c63e --- /dev/null +++ b/docs/DevLog.drawio @@ -0,0 +1,719 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +