fix(1.5-p12): 워치 미니 행동 화면 재구성 — 하단 버튼 잘림 해소, 아이콘=실행 버튼 (빌드 9 동일)

사용자 울트라 실측: 컴플리케이션 착지 화면의 하단 실행 버튼이 아래가 살짝 잘림.
- 하단 버튼 제거, 아이콘 타일이 실행 버튼 (우하단 +/▶/■ 배지, 측정 중=노란 테두리+배지)
- 상태별 한 줄 힌트("아이콘을 누르면 시작/종료/1회 추가") — ja 40mm 한 줄 유지(축소 0.75)
- 작은 워치(높이<220pt) 컴팩트 메트릭 — 40mm에서도 힌트까지 스크롤 없이 한 화면
- 실행 경로는 기존 store.run 그대로(햅틱 포함), a11y 라벨은 기존 버튼 문구 유지
- 도움말 '행동 기록 컴플리케이션' 문구 갱신(3언어), 전 카탈로그 missing/stale 0
- 신규 검증 인자 -watchSeedSnapshot(페어링 없는 합성 스냅숏)·-watchOpenActionIndex

검증: 49mm(울트라)·46mm(26.2)·40mm(11.5) × 3상태 + ja 스크린샷 잘림 0,
워치·iOS Debug·Store 빌드 그린. 스키마·빌드 번호 무변경(9 — ASC 업로드 전).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
This commit is contained in:
songyc macbook 2026-08-26 14:53:03 +09:00
parent 2ae469d187
commit efe38cb25b
8 changed files with 188 additions and 33 deletions

File diff suppressed because one or more lines are too long

View File

@ -199,3 +199,10 @@
- **적용 7건**: ⓐ건강 캐시 2종 인메모리화(세대 카운터로 프로세스 간 무효화, workoutKeys 이중 load 제거) ⓑDayMath.calendar 저장 프로퍼티화 ⓒ수면 백필 정렬+포인터 스위프 ⓓrefreshRecent 기록 배칭 ⓔspanValue/spanRawValue 하루 버킷화(**게이지의 '미래 기록 즉시 반영' 스펙 보존** — streak의 perDayValues와 별도 헬퍼 perDayGaugeValues/perDayRecordValues) ⓕ위젯 4프로바이더 makeEntry refresh 파라미터(타임라인당 컨테이너 1회) ⓖ일기 건강 카드 칩 value() 1회화.
- **의도적 제외**: 건강 재조회 스로틀(신선도 변화=동작 변화), 워치 스냅숏 백그라운드화(SwiftData 메인 컨텍스트 위험), 모음 탭 셀 오늘 값 버킷화(구조 변경 — 소킹 체감 보고 후 재논의).
- **검증(엄밀 A/B 포함)**: ①옛/새 빌드를 각각 **새 컨테이너+동일 시드**(seedDemo+건강 캐시+건강 다짐+주기 몫)로 캡처 — streak·건강 다짐·인텐트 스모크 덤프 3종 **값 완전 동일**(순서 무관 정렬 diff 0 — fetch 순서 비결정성만 존재), 목표 탭·모음 탭·위젯 ④·일기 4화면 **픽셀 단위 동일**(자작 pixdiff — 상태바 시계·시뮬 iCloud 배너만 제외) ②자가 검증 62/20/27/10 ALL PASS(26.5 심+**18.5 심 62건 별도 ALL PASS**) ③수면 스위프는 무작위 퍼즈 **3,000회 등가 증명**(창 40/400/7개×길이 1분~24h×DST식 겹침 창×구간 0~2,000개×경계 정확 접촉·0길이·전체 덮개) ④벌크 1만 건 시드에서 목표·통계·모음 3탭 렌더 정상(수치 실측 일치) ⑤3종 빌드(Debug·Store·워치) 그린 ⑥신규 현지화 문자열 0(카탈로그 무변경). 스토어: whats-new-1.5 3언어에 성능 개선 1줄 추가.
### §12 후속 5 — 워치 미니 행동 화면 재구성 (2026-08-26, 빌드 9 동일)
- **발단(사용자 울트라 실측)**: '행동 기록' 컴플리케이션 착지 화면(미니 행동 화면)의 하단 실행 버튼이 가장 큰 화면(울트라 49mm)에서도 아래가 살짝 잘림 — 동작 지장은 없지만 디테일 아쉬움. 사용자 제안(하단 버튼 제거+아이콘=버튼) 채택.
- **새 구성**: [뒤로가기(시스템)] / **아이콘 타일=실행 버튼**(64pt, 우하단 배지 (횟수)·▶(대기)·■(측정 중), 측정 중=노란 테두리+노란 배지 — 앱 전체 '측정 중=노랑' 문법) / 이름 / 기간 라벨+큰 값(측정 중 노란 타이머) / **상태별 한 줄 힌트**("아이콘을 누르면 시작/종료/1회 추가"). 별도 '측정 중' 줄은 사중 중복이라 제거. 실행 경로는 기존과 동일한 store.run(햅틱 포함) — 트리거 뷰만 바뀜, a11y 라벨은 기존 버튼 문구 재사용.
- **작은 화면 대응**: 화면 높이<220pt(40·41mm) 컴팩트 메트릭(아이콘 54·간격 6) 40mm에서도 힌트까지 스크롤 없이 화면. 힌트는 lineLimit 1+minimumScaleFactor 0.75(ja "アイコンをタップで計測終了" 40mm에서 줄로 꺾여 폴드 밑으로 가던 실측 수정 일기 건강 전례 문법).
- **검증**: 신규 인자 `-watchSeedSnapshot`(페어링 없는 합성 스냅숏 — 3상태)+`-watchOpenActionIndex`**49mm(울트라)·46mm(26.2)·40mm SE(11.5) × 횟수형/측정 중/대기 + ja** 스크린샷 전부 잘림 0(임시 심은 QA 후 삭제). 도움말 문구 갱신+전 카탈로그 missing/stale 0, 워치·iOS Debug·Store 빌드 그린. 스키마·빌드 번호 무변경(9 유지 — ASC 업로드 전).

View File

@ -44,12 +44,16 @@ struct ContentView: View {
.onAppear { store.activate() }
#if DEBUG
// -watchOpenActionDetail YES: ( )
// push
// push .
// -watchOpenActionIndex N N(0) ( QA, 1.5(9))
.onChange(of: store.snapshot?.generatedAt) {
guard !didAutoOpenDetail,
UserDefaults.standard.bool(forKey: "watchOpenActionDetail"),
let snapshot = store.snapshot else { return }
let target = snapshot.actions.first { $0.complicationPeriodRaw != nil } ?? snapshot.actions.first
let index = UserDefaults.standard.integer(forKey: "watchOpenActionIndex")
let target: WatchActionInfo? = snapshot.actions.indices.contains(index) && index > 0
? snapshot.actions[index]
: snapshot.actions.first { $0.complicationPeriodRaw != nil } ?? snapshot.actions.first
guard let target else { return }
didAutoOpenDetail = true
path.append(target.id)

View File

@ -553,6 +553,54 @@
}
}
},
"아이콘을 누르면 1회 추가": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Tap the icon to add 1"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "アイコンをタップで1回追加"
}
}
}
},
"아이콘을 누르면 측정 시작": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Tap the icon to start"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "アイコンをタップで計測開始"
}
}
}
},
"아이콘을 누르면 측정 종료": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Tap the icon to stop"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "アイコンをタップで計測終了"
}
}
}
},
"앱을 열면 새로고침돼요": {
"localizations": {
"en": {

View File

@ -5,15 +5,25 @@
// (1.5 Docs/plan-1.5.md §2)
// ' ' .
// - push ( )
// - : ( )
// - : ( )
// - 1.5(9) ( ):
// ** **. (//)
// , (//1 ) .
// : + + .
// (40mm) .
//
import SwiftUI
import WatchKit
struct WatchActionDetailView: View {
@State private var store = WatchStore.shared
let actionID: UUID
/// (40·41mm)
/// · ( )
private var compact: Bool { WKInterfaceDevice.current().screenBounds.height < 220 }
/// ( )
private var action: WatchActionInfo? {
store.snapshot?.actions.first { $0.id == actionID }
@ -31,30 +41,23 @@ struct WatchActionDetailView: View {
}
private func detail(_ action: WatchActionInfo) -> some View {
// ScrollView ( · ) ,
ScrollView {
VStack(spacing: 10) {
Image(safeSymbol: action.symbolName)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.background(
Color(watchHex: action.colorHex),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
VStack(spacing: compact ? 6 : 8) {
iconButton(action)
Text(action.name)
.font(.headline)
.multilineTextAlignment(.center)
.lineLimit(2)
valueBlock(action)
Button {
store.run(action)
} label: {
Label(buttonTitle(action), systemImage: buttonSymbol(action))
.font(.system(size: 15, weight: .semibold))
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(action.isRunning && !action.isCount ? .yellow : Color(watchHex: action.colorHex))
// = (//1 ).
// ja 40mm
// + ( 1.5(4) )
Text(hintText(action))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.75)
if store.lastSendFailed {
Text("iPhone에 연결할 수 없어요. 근처에 있는지 확인해 주세요.")
.font(.footnote)
@ -62,10 +65,62 @@ struct WatchActionDetailView: View {
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 4)
}
}
/// = . (//) , ·
private func iconButton(_ action: WatchActionInfo) -> some View {
let runningTime = action.isRunning && !action.isCount
let side: CGFloat = compact ? 54 : 64
return Button {
store.run(action)
} label: {
Image(safeSymbol: action.symbolName)
.font(.system(size: compact ? 22 : 26, weight: .semibold))
.foregroundStyle(.white)
.frame(width: side, height: side)
.background(
Color(watchHex: action.colorHex),
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
)
.overlay {
if runningTime {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(.yellow, lineWidth: 2.5)
}
}
.overlay(alignment: .bottomTrailing) {
actionBadge(action, runningTime: runningTime)
}
//
.contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
.buttonStyle(.plain)
.padding(.top, 2)
.accessibilityLabel(Text(buttonTitle(action)))
}
/// : + /, : +
private func actionBadge(_ action: WatchActionInfo, runningTime: Bool) -> some View {
Image(systemName: buttonSymbol(action))
.font(.system(size: 10, weight: .bold))
.foregroundStyle(runningTime ? Color.black : Color(watchHex: action.colorHex))
.frame(width: 20, height: 20)
.background(runningTime ? Color.yellow : Color.white, in: Circle())
.overlay(Circle().strokeBorder(Color.black.opacity(0.15), lineWidth: 0.5))
.offset(x: 5, y: 5)
}
/// ( )
private func hintText(_ action: WatchActionInfo) -> String {
if action.isCount { return String(localized: "아이콘을 누르면 1회 추가") }
return action.isRunning
? String(localized: "아이콘을 누르면 측정 종료")
: String(localized: "아이콘을 누르면 측정 시작")
}
/// ( ) ,
@ViewBuilder
private func valueBlock(_ action: WatchActionInfo) -> some View {
@ -87,11 +142,8 @@ struct WatchActionDetailView: View {
Text(WatchFormat.duration(period != nil ? (action.periodValue ?? 0) : action.todayValue))
.font(.system(.title3, design: .rounded).weight(.bold).monospacedDigit())
}
if action.isRunning, !action.isCount {
Label("측정 중", systemImage: "record.circle")
.font(.caption2)
.foregroundStyle(.yellow)
}
// ' ' · · ·
// , (1.5(9))
}
}

View File

@ -30,6 +30,14 @@ final class WatchStore: NSObject {
static let groupDefaults = UserDefaults(suiteName: "group.com.yechan.HaruDanim") ?? .standard
func activate() {
#if DEBUG
// -watchSeedSnapshot YES: iPhone
// (1.5(9) QA. -watchOpenActionDetail )
if UserDefaults.standard.bool(forKey: "watchSeedSnapshot") {
apply(Self.debugSeedSnapshot())
return
}
#endif
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.delegate = self
@ -114,6 +122,42 @@ final class WatchStore: NSObject {
}
#if DEBUG
/// -watchSeedSnapshot (· ·
/// ) . : () ()
private static func debugSeedSnapshot() -> WatchSnapshot {
let countID = UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000001")!
let runID = UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000002")!
let idleID = UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000003")!
var snapshot = WatchSnapshot()
snapshot.isPremium = true
snapshot.actions = [
WatchActionInfo(
id: countID, name: "팔굽혀펴기", symbolName: "dumbbell.fill",
colorHex: "#7A5C9D", isCount: true, todayValue: 20, isRunning: false,
tickingBase: nil, tagIDs: [], isFavorite: true,
complicationPeriodRaw: "week", periodValue: 62
),
WatchActionInfo(
id: runID, name: "달리기", symbolName: "figure.run",
colorHex: "#9D5B4A", isCount: false, todayValue: 40 * 60, isRunning: true,
tickingBase: Date.now.addingTimeInterval(-40 * 60), tagIDs: [], isFavorite: true,
complicationPeriodRaw: "day", periodValue: 40 * 60,
periodTickingBase: Date.now.addingTimeInterval(-40 * 60)
),
WatchActionInfo(
id: idleID, name: "독서", symbolName: "book.fill",
colorHex: "#2F6B4F", isCount: false, todayValue: 25 * 60, isRunning: false,
tickingBase: nil, tagIDs: [], isFavorite: true,
complicationPeriodRaw: "last7", periodValue: 3 * 3600 + 20 * 60
),
]
snapshot.runningName = "달리기"
snapshot.runningSymbol = "figure.run"
snapshot.runningStartAt = Date.now.addingTimeInterval(-40 * 60)
snapshot.runningActionID = runID
return snapshot
}
/// -autoRunFirstAction YES:
/// ( iPhone Live Activity CLI )
private var didAutoRun = false

View File

@ -17470,18 +17470,18 @@
}
}
},
"행동의 누적 기록을 워치 페이스에 두고 싶다면, 아이폰의 행동 편집에서 '애플워치 컴플리케이션'을 켜고 표시 기간(하루·이번 주·이번 달·지난 7일·지난 30일)을 골라 주세요. 그러면 페이스 편집의 '행동 기록' 목록에 그 행동이 나타나요. 측정 중일 때는 컴플리케이션이 노란 타이머로 바뀌고, 컴플리케이션을 누르면 워치 앱의 그 행동 화면이 열려 큰 버튼으로 바로 시작·종료(횟수형은 +1)할 수 있어요 — 뒤로 가면 워치 앱 첫 화면이에요. 페이스의 숫자는 몇 분 간격으로 갱신되니 방금 누른 기록이 바로 안 보여도 잠시만 기다려 주세요.": {
"행동의 누적 기록을 워치 페이스에 두고 싶다면, 아이폰의 행동 편집에서 '애플워치 컴플리케이션'을 켜고 표시 기간(하루·이번 주·이번 달·지난 7일·지난 30일)을 골라 주세요. 그러면 페이스 편집의 '행동 기록' 목록에 그 행동이 나타나요. 측정 중일 때는 컴플리케이션이 노란 타이머로 바뀌고, 컴플리케이션을 누르면 워치 앱의 그 행동 화면이 열려요 — 화면의 행동 아이콘을 누르면 바로 시작·종료(횟수형은 +1)되고, 뒤로 가면 워치 앱 첫 화면이에요. 페이스의 숫자는 몇 분 간격으로 갱신되니 방금 누른 기록이 바로 안 보여도 잠시만 기다려 주세요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Want an action's running total on your watch face? In the action editor on iPhone, turn on Apple Watch complication and pick a display period — day, this week, this month, last 7 days or last 30 days. The action then appears in the Action Log list when editing your watch face. While tracking, the complication turns into a yellow timer, and tapping it opens that action's screen in the watch app, where a big button starts or stops (or adds +1 for count types) — going back lands on the watch app's first screen. The number on the face refreshes every few minutes, so give it a moment if a fresh record doesn't show right away."
"value": "Want an action's running total on your watch face? In the action editor on iPhone, turn on Apple Watch complication and pick a display period — day, this week, this month, last 7 days or last 30 days. The action then appears in the Action Log list when editing your watch face. While tracking, the complication turns into a yellow timer, and tapping it opens that action's screen in the watch app — tap the action icon there to start or stop right away (or add +1 for count types), and going back lands on the watch app's first screen. The number on the face refreshes every few minutes, so give it a moment if a fresh record doesn't show up right away."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "アクションの累計をウォッチフェイスに置きたいなら、iPhoneのアクション編集で「Apple Watchコンプリケーション」をオンにして、表示期間1日・今週・今月・過去7日間・過去30日間を選んでください。するとフェイス編集の「アクションの記録」一覧にそのアクションが現れます。計測中はコンプリケーションが黄色いタイマーに変わり、タップするとウォッチアプリのそのアクション画面が開いて、大きなボタンですぐ開始・終了(回数型は+1できます — 戻るとウォッチアプリの最初の画面です。フェイスの数字は数分おきに更新されるので、いま押した記録がすぐ見えなくても少しだけ待ってください。"
"value": "アクションの累計をウォッチフェイスに置きたいなら、iPhoneのアクション編集で「Apple Watchコンプリケーション」をオンにして、表示期間1日・今週・今月・過去7日間・過去30日間を選んでください。するとフェイス編集の「アクションの記録」一覧にそのアクションが現れます。計測中はコンプリケーションが黄色いタイマーに変わり、タップするとウォッチアプリのそのアクション画面が開きます — 画面のアクションアイコンをタップすると、すぐ開始・終了(回数型は+1できて、戻るとウォッチアプリの最初の画面です。フェイスの数字は数分おきに更新されるので、いま押した記録がすぐ見えなくても少しだけ待ってください。"
}
}
}

View File

@ -433,7 +433,7 @@ struct HelpView: View {
HelpTopic(
symbol: "applewatch.watchface",
title: "행동 기록 컴플리케이션",
body: "행동의 누적 기록을 워치 페이스에 두고 싶다면, 아이폰의 행동 편집에서 '애플워치 컴플리케이션'을 켜고 표시 기간(하루·이번 주·이번 달·지난 7일·지난 30일)을 골라 주세요. 그러면 페이스 편집의 '행동 기록' 목록에 그 행동이 나타나요. 측정 중일 때는 컴플리케이션이 노란 타이머로 바뀌고, 컴플리케이션을 누르면 워치 앱의 그 행동 화면이 열려 큰 버튼으로 바로 시작·종료(횟수형은 +1)할 수 있어요 — 뒤로 가면 워치 앱 첫 화면이에요. 페이스의 숫자는 몇 분 간격으로 갱신되니 방금 누른 기록이 바로 안 보여도 잠시만 기다려 주세요."
body: "행동의 누적 기록을 워치 페이스에 두고 싶다면, 아이폰의 행동 편집에서 '애플워치 컴플리케이션'을 켜고 표시 기간(하루·이번 주·이번 달·지난 7일·지난 30일)을 골라 주세요. 그러면 페이스 편집의 '행동 기록' 목록에 그 행동이 나타나요. 측정 중일 때는 컴플리케이션이 노란 타이머로 바뀌고, 컴플리케이션을 누르면 워치 앱의 그 행동 화면이 열려요 — 화면의 행동 아이콘을 누르면 바로 시작·종료(횟수형은 +1)되고, 뒤로 가면 워치 앱 첫 화면이에요. 페이스의 숫자는 몇 분 간격으로 갱신되니 방금 누른 기록이 바로 안 보여도 잠시만 기다려 주세요."
),
HelpTopic(
symbol: "arrow.triangle.2.circlepath",