From c57c39002b26baa61268759307b0a6a5493e478a Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Fri, 10 Jul 2026 15:42:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(widgets):=20=ED=99=88=20=EC=9C=84=EC=A0=AF?= =?UTF-8?q?=20=ED=92=80=EB=B8=94=EB=A6=AC=EB=93=9C=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8B=A4=EC=A7=90=20=ED=98=84=ED=99=A9=20=EA=B0=84?= =?UTF-8?q?=EA=B2=A9=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 홈 위젯 5종에 contentMarginsDisabled() 적용: 시스템 기본 베젤 제거, 여백은 각 뷰가 직접 관리 (다중 셀 10pt, 콘텐츠형 13pt) - 소형(단일 셀) 위젯은 셀이 위젯 전체를 여백 없이 채움 — 셀 배경/테두리가 ContainerRelativeShape로 위젯 모서리에 정확히 맞음 (A·B·C 위젯) - 다짐 현황(D) 위젯 간격 개선: 링 그리드 행 간격 6→12, 열 간격 4→8, 섹션 간격 6→10, 대형 목표 2개 간격 10→16, 링-이름 간격 확대 - 미리보기 하니스도 여백 제거를 반영해 실제 렌더링과 일치 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- .../IOS/Views/WidgetPreviewScreen.swift | 2 +- myApp/HaruDanim/Widgets/ActionRunWidget.swift | 23 ++++++-- myApp/HaruDanim/Widgets/GoalWidgets.swift | 26 +++++++--- myApp/HaruDanim/Widgets/QuestRingWidget.swift | 15 ++++-- .../HaruDanim/Widgets/StatsChartWidget.swift | 2 + myApp/HaruDanim/Widgets/WidgetTheme.swift | 52 +++++++++++-------- 6 files changed, 81 insertions(+), 39 deletions(-) diff --git a/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift b/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift index e98cd91..2a4b7b9 100644 --- a/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift +++ b/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift @@ -199,8 +199,8 @@ struct WidgetPreviewScreen: View { } // containerBackground는 실제 위젯 컨텍스트 밖에서는 무시되므로 // 미리보기 박스가 테마 배경/컬러 스킴을 직접 재현한다 + // contentMarginsDisabled 적용 후에는 위젯 뷰가 여백을 스스로 관리한다 return content() - .padding(12) .frame(width: size.width, height: size.height) .background { if theme == .glass { diff --git a/myApp/HaruDanim/Widgets/ActionRunWidget.swift b/myApp/HaruDanim/Widgets/ActionRunWidget.swift index c6a151e..214d0c6 100644 --- a/myApp/HaruDanim/Widgets/ActionRunWidget.swift +++ b/myApp/HaruDanim/Widgets/ActionRunWidget.swift @@ -88,6 +88,8 @@ struct ActionRunCellView: View { let cell: ActionCellSnapshot let periodLabel: String var compact = false + /// 셀 하나가 위젯 전체를 차지할 때 (소형) — 위젯 모서리에 맞춰 여백 없이 채움 + var fullBleed = false var body: some View { Button(intent: RunActionIntent(action: cell.entity)) { @@ -121,13 +123,18 @@ struct ActionRunCellView: View { .lineLimit(1) } .foregroundStyle(.white) - .padding(compact ? 8 : 12) + .padding(fullBleed ? 14 : (compact ? 8 : 12)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .background(WidgetTintedCellBackground(color: cell.color)) + .background(WidgetTintedCellBackground(color: cell.color, fullBleed: fullBleed)) .overlay { if cell.isRunning { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(AppTheme.yellow, lineWidth: 2) + if fullBleed { + ContainerRelativeShape() + .strokeBorder(AppTheme.yellow, lineWidth: 2) + } else { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(AppTheme.yellow, lineWidth: 2) + } } } } @@ -146,11 +153,13 @@ struct ActionRunWidgetView: View { Group { if entry.locked { WidgetLockedView() + .padding(12) } else if entry.cells.isEmpty { Text("앱에서 행동을 만들면 여기서 실행할 수 있어요.") .font(.caption2) .foregroundStyle(.secondary) .multilineTextAlignment(.center) + .padding(12) } else { switch family { case .systemMedium: @@ -159,6 +168,7 @@ struct ActionRunWidgetView: View { ActionRunCellView(cell: cell, periodLabel: entry.periodLabel, compact: true) } } + .padding(10) case .systemLarge: let cells = Array(entry.cells.prefix(4)) VStack(spacing: 8) { @@ -175,9 +185,11 @@ struct ActionRunWidgetView: View { } } } + .padding(10) default: + // 소형: 셀 하나가 위젯 전체를 여백 없이 채움 (풀블리드) if let cell = entry.cells.first { - ActionRunCellView(cell: cell, periodLabel: entry.periodLabel) + ActionRunCellView(cell: cell, periodLabel: entry.periodLabel, fullBleed: true) } } } @@ -200,5 +212,6 @@ struct ActionRunWidget: Widget { .configurationDisplayName("행동 실행") .description("행동을 위젯에서 바로 실행하고 누적값을 확인해요. (프리미엄)") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + .contentMarginsDisabled() } } diff --git a/myApp/HaruDanim/Widgets/GoalWidgets.swift b/myApp/HaruDanim/Widgets/GoalWidgets.swift index 74945d5..7f983cd 100644 --- a/myApp/HaruDanim/Widgets/GoalWidgets.swift +++ b/myApp/HaruDanim/Widgets/GoalWidgets.swift @@ -68,6 +68,8 @@ struct GoalBarsProvider: AppIntentTimelineProvider { struct GoalBarsCellView: View { let goal: GoalSnapshot + /// 셀 하나가 위젯 전체를 차지할 때 (소형) — 위젯 모서리에 맞춰 여백 없이 채움 + var fullBleed = false var body: some View { VStack(alignment: .leading, spacing: 5) { @@ -83,9 +85,9 @@ struct GoalBarsCellView: View { SpanBarRow(label: "주간", ratio: goal.weekRatio, color: goal.color) SpanBarRow(label: "월간", ratio: goal.monthRatio, color: goal.color) } - .padding(10) + .padding(fullBleed ? 14 : 10) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .background(WidgetCellBackground()) + .background(WidgetCellBackground(fullBleed: fullBleed)) } } @@ -108,11 +110,13 @@ struct GoalBarsWidgetView: View { Group { if entry.locked { WidgetLockedView() + .padding(12) } else if entry.goals.isEmpty { Text("앱의 목표 탭에서 목표를 만들면 진행률이 표시돼요.") .font(.caption2) .foregroundStyle(.secondary) .multilineTextAlignment(.center) + .padding(12) } else { let goals = Array(entry.goals.prefix(visibleCount)) switch family { @@ -120,6 +124,7 @@ struct GoalBarsWidgetView: View { HStack(spacing: 8) { ForEach(goals) { GoalBarsCellView(goal: $0) } } + .padding(10) case .systemLarge: VStack(spacing: 8) { HStack(spacing: 8) { @@ -131,9 +136,11 @@ struct GoalBarsWidgetView: View { } } } + .padding(10) default: + // 소형: 셀 하나가 위젯 전체를 여백 없이 채움 (풀블리드) if let goal = goals.first { - GoalBarsCellView(goal: goal) + GoalBarsCellView(goal: goal, fullBleed: true) } } } @@ -154,6 +161,7 @@ struct GoalBarsWidget: Widget { .configurationDisplayName("목표 진행률") .description("목표의 하루·주간·월간 진행률을 진행바로 확인해요. (프리미엄)") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + .contentMarginsDisabled() } } @@ -224,7 +232,7 @@ struct QuestRingCellView: View { var ringSize: CGFloat = 34 var body: some View { - VStack(spacing: 3) { + VStack(spacing: 5) { QuestRingView(snapshot: quest, lineWidth: 4, iconSize: ringSize * 0.36) .frame(width: ringSize, height: ringSize) Text(quest.targetName) @@ -261,7 +269,7 @@ struct GoalQuestGridWidgetView: View { case .systemLarge: // 대형 D: 목표 2개를 2줄로, 혹은 1개 목표의 다짐 8개 크게 if entry.goals.count > 1 { - VStack(spacing: 10) { + VStack(spacing: 16) { ForEach(entry.goals.prefix(2)) { goal in goalSection(goal, maxCount: 4, columns: 4, ringSize: 40) } @@ -277,11 +285,12 @@ struct GoalQuestGridWidgetView: View { } } } + .padding(13) .widgetTheme(entry.theme) } private func goalSection(_ goal: GoalSnapshot, maxCount: Int, columns: Int, ringSize: CGFloat) -> some View { - VStack(spacing: 6) { + VStack(spacing: 10) { HStack(spacing: 4) { Image(systemName: goal.symbolName) .font(.system(size: 10, weight: .semibold)) @@ -302,8 +311,8 @@ struct GoalQuestGridWidgetView: View { Spacer() } else { let quests = Array(goal.quests.prefix(maxCount)) - let grid = [GridItem](repeating: GridItem(.flexible(), spacing: 4), count: columns) - LazyVGrid(columns: grid, spacing: 6) { + let grid = [GridItem](repeating: GridItem(.flexible(), spacing: 8), count: columns) + LazyVGrid(columns: grid, spacing: 12) { ForEach(quests) { quest in QuestRingCellView(quest: quest, ringSize: ringSize) } @@ -326,5 +335,6 @@ struct GoalQuestGridWidget: Widget { .configurationDisplayName("다짐 현황") .description("목표에 속한 다짐들의 진행률을 원형 링으로 한눈에 봐요. (프리미엄)") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + .contentMarginsDisabled() } } diff --git a/myApp/HaruDanim/Widgets/QuestRingWidget.swift b/myApp/HaruDanim/Widgets/QuestRingWidget.swift index bc38a43..e5a2a69 100644 --- a/myApp/HaruDanim/Widgets/QuestRingWidget.swift +++ b/myApp/HaruDanim/Widgets/QuestRingWidget.swift @@ -100,6 +100,9 @@ struct QuestRingActionCellView: View { } } + /// 셀 하나가 위젯 전체를 차지할 때 (소형) — 위젯 모서리에 맞춰 여백 없이 채움 + var fullBleed = false + private var content: some View { VStack(spacing: 4) { ZStack { @@ -120,8 +123,8 @@ struct QuestRingActionCellView: View { .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(6) - .background(WidgetCellBackground()) + .padding(fullBleed ? 10 : 6) + .background(WidgetCellBackground(fullBleed: fullBleed)) } private var percentText: String { @@ -153,11 +156,13 @@ struct QuestRingWidgetView: View { Group { if entry.locked { WidgetLockedView() + .padding(12) } else if entry.cells.isEmpty { Text("앱의 목표 탭에서 다짐을 만들면 진행률이 표시돼요.") .font(.caption2) .foregroundStyle(.secondary) .multilineTextAlignment(.center) + .padding(12) } else { let cells = Array(entry.cells.prefix(visibleCount)) switch family { @@ -165,6 +170,7 @@ struct QuestRingWidgetView: View { HStack(spacing: 8) { ForEach(cells) { QuestRingActionCellView(cell: $0, spanLabel: entry.spanLabel) } } + .padding(10) case .systemLarge: VStack(spacing: 8) { HStack(spacing: 8) { @@ -180,9 +186,11 @@ struct QuestRingWidgetView: View { } } } + .padding(10) default: + // 소형: 셀 하나가 위젯 전체를 여백 없이 채움 (풀블리드) if let cell = cells.first { - QuestRingActionCellView(cell: cell, spanLabel: entry.spanLabel, ringSize: 58) + QuestRingActionCellView(cell: cell, spanLabel: entry.spanLabel, ringSize: 58, fullBleed: true) } } } @@ -205,5 +213,6 @@ struct QuestRingWidget: Widget { .configurationDisplayName("다짐 진행률") .description("다짐의 진행률을 원형 링으로 보고, 눌러서 바로 실행해요. (프리미엄)") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + .contentMarginsDisabled() } } diff --git a/myApp/HaruDanim/Widgets/StatsChartWidget.swift b/myApp/HaruDanim/Widgets/StatsChartWidget.swift index fc7f000..c4966c1 100644 --- a/myApp/HaruDanim/Widgets/StatsChartWidget.swift +++ b/myApp/HaruDanim/Widgets/StatsChartWidget.swift @@ -190,6 +190,7 @@ struct StatsChartWidgetView: View { } } } + .padding(13) .widgetTheme(entry.theme) } @@ -249,5 +250,6 @@ struct StatsChartWidget: Widget { .configurationDisplayName("행동 통계") .description("선택한 행동들의 통계 꺾은선 그래프를 확인해요. (프리미엄)") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + .contentMarginsDisabled() } } diff --git a/myApp/HaruDanim/Widgets/WidgetTheme.swift b/myApp/HaruDanim/Widgets/WidgetTheme.swift index 7f475be..50ef3ab 100644 --- a/myApp/HaruDanim/Widgets/WidgetTheme.swift +++ b/myApp/HaruDanim/Widgets/WidgetTheme.swift @@ -105,20 +105,26 @@ extension View { } /// 셀 카드 배경: 일반 테마는 표면색, 리퀴드 글라스는 머티리얼 + 유리 테두리 +/// fullBleed = 셀 하나가 위젯 전체를 차지할 때 위젯 모서리(ContainerRelativeShape)에 맞춤 struct WidgetCellBackground: View { @Environment(\.widgetGlass) private var glass + var fullBleed = false var body: some View { - if glass { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.thinMaterial) - .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(.white.opacity(0.25), lineWidth: 1) - } + if fullBleed { + styled(ContainerRelativeShape()) } else { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(AppTheme.surface) + styled(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + } + + @ViewBuilder + private func styled(_ shape: some InsettableShape) -> some View { + if glass { + shape.fill(.thinMaterial) + .overlay { shape.strokeBorder(.white.opacity(0.25), lineWidth: 1) } + } else { + shape.fill(AppTheme.surface) } } } @@ -128,22 +134,24 @@ struct WidgetCellBackground: View { struct WidgetTintedCellBackground: View { @Environment(\.widgetGlass) private var glass let color: Color + var fullBleed = false var body: some View { - if glass { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.thinMaterial) - .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(color.opacity(0.5)) - } - .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(.white.opacity(0.3), lineWidth: 1) - } + if fullBleed { + styled(ContainerRelativeShape()) } else { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(color) + styled(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + } + + @ViewBuilder + private func styled(_ shape: some InsettableShape) -> some View { + if glass { + shape.fill(.thinMaterial) + .overlay { shape.fill(color.opacity(0.5)) } + .overlay { shape.strokeBorder(.white.opacity(0.3), lineWidth: 1) } + } else { + shape.fill(color) } } }