From 99a797dd1f341fabd004b3959a2427266c073a37 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 24 Apr 2026 11:06:45 +0900 Subject: [PATCH 01/13] =?UTF-8?q?docs:=20=EA=B0=81=EC=A2=85=20=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DevLog.drawio | 719 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 719 insertions(+) create mode 100644 docs/DevLog.drawio 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 6bbeba3ac040988913dcfad8238378671b0a10c3 Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 28 Apr 2026 18:28:18 +0900 Subject: [PATCH 02/13] =?UTF-8?q?refactor:=20=EB=8B=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=98=95=ED=83=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Heatmap/HeatmapWidgetSnapshot.swift | 7 +- .../HeatmapWidgetSnapshotFactory.swift | 90 ++++++++++++------- .../HeatmapWidgetSyncCoordinator.swift | 31 +++---- .../HeatmapWidgetSnapshotFactoryTests.swift | 48 ++++++---- 4 files changed, 110 insertions(+), 66 deletions(-) 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/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) From 92c9e40b441797063ff240515ce19a84933db5ce Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 28 Apr 2026 18:29:03 +0900 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20=ED=9E=88=ED=8A=B8=EB=A7=B5=20UI?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLogWidget/Heatmap/HeatmapWidget.swift | 4 +- .../HeatmapWidgetConfigurationIntent.swift | 2 +- .../Heatmap/HeatmapWidgetEntryView.swift | 79 ++++++--- .../Heatmap/HeatmapWidgetSnapshot.swift | 40 ++++- DevLogWidget/Heatmap/WidgetHeatmapGrid.swift | 153 ++++++++++++++++++ .../Heatmap/WidgetHeatmapLayout.swift | 130 +++++++++++++++ 6 files changed, 379 insertions(+), 29 deletions(-) create mode 100644 DevLogWidget/Heatmap/WidgetHeatmapGrid.swift create mode 100644 DevLogWidget/Heatmap/WidgetHeatmapLayout.swift 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..0867a8fc 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,70 @@ 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 + ) } - } else { - WidgetPlaceholderCard( - title: "이번 달 히트맵", - message: "저장된 주차 \(snapshot.weeks.count)개" - ) - .frame(maxWidth: .infinity) + case .systemMedium: + VStack(alignment: .leading, spacing: 8) { + header(title: "이번 분기 히트맵") + WidgetHeatmapGrid( + months: snapshot.months, + selectedActivityKindRawValues: snapshot.selectedActivityKindRawValues, + maxCount: snapshot.maxCount, + showsMonthTitles: true + ) + } + default: + EmptyView() } } @ViewBuilder private var emptyState: some View { - if widgetFamily == .systemSmall { - Text("앱을 열어\n히트맵을 준비하세요") - .font(.caption) - .foregroundStyle(.secondary) - } else { + switch widgetFamily { + case .systemSmall: + VStack(alignment: .leading, spacing: 8) { + Text("이번 달 히트맵") + .font(.headline) + Text("앱을 열어\n히트맵을 준비하세요") + .font(.caption) + .foregroundStyle(.secondary) + } + case .systemMedium: WidgetPlaceholderCard( - title: "이번 달 히트맵", + title: "이번 분기 히트맵", message: "데이터 연결 전" ) .frame(maxWidth: .infinity) + 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/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..8439b2b2 --- /dev/null +++ b/DevLogWidget/Heatmap/WidgetHeatmapGrid.swift @@ -0,0 +1,153 @@ +// +// 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 + private let orderedWeekdays = Array(1...7) + + 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) { + weekdayLabel(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) + } + } + + @ViewBuilder + private func weekdayLabel(_ layout: WidgetHeatmapLayout) -> some View { + let weekdayLabels = [ + 2: "월", + 4: "수", + 6: "금" + ] + + VStack(alignment: .leading, spacing: layout.cellSpacing) { + ForEach(orderedWeekdays, id: \.self) { weekday in + 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 + ) + } + } + } + .padding(.top, layout.weekdayTopPadding) + } +} + +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 +} 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) + } +} From 5854eeb401157614d1b18cea94c22b7f41ba8e3d Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 28 Apr 2026 20:29:58 +0900 Subject: [PATCH 04/13] =?UTF-8?q?ui:=20=ED=83=80=EC=9D=B4=ED=8B=80?= =?UTF-8?q?=EC=97=90=EC=84=9C=20'Todo'=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLogWidget/Today/TodayTodoWidget.swift | 2 +- DevLogWidget/Today/TodayTodoWidgetConfigurationIntent.swift | 2 +- DevLogWidget/Today/TodayTodoWidgetEntryView.swift | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/DevLogWidget/Today/TodayTodoWidget.swift b/DevLogWidget/Today/TodayTodoWidget.swift index 4de1f71e..257b13fc 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 목록을 표시합니다.") + .configurationDisplayName("Today") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) } } 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..987362fe 100644 --- a/DevLogWidget/Today/TodayTodoWidgetEntryView.swift +++ b/DevLogWidget/Today/TodayTodoWidgetEntryView.swift @@ -14,7 +14,7 @@ struct TodayTodoWidgetEntryView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Text("Today Todo") + Text("Today") .font(.headline) Spacer() @@ -42,7 +42,7 @@ struct TodayTodoWidgetEntryView: View { } case .systemMedium, .systemLarge: WidgetPlaceholderCard( - title: "Today Todo", + title: "Today", message: "저장된 할 일 \(snapshot.totalCount)개" ) .frame(maxWidth: .infinity) @@ -60,7 +60,7 @@ struct TodayTodoWidgetEntryView: View { .foregroundStyle(.secondary) case .systemMedium, .systemLarge: WidgetPlaceholderCard( - title: "Today Todo", + title: "Today", message: "데이터 연결 전" ) .frame(maxWidth: .infinity) From de5aed11b9a4beaef5d2d0203369398498007aff Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 29 Apr 2026 10:39:38 +0900 Subject: [PATCH 05/13] =?UTF-8?q?ui:=20=ED=88=AC=EB=8D=B0=EC=9D=B4=20?= =?UTF-8?q?=EC=9C=84=EC=A0=AF=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Common/WidgetPlaceholderCard.swift | 7 +- .../Heatmap/HeatmapWidgetEntryView.swift | 7 +- .../Heatmap/HeatmapWidgetProvider.swift | 4 +- DevLogWidget/Today/TodayTodoWidget.swift | 2 +- .../Today/TodayTodoWidgetEntryView.swift | 65 ++++++++++++++----- 5 files changed, 53 insertions(+), 32 deletions(-) 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/HeatmapWidgetEntryView.swift b/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift index 0867a8fc..f4469746 100644 --- a/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift +++ b/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift @@ -63,11 +63,8 @@ struct HeatmapWidgetEntryView: View { .foregroundStyle(.secondary) } case .systemMedium: - WidgetPlaceholderCard( - title: "이번 분기 히트맵", - message: "데이터 연결 전" - ) - .frame(maxWidth: .infinity) + WidgetPlaceholderCard(message: "앱을 열어\n히트맵을 준비하세요") + .frame(maxWidth: .infinity) default: EmptyView() } 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/Today/TodayTodoWidget.swift b/DevLogWidget/Today/TodayTodoWidget.swift index 257b13fc..d4df2f96 100644 --- a/DevLogWidget/Today/TodayTodoWidget.swift +++ b/DevLogWidget/Today/TodayTodoWidget.swift @@ -23,6 +23,6 @@ struct TodayTodoWidget: Widget { } .description("오늘 기준 Todo 목록을 표시합니다.") .configurationDisplayName("Today") - .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + .supportedFamilies([.systemSmall, .systemMedium]) } } diff --git a/DevLogWidget/Today/TodayTodoWidgetEntryView.swift b/DevLogWidget/Today/TodayTodoWidgetEntryView.swift index 987362fe..7820c58e 100644 --- a/DevLogWidget/Today/TodayTodoWidgetEntryView.swift +++ b/DevLogWidget/Today/TodayTodoWidgetEntryView.swift @@ -13,7 +13,7 @@ struct TodayTodoWidgetEntryView: View { @Environment(\.widgetFamily) private var widgetFamily var body: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading) { Text("Today") .font(.headline) @@ -24,6 +24,8 @@ struct TodayTodoWidgetEntryView: View { } else { emptyState } + + Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } @@ -40,12 +42,21 @@ struct TodayTodoWidgetEntryView: View { .foregroundStyle(.secondary) .lineLimit(2) } - case .systemMedium, .systemLarge: - WidgetPlaceholderCard( - title: "Today", - message: "저장된 할 일 \(snapshot.totalCount)개" - ) - .frame(maxWidth: .infinity) + 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) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) default: EmptyView() } @@ -54,16 +65,9 @@ struct TodayTodoWidgetEntryView: View { @ViewBuilder private var emptyState: some View { switch widgetFamily { - case .systemSmall: - Text("앱을 열어\nToday 위젯을 준비하세요") - .font(.caption) - .foregroundStyle(.secondary) - case .systemMedium, .systemLarge: - WidgetPlaceholderCard( - title: "Today", - message: "데이터 연결 전" - ) - .frame(maxWidth: .infinity) + case .systemSmall, .systemMedium: + WidgetPlaceholderCard(message: "앱을 열어\nToday 위젯을 준비하세요") + .frame(maxWidth: .infinity) default: EmptyView() } @@ -73,6 +77,31 @@ struct TodayTodoWidgetEntryView: View { snapshot.sections .flatMap(\.items) .first? - .title ?? "할 일이 없습니다" + .title ?? "오늘은 할 일이 없어요.\n잠시 휴식을 취해보세요!" + } + + private func displayedItems(from snapshot: TodayWidgetSnapshot) -> [WidgetTodoSnapshotItem] { + Array(snapshot + .sections + .flatMap(\.items) + .prefix(3)) + } + + private func todoRow(_ item: WidgetTodoSnapshotItem) -> 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(1) + } } } From 818da85dad6c44bc4729d1ff7c29b444b3ee3b42 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 29 Apr 2026 10:40:16 +0900 Subject: [PATCH 06/13] =?UTF-8?q?refactor:=20=EC=9C=84=EC=A0=AF=EC=97=90?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EA=B0=80=20=EB=8D=94=20=EB=B9=A8?= =?UTF-8?q?=EB=A6=AC=20=EB=9C=B0=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLogWidget/Today/TodayTodoWidgetProvider.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ) ] From 609f22e99f71ef6250d2f0adff2afa5e3837c2bb Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 29 Apr 2026 15:06:06 +0900 Subject: [PATCH 07/13] =?UTF-8?q?ui:=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=A5=BC=20=EB=B0=9B=EC=95=84=EC=98=A4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=95=98=EC=9D=84=20=EB=95=8C=EC=9D=98=20=EB=B7=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Heatmap/HeatmapWidgetEntryView.swift | 120 +++++++++++++++++- .../Today/TodayTodoWidgetEntryView.swift | 55 +++++++- 2 files changed, 167 insertions(+), 8 deletions(-) diff --git a/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift b/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift index f4469746..9d25e037 100644 --- a/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift +++ b/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift @@ -58,13 +58,27 @@ struct HeatmapWidgetEntryView: View { VStack(alignment: .leading, spacing: 8) { Text("이번 달 히트맵") .font(.headline) - Text("앱을 열어\n히트맵을 준비하세요") - .font(.caption) - .foregroundStyle(.secondary) + GeometryReader { proxy in + placeholderHeatmapGrid( + monthCount: 1, + availableSize: proxy.size, + showsMonthTitles: false + ) + } + .frame(height: 72) } case .systemMedium: - WidgetPlaceholderCard(message: "앱을 열어\n히트맵을 준비하세요") - .frame(maxWidth: .infinity) + VStack(alignment: .leading, spacing: 8) { + header(title: "이번 분기 히트맵") + GeometryReader { proxy in + placeholderHeatmapGrid( + monthCount: 3, + availableSize: proxy.size, + showsMonthTitles: true + ) + } + .frame(height: 80) + } default: EmptyView() } @@ -88,4 +102,100 @@ struct HeatmapWidgetEntryView: View { return Array(snapshot.months.prefix(1)) } + + private func placeholderHeatmapGrid( + monthCount: Int, + availableSize: CGSize, + showsMonthTitles: Bool + ) -> some View { + let resolvedMonthCount = max(monthCount, 1) + let columnCount = showsMonthTitles ? 5 : 6 + let monthSpacing = showsMonthTitles ? availableSize.width / 18 : 0 + let cellSpacing: CGFloat = 3 + let monthTitleHeight: CGFloat = showsMonthTitles ? 8 : 0 + let monthTitleSpacing: CGFloat = showsMonthTitles ? 6 : 0 + let totalMonthSpacing = monthSpacing * CGFloat(max(resolvedMonthCount - 1, 0)) + let monthWidth = max((availableSize.width - totalMonthSpacing) / CGFloat(resolvedMonthCount), 0) + let widthBasedCellSize = max( + (monthWidth - cellSpacing * CGFloat(max(columnCount - 1, 0))) / CGFloat(columnCount), + 0 + ) + let verticalFixedHeight = monthTitleHeight + + monthTitleSpacing + + cellSpacing * CGFloat(max(7 - 1, 0)) + let heightBasedCellSize = max((availableSize.height - verticalFixedHeight) / 7, 0) + let cellSize = min(widthBasedCellSize, heightBasedCellSize) + + return HStack(alignment: .top, spacing: monthSpacing) { + ForEach(0.. some View { + VStack(alignment: .leading, spacing: monthTitleSpacing) { + if showsMonthTitles { + RoundedRectangle(cornerRadius: 3) + .fill(Color.secondary.opacity(0.18)) + .frame(width: cellSize * 3, height: monthTitleHeight) + } + + HStack(alignment: .top, spacing: cellSpacing) { + ForEach(0.. some View { + let opacity = placeholderHeatmapCellOpacity(columnIndex: columnIndex, rowIndex: rowIndex) + + return RoundedRectangle(cornerRadius: max(2, size / 5)) + .fill(Color.secondary.opacity(opacity)) + .frame(width: size, height: size) + } + + private func placeholderHeatmapCellOpacity( + columnIndex: Int, + rowIndex: Int + ) -> Double { + switch (columnIndex + rowIndex) % 4 { + case 0: + return 1 / 8 + case 1: + return 1 / 5 + case 2: + return 1 / 4 + default: + return 3 / 20 + } + } } diff --git a/DevLogWidget/Today/TodayTodoWidgetEntryView.swift b/DevLogWidget/Today/TodayTodoWidgetEntryView.swift index 7820c58e..0849aa2e 100644 --- a/DevLogWidget/Today/TodayTodoWidgetEntryView.swift +++ b/DevLogWidget/Today/TodayTodoWidgetEntryView.swift @@ -65,9 +65,33 @@ struct TodayTodoWidgetEntryView: View { @ViewBuilder private var emptyState: some View { switch widgetFamily { - case .systemSmall, .systemMedium: - WidgetPlaceholderCard(message: "앱을 열어\nToday 위젯을 준비하세요") - .frame(maxWidth: .infinity) + case .systemSmall: + GeometryReader { proxy in + VStack(alignment: .leading, spacing: 8) { + Text("준비 중") + .font(.caption) + .foregroundStyle(.tertiary) + + placeholderTodoRow(width: placeholderTodoRowWidth(in: proxy.size.width, at: 0)) + placeholderTodoRow(width: placeholderTodoRowWidth(in: proxy.size.width, at: 1)) + } + } + .frame(height: 48) + .frame(maxWidth: .infinity, alignment: .leading) + case .systemMedium: + GeometryReader { proxy in + VStack(alignment: .leading, spacing: 6) { + Text("준비 중") + .font(.caption2) + .foregroundStyle(.tertiary) + + 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() } @@ -104,4 +128,29 @@ struct TodayTodoWidgetEntryView: View { .lineLimit(1) } } + + 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 + } + } } From 6495fcaed0ca63bc122be6825b53049bf2899b93 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 29 Apr 2026 15:58:07 +0900 Subject: [PATCH 08/13] =?UTF-8?q?ui:=20=ED=88=AC=EB=8D=B0=EC=9D=B4=20?= =?UTF-8?q?=EC=9C=84=EC=A0=AF=EC=9D=B4=20=EC=BB=B4=ED=8C=A9=ED=8A=B8?= =?UTF-8?q?=EC=9D=BC=20=EA=B2=BD=EC=9A=B0=20Todo=20=EB=B2=88=ED=98=B8?= =?UTF-8?q?=EC=99=80=20=EC=A4=91=EC=9A=94=20=ED=91=9C=EC=8B=9C=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20=EB=B3=B4=EC=9D=B4=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Today/TodayTodoWidgetEntryView.swift | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/DevLogWidget/Today/TodayTodoWidgetEntryView.swift b/DevLogWidget/Today/TodayTodoWidgetEntryView.swift index 0849aa2e..e62d796e 100644 --- a/DevLogWidget/Today/TodayTodoWidgetEntryView.swift +++ b/DevLogWidget/Today/TodayTodoWidgetEntryView.swift @@ -37,10 +37,15 @@ 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) @@ -52,7 +57,7 @@ struct TodayTodoWidgetEntryView: View { .foregroundStyle(.secondary) } else { ForEach(items, id: \.id) { item in - todoRow(item) + todoRow(item, lineLimit: 1) } } } @@ -97,13 +102,6 @@ struct TodayTodoWidgetEntryView: View { } } - private func topItemTitle(from snapshot: TodayWidgetSnapshot) -> String { - snapshot.sections - .flatMap(\.items) - .first? - .title ?? "오늘은 할 일이 없어요.\n잠시 휴식을 취해보세요!" - } - private func displayedItems(from snapshot: TodayWidgetSnapshot) -> [WidgetTodoSnapshotItem] { Array(snapshot .sections @@ -111,7 +109,7 @@ struct TodayTodoWidgetEntryView: View { .prefix(3)) } - private func todoRow(_ item: WidgetTodoSnapshotItem) -> some View { + private func todoRow(_ item: WidgetTodoSnapshotItem, lineLimit: Int? = nil) -> some View { HStack(spacing: 6) { Text("#\(item.number)") .font(.caption2) @@ -125,7 +123,7 @@ struct TodayTodoWidgetEntryView: View { Text(item.title) .font(.caption) - .lineLimit(1) + .lineLimit(lineLimit) } } From c1f6bb083448882e069d05d5b0616cf30bbd5afd Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 29 Apr 2026 16:08:03 +0900 Subject: [PATCH 09/13] =?UTF-8?q?ui:=20Todo=20=EC=9C=84=EC=A0=AF=EC=9D=98?= =?UTF-8?q?=20=ED=94=8C=EB=A0=88=EC=9D=B4=EC=8A=A4=ED=99=80=EB=8D=94?= =?UTF-8?q?=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20=EB=B3=B4=EC=97=AC=EC=A3=BC?= =?UTF-8?q?=EB=8A=94=20UI=EC=99=80=20=EB=B9=84=EC=8A=B7=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Today/TodayTodoWidgetEntryView.swift | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/DevLogWidget/Today/TodayTodoWidgetEntryView.swift b/DevLogWidget/Today/TodayTodoWidgetEntryView.swift index e62d796e..08bc5f0f 100644 --- a/DevLogWidget/Today/TodayTodoWidgetEntryView.swift +++ b/DevLogWidget/Today/TodayTodoWidgetEntryView.swift @@ -72,16 +72,12 @@ struct TodayTodoWidgetEntryView: View { switch widgetFamily { case .systemSmall: GeometryReader { proxy in - VStack(alignment: .leading, spacing: 8) { - Text("준비 중") - .font(.caption) - .foregroundStyle(.tertiary) - + VStack(alignment: .leading, spacing: 4) { + placeholderTodoCount() placeholderTodoRow(width: placeholderTodoRowWidth(in: proxy.size.width, at: 0)) - placeholderTodoRow(width: placeholderTodoRowWidth(in: proxy.size.width, at: 1)) } } - .frame(height: 48) + .frame(height: 56) .frame(maxWidth: .infinity, alignment: .leading) case .systemMedium: GeometryReader { proxy in @@ -127,6 +123,12 @@ struct TodayTodoWidgetEntryView: View { } } + 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) From c97285f340886787f44b63c095dcbaedd4d678de Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 29 Apr 2026 16:14:57 +0900 Subject: [PATCH 10/13] =?UTF-8?q?ui:=20=EC=A4=80=EB=B9=84=20=EC=A4=91=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLogWidget/Today/TodayTodoWidgetEntryView.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/DevLogWidget/Today/TodayTodoWidgetEntryView.swift b/DevLogWidget/Today/TodayTodoWidgetEntryView.swift index 08bc5f0f..9e4a949b 100644 --- a/DevLogWidget/Today/TodayTodoWidgetEntryView.swift +++ b/DevLogWidget/Today/TodayTodoWidgetEntryView.swift @@ -82,10 +82,6 @@ struct TodayTodoWidgetEntryView: View { case .systemMedium: GeometryReader { proxy in VStack(alignment: .leading, spacing: 6) { - Text("준비 중") - .font(.caption2) - .foregroundStyle(.tertiary) - ForEach(0..<3, id: \.self) { index in placeholderTodoRow(width: placeholderTodoRowWidth(in: proxy.size.width, at: index)) } From 4c1331951cc5039075dcdf7226631b8e88f16b43 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 29 Apr 2026 16:40:48 +0900 Subject: [PATCH 11/13] =?UTF-8?q?refactor:=20WidgetHeatmapLayout=EB=A5=BC?= =?UTF-8?q?=20=EA=B3=B5=EC=9C=A0=ED=95=98=EB=8A=94=20=ED=94=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=ED=99=80=EB=8D=94=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20=ED=98=95=ED=83=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Heatmap/HeatmapWidgetEntryView.swift | 118 ++-------------- DevLogWidget/Heatmap/WidgetHeatmapGrid.swift | 131 ++++++++++++++---- 2 files changed, 114 insertions(+), 135 deletions(-) diff --git a/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift b/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift index 9d25e037..f319a505 100644 --- a/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift +++ b/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift @@ -58,25 +58,19 @@ struct HeatmapWidgetEntryView: View { VStack(alignment: .leading, spacing: 8) { Text("이번 달 히트맵") .font(.headline) - GeometryReader { proxy in - placeholderHeatmapGrid( - monthCount: 1, - availableSize: proxy.size, - showsMonthTitles: false - ) - } + WidgetHeatmapPlaceholderGrid( + weekCounts: [5], + showsMonthTitles: false + ) .frame(height: 72) } case .systemMedium: VStack(alignment: .leading, spacing: 8) { header(title: "이번 분기 히트맵") - GeometryReader { proxy in - placeholderHeatmapGrid( - monthCount: 3, - availableSize: proxy.size, - showsMonthTitles: true - ) - } + WidgetHeatmapPlaceholderGrid( + weekCounts: [5, 5, 5], + showsMonthTitles: true + ) .frame(height: 80) } default: @@ -102,100 +96,4 @@ struct HeatmapWidgetEntryView: View { return Array(snapshot.months.prefix(1)) } - - private func placeholderHeatmapGrid( - monthCount: Int, - availableSize: CGSize, - showsMonthTitles: Bool - ) -> some View { - let resolvedMonthCount = max(monthCount, 1) - let columnCount = showsMonthTitles ? 5 : 6 - let monthSpacing = showsMonthTitles ? availableSize.width / 18 : 0 - let cellSpacing: CGFloat = 3 - let monthTitleHeight: CGFloat = showsMonthTitles ? 8 : 0 - let monthTitleSpacing: CGFloat = showsMonthTitles ? 6 : 0 - let totalMonthSpacing = monthSpacing * CGFloat(max(resolvedMonthCount - 1, 0)) - let monthWidth = max((availableSize.width - totalMonthSpacing) / CGFloat(resolvedMonthCount), 0) - let widthBasedCellSize = max( - (monthWidth - cellSpacing * CGFloat(max(columnCount - 1, 0))) / CGFloat(columnCount), - 0 - ) - let verticalFixedHeight = monthTitleHeight - + monthTitleSpacing - + cellSpacing * CGFloat(max(7 - 1, 0)) - let heightBasedCellSize = max((availableSize.height - verticalFixedHeight) / 7, 0) - let cellSize = min(widthBasedCellSize, heightBasedCellSize) - - return HStack(alignment: .top, spacing: monthSpacing) { - ForEach(0.. some View { - VStack(alignment: .leading, spacing: monthTitleSpacing) { - if showsMonthTitles { - RoundedRectangle(cornerRadius: 3) - .fill(Color.secondary.opacity(0.18)) - .frame(width: cellSize * 3, height: monthTitleHeight) - } - - HStack(alignment: .top, spacing: cellSpacing) { - ForEach(0.. some View { - let opacity = placeholderHeatmapCellOpacity(columnIndex: columnIndex, rowIndex: rowIndex) - - return RoundedRectangle(cornerRadius: max(2, size / 5)) - .fill(Color.secondary.opacity(opacity)) - .frame(width: size, height: size) - } - - private func placeholderHeatmapCellOpacity( - columnIndex: Int, - rowIndex: Int - ) -> Double { - switch (columnIndex + rowIndex) % 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/WidgetHeatmapGrid.swift b/DevLogWidget/Heatmap/WidgetHeatmapGrid.swift index 8439b2b2..ad7c7b3b 100644 --- a/DevLogWidget/Heatmap/WidgetHeatmapGrid.swift +++ b/DevLogWidget/Heatmap/WidgetHeatmapGrid.swift @@ -12,7 +12,6 @@ struct WidgetHeatmapGrid: View { let selectedActivityKindRawValues: [String] let maxCount: Int let showsMonthTitles: Bool - private let orderedWeekdays = Array(1...7) var body: some View { GeometryReader { proxy in @@ -24,7 +23,7 @@ struct WidgetHeatmapGrid: View { ) HStack(alignment: .top, spacing: layout.weekdayLabelSpacing) { - weekdayLabel(layout) + WidgetHeatmapWeekdayLabels(layout: layout) HStack(alignment: .top, spacing: layout.monthSpacing) { ForEach(months, id: \.monthStart) { month in @@ -41,37 +40,76 @@ struct WidgetHeatmapGrid: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } +} - @ViewBuilder - private func weekdayLabel(_ layout: WidgetHeatmapLayout) -> some View { - let weekdayLabels = [ - 2: "월", - 4: "수", - 6: "금" - ] +struct WidgetHeatmapPlaceholderGrid: View { + let weekCounts: [Int] + let showsMonthTitles: Bool - VStack(alignment: .leading, spacing: layout.cellSpacing) { - ForEach(orderedWeekdays, id: \.self) { weekday in - 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 + 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.weekdayLabelSpacing) { + WidgetHeatmapWeekdayLabels(layout: layout) + + 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 { @@ -151,3 +189,46 @@ private enum WidgetHeatmapActivityKind: String { 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 + } + } +} From f79e71475600168e30fcbab816123bd6ef995209 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 29 Apr 2026 16:57:16 +0900 Subject: [PATCH 12/13] =?UTF-8?q?ui:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=ED=99=80=EB=8D=94=EC=9D=BC=20=EB=95=8C=20=EC=9B=94,?= =?UTF-8?q?=20=EC=88=98,=20=EA=B8=88=20=EB=9D=BC=EB=B2=A8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLogWidget/Heatmap/WidgetHeatmapGrid.swift | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/DevLogWidget/Heatmap/WidgetHeatmapGrid.swift b/DevLogWidget/Heatmap/WidgetHeatmapGrid.swift index ad7c7b3b..79e76f33 100644 --- a/DevLogWidget/Heatmap/WidgetHeatmapGrid.swift +++ b/DevLogWidget/Heatmap/WidgetHeatmapGrid.swift @@ -55,17 +55,13 @@ struct WidgetHeatmapPlaceholderGrid: View { showsMonthTitles: showsMonthTitles ) - HStack(alignment: .top, spacing: layout.weekdayLabelSpacing) { - WidgetHeatmapWeekdayLabels(layout: layout) - - HStack(alignment: .top, spacing: layout.monthSpacing) { - ForEach(Array(weekCounts.enumerated()), id: \.offset) { _, weekCount in - WidgetHeatmapPlaceholderMonthGrid( - weekCount: weekCount, - layout: layout, - showsMonthTitle: 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) From 81e61e196c526959802b0031d9902f5aa178c694 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 29 Apr 2026 16:57:25 +0900 Subject: [PATCH 13/13] =?UTF-8?q?ui:=20=EC=9D=98=EB=8F=84=EC=A0=81?= =?UTF-8?q?=EC=9D=B8=20=EB=86=92=EC=9D=B4=20=EC=A0=9C=ED=95=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift b/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift index f319a505..97a0d08f 100644 --- a/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift +++ b/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift @@ -62,7 +62,6 @@ struct HeatmapWidgetEntryView: View { weekCounts: [5], showsMonthTitles: false ) - .frame(height: 72) } case .systemMedium: VStack(alignment: .leading, spacing: 8) { @@ -71,7 +70,6 @@ struct HeatmapWidgetEntryView: View { weekCounts: [5, 5, 5], showsMonthTitles: true ) - .frame(height: 80) } default: EmptyView()