feat(widgets): 홈 위젯 풀블리드 적용 및 다짐 현황 간격 개선
- 홈 위젯 5종에 contentMarginsDisabled() 적용: 시스템 기본 베젤 제거, 여백은 각 뷰가 직접 관리 (다중 셀 10pt, 콘텐츠형 13pt) - 소형(단일 셀) 위젯은 셀이 위젯 전체를 여백 없이 채움 — 셀 배경/테두리가 ContainerRelativeShape로 위젯 모서리에 정확히 맞음 (A·B·C 위젯) - 다짐 현황(D) 위젯 간격 개선: 링 그리드 행 간격 6→12, 열 간격 4→8, 섹션 간격 6→10, 대형 목표 2개 간격 10→16, 링-이름 간격 확대 - 미리보기 하니스도 여백 제거를 반영해 실제 렌더링과 일치 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
parent
c0c59af0ac
commit
c57c39002b
@ -199,8 +199,8 @@ struct WidgetPreviewScreen: View {
|
||||
}
|
||||
// containerBackground는 실제 위젯 컨텍스트 밖에서는 무시되므로
|
||||
// 미리보기 박스가 테마 배경/컬러 스킴을 직접 재현한다
|
||||
// contentMarginsDisabled 적용 후에는 위젯 뷰가 여백을 스스로 관리한다
|
||||
return content()
|
||||
.padding(12)
|
||||
.frame(width: size.width, height: size.height)
|
||||
.background {
|
||||
if theme == .glass {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user