2026-07-23 사용성 감사(A그룹 5건 + 지시 추가분)를 한 번에 반영.
[테마] '시스템 설정 따름' 추가 + 기본값 light→system. "system"은 스킴을
강제하지 않아(preferredColorScheme nil) 앱·위젯('앱 일치' 옵션,
WidgetThemeOption.scheme: ColorScheme?)·DEBUG 위젯 미리보기까지
시스템 라이트/다크를 그대로 따른다
[온보딩] 첫 실행 3장 소개(OnboardingView — 행동→목표·다짐→정리).
데이터가 하나도 없고 본 적 없을 때만 1회(건너뛰기 포함 재표시 없음,
onboarding.done), 시드·마케팅 촬영 플로우는 자동 건너뜀.
설정 → 지원 '앱 소개 다시 보기'로 재열람
[일기 잠금] 설정 → 일기(iPad 전용 표시) 토글 — DiaryLock이
.deviceOwnerAuthentication으로 Face ID/Touch ID/Optic ID+암호 폴백을
기기별 분기 없이 처리. 일기 탭 진입 게이트(DiaryLockGateView),
백그라운드 재잠금, 토글 변경 시 인증 요구, 잠글 수단 없는 기기는
통과(영구 잠김 방지). NSFaceIDUsageDescription 추가(Info.plist+
InfoPlist.xcstrings ko/en/ja)
[통계] 이전 기간 비교 카드(맨 위) — 총 시간·횟수를 어제/지난주/지난달과
비교(▲▼%, 이전 기록 없으면 상태 문구). 이전 기간은 관계 기반
Aggregator로 직접 집계, 필터 반영. 화면 전용(내보내기 미포함 의도)
[스플래시] 1.2→0.7초 단축 (하루에도 여러 번 여는 앱)
[워치] 행동 실행 탭 시 WKInterfaceDevice.play(.click) 햅틱 — 화면을
안 보고 탭해도 접수 확인
[컴플리케이션] rectangular 빈 상태에 해결 안내("목표 편집에서
'애플워치에서 보기'를 켜면 나타나요") — 옵트인 발견성 보완
[문구] 행동 편집기 꼬리표 색 규칙(여러 개면 가장 먼저 만든 꼬리표),
새로고침 버튼 accessibilityHint
- 도움말 3주제 추가(새로고침 버튼·일기 잠금·이전 기간과 비교)
- 신규 문구 31키 en/ja 완역 + 워치 카탈로그 2키, missing/stale 0
- CLAUDE.md §6·§6.6·§6.7·§6.8·§10·§11·§14 갱신, DEBUG 인자
-showOnboarding·-diaryLockScreen 추가
- 검증: Debug/워치/Store 빌드, 온보딩·설정·비교 카드·잠금 게이트·
컴플리케이션 스크린샷
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
150 lines
5.6 KiB
Swift
150 lines
5.6 KiB
Swift
//
|
|
// WidgetTheme.swift
|
|
// Haru_DanimWidgets
|
|
//
|
|
// 위젯 외형 (CLAUDE.md §8.1)
|
|
// - 모든 홈 위젯 설정(Intent)에 [앱 설정과 일치 / 라이트 고정 / 다크 고정] 테마 옵션 제공.
|
|
// - containerBackground(iOS 17+)로 배경을 그리고, 홈 화면이 틴트/클리어(리퀴드 글라스)
|
|
// 모드일 때는 시스템이 배경을 자체 유리로 대체하고 색을 재해석하므로 커스텀 배경·강제
|
|
// 스킴을 얹지 않고(Color.clear) 시스템 바이브런트 렌더링에 맡긴다 — 흰 글씨가
|
|
// 흰 배경에 묻히는(white-on-white) 버그 방어의 핵심.
|
|
//
|
|
|
|
import SwiftUI
|
|
import WidgetKit
|
|
import AppIntents
|
|
|
|
// MARK: - 테마 옵션 (모든 홈 위젯 설정 공용)
|
|
|
|
enum WidgetThemeOption: String, AppEnum {
|
|
case matchApp
|
|
case light
|
|
case dark
|
|
|
|
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "테마")
|
|
static let caseDisplayRepresentations: [WidgetThemeOption: DisplayRepresentation] = [
|
|
.matchApp: "앱 설정과 일치",
|
|
.light: "라이트 모드 고정",
|
|
.dark: "다크 모드 고정",
|
|
]
|
|
|
|
/// 이 옵션이 위젯에 적용할 컬러 스킴 (nil = 강제하지 않고 시스템 모드를 따름)
|
|
var scheme: ColorScheme? {
|
|
switch self {
|
|
case .matchApp: return WidgetAppearance.appScheme
|
|
case .light: return .light
|
|
case .dark: return .dark
|
|
}
|
|
}
|
|
}
|
|
|
|
enum WidgetAppearance {
|
|
/// 앱 설정의 테마 ("system" | "light" | "dark") → 위젯에 적용할 컬러 스킴.
|
|
/// "system"(기본)은 nil — 위젯 환경의 시스템 모드를 그대로 따른다
|
|
static var appScheme: ColorScheme? {
|
|
let raw = AppGroup.defaults.string(forKey: SettingsKeys.theme)
|
|
?? UserDefaults.standard.string(forKey: SettingsKeys.theme)
|
|
switch raw {
|
|
case "dark": return .dark
|
|
case "light": return .light
|
|
default: return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 테마 적용 모디파이어
|
|
|
|
struct WidgetThemeModifier: ViewModifier {
|
|
@Environment(\.widgetRenderingMode) private var renderingMode
|
|
let theme: WidgetThemeOption
|
|
|
|
func body(content: Content) -> some View {
|
|
if renderingMode != .fullColor {
|
|
// 틴트/클리어(리퀴드 글라스) 홈 화면: 커스텀 배경·강제 스킴을 얹으면
|
|
// 시스템 유리 위에서 텍스트가 묻힌다 → 시스템 기본(바이브런트) 렌더링에 맡긴다
|
|
content
|
|
.containerBackground(for: .widget) { Color.clear }
|
|
} else if let scheme = theme.scheme {
|
|
content
|
|
.environment(\.colorScheme, scheme)
|
|
.containerBackground(for: .widget) {
|
|
// containerBackground는 시스템이 위젯 자체 환경(시스템 모드)으로 그리므로
|
|
// 콘텐츠에 적용한 스킴을 배경에도 직접 지정해야 선택한 테마가 실제로 적용된다
|
|
AppTheme.background
|
|
.environment(\.colorScheme, scheme)
|
|
}
|
|
} else {
|
|
// 시스템 따르기(테마 "system"): 스킴을 강제하지 않는다 —
|
|
// 동적 색(AppTheme)이 위젯 환경의 시스템 모드에 맞춰 알아서 전환된다
|
|
content
|
|
.containerBackground(for: .widget) { AppTheme.background }
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
/// 위젯 루트에 테마 적용 (컬러 스킴 + containerBackground 배경)
|
|
func widgetAppTheme(_ theme: WidgetThemeOption = .matchApp) -> some View {
|
|
modifier(WidgetThemeModifier(theme: theme))
|
|
}
|
|
}
|
|
|
|
// MARK: - 셀 배경
|
|
|
|
/// 셀 카드 배경 (표면색).
|
|
/// fullBleed = 셀 하나가 위젯 전체를 차지할 때 위젯 모서리(ContainerRelativeShape)에 맞춤
|
|
/// 틴트/클리어(리퀴드 글라스) 모드에서는 불투명 표면색이 흰 판으로 재해석돼 바이브런트
|
|
/// 콘텐츠와 겹치면 white-on-white가 되므로, 옅은 반투명 패널로 바꿔 시스템 유리 질감을
|
|
/// 살리면서 대비를 유지한다 (①의 틴트 셀 0.2 반투명 처리와 같은 원리).
|
|
struct WidgetCellBackground: View {
|
|
@Environment(\.widgetRenderingMode) private var renderingMode
|
|
var fullBleed = false
|
|
|
|
private var fill: AnyShapeStyle {
|
|
renderingMode == .fullColor
|
|
? AnyShapeStyle(AppTheme.surface)
|
|
: AnyShapeStyle(Color.white.opacity(0.14))
|
|
}
|
|
|
|
var body: some View {
|
|
if fullBleed {
|
|
ContainerRelativeShape().fill(fill)
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous).fill(fill)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 행동 실행 셀처럼 꼬리표 색이 배경인 셀
|
|
struct WidgetTintedCellBackground: View {
|
|
let color: Color
|
|
var fullBleed = false
|
|
|
|
var body: some View {
|
|
if fullBleed {
|
|
ContainerRelativeShape().fill(color)
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous).fill(color)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 아직 행동/다짐을 선택하지 않은 슬롯의 빈 칸 (점선 테두리)
|
|
struct WidgetBlankCellView: View {
|
|
var fullBleed = false
|
|
|
|
var body: some View {
|
|
Group {
|
|
if fullBleed {
|
|
ContainerRelativeShape()
|
|
.strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [5, 4]))
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [5, 4]))
|
|
}
|
|
}
|
|
.foregroundStyle(.quaternary)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|