feat(widget): stop button on Live Activity lock-screen banner

- 잠금화면 실시간 현황(라이브 액티비티)에 종료 수단이 없다는 보고 → 배너 오른쪽에 노란 정지 버튼 추가. 워치·⑥ 위젯과 같은 검증된 경로(NowRunningStopButton → RunActionIntent 토글) 재사용이라 위험 낮음 판단 후 진행
- ContentState에 actionID(optional) 추가 — 앱 업데이트 전 인플라이트 액티비티(키 없음)도 안전 디코딩(WatchSnapshot 선례), 없으면 버튼 없이 기존 표시
- LockScreenActivityView를 NowRunningWidget.swift로 이동 — @main 파일은 앱 타깃에서 제외라 앱 내 위젯 미리보기(-widgetPreview)에서 배너를 렌더하기 위함(동작 불변). 미리보기 최상단에 배너 박스 신설
- 다이나믹 아일랜드 스모크 확인(상태 필드 추가 회귀 없음 — 콤팩트 정상 표시). 실기기 확인 항목: 잠금화면에서 정지 버튼 탭 시 즉시 종료
- 도움말 '다이나믹 아일랜드·잠금화면' 문구에 정지 버튼 안내 추가(ko/en/ja, stale 정리), CLAUDE.md §9 갱신. Debug/Store 빌드 성공, 카탈로그 9종 클린

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp
This commit is contained in:
songyc macbook 2026-07-19 04:46:38 +09:00
parent 7c8e882cda
commit e1537b32f3
8 changed files with 79 additions and 44 deletions

View File

@ -258,7 +258,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
- 측정 시작/종료/수정마다 `sync(context:)`: 진행 중 세션 조회 → 대표 1개(설정: earliest/latest) + `extraCount`(+N 표시) 상태로 Activity 시작/갱신/종료. 중복 Activity 정리
- 백그라운드(워치 명령)에서 시작 거부 시 `pendingStartRetry` → 포그라운드 복귀 때 재시도
- UI: 잠금화면 배너(아이콘 타일+이름+타이머) / 확장 아일랜드(leading 아이콘+이름, trailing 타이머, bottom "+N개 함께 추적 중") / compact(아이콘+타이머+N) / minimal(아이콘)
- UI: 잠금화면 배너(아이콘 타일+이름+타이머+**종료 버튼** — ⑥과 같은 NowRunningStopButton/RunActionIntent, ContentState.actionID(optional — 구버전 인플라이트 액티비티 호환)로 대표 식별. 배너 뷰 LockScreenActivityView는 NowRunningWidget.swift에 정의 — @main 파일은 앱 타깃 제외라 앱 내 위젯 미리보기에서 렌더하기 위함) / 확장 아일랜드(leading 아이콘+이름, trailing 타이머, bottom "+N개 함께 추적 중") / compact(아이콘+타이머+N) / minimal(아이콘). 위젯 미리보기(-widgetPreview) 최상단에 배너 박스 포함
---

View File

@ -45,7 +45,8 @@ enum LiveActivityManager {
symbolName: action.symbolName,
colorHex: action.sortedTags.first?.colorHex ?? "#2F6B4F",
startedAt: representative.startAt,
extraCount: max(running.count - 1, 0)
extraCount: max(running.count - 1, 0),
actionID: action.uuid.uuidString
)
let content = ActivityContent(state: state, staleDate: nil)

View File

@ -6589,18 +6589,18 @@
}
}
},
"시간을 측정 중이면 다이나믹 아일랜드와 잠금화면에 진행 시간이 표시돼요. 여러 개를 동시에 측정하면 설정의 '대표 시간 기준'(먼저/나중에 시작한 행동)에 따라 하나가 대표로 보이고 나머지는 +N으로 표시돼요.": {
"시간을 측정 중이면 다이나믹 아일랜드와 잠금화면에 진행 시간이 표시돼요. 여러 개를 동시에 측정하면 설정의 '대표 시간 기준'(먼저/나중에 시작한 행동)에 따라 하나가 대표로 보이고 나머지는 +N으로 표시돼요. 잠금화면 배너의 노란 정지 버튼을 누르면 앱을 열지 않고도 대표 측정이 바로 종료돼요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "While tracking time, the elapsed time appears in the Dynamic Island and on the Lock Screen. When tracking several actions at once, one is shown as the primary based on the 'Primary Timer' setting (first or most recently started), and the rest appear as +N."
"value": "While tracking time, the Dynamic Island and Lock Screen show the elapsed time. When tracking several actions at once, one is shown as the representative based on the 'Representative time' setting (earliest/latest started) and the rest appear as +N. Tap the yellow stop button on the Lock Screen banner to stop the representative tracking right away without opening the app."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "時間を計測中はDynamic Islandとロック画面に経過時間が表示されます。複数を同時に計測している場合は、設定の「代表タイマー基準」(先に/後に開始したアクションに従って1つが代表として表示され、残りは+Nで示されます。"
"value": "時間を計測中はDynamic Islandとロック画面に経過時間が表示されます。複数を同時に計測すると、設定の「代表時間の基準」(先に/後に開始したアクションに従って1つが代表として表示され、残りは+Nで示されます。ロック画面バナーの黄色い停止ボタンをタップすると、アプリを開かずに代表の計測をすぐ終了できます。"
}
}
}

View File

@ -343,7 +343,7 @@ struct HelpView: View {
HelpTopic(
symbol: "bolt.horizontal.circle",
title: "다이나믹 아일랜드·잠금화면",
body: "시간을 측정 중이면 다이나믹 아일랜드와 잠금화면에 진행 시간이 표시돼요. 여러 개를 동시에 측정하면 설정의 '대표 시간 기준'(먼저/나중에 시작한 행동)에 따라 하나가 대표로 보이고 나머지는 +N으로 표시돼요."
body: "시간을 측정 중이면 다이나믹 아일랜드와 잠금화면에 진행 시간이 표시돼요. 여러 개를 동시에 측정하면 설정의 '대표 시간 기준'(먼저/나중에 시작한 행동)에 따라 하나가 대표로 보이고 나머지는 +N으로 표시돼요. 잠금화면 배너의 노란 정지 버튼을 누르면 앱을 열지 않고도 대표 측정이 바로 종료돼요."
),
HelpTopic(
symbol: "switch.2",

View File

@ -158,6 +158,7 @@ struct WidgetPreviewScreen: View {
@ViewBuilder
private var homeSection: some View {
liveActivityRow
actionRows
goalRows
ringRows
@ -166,6 +167,24 @@ struct WidgetPreviewScreen: View {
nowRunningRows
}
/// ( )
/// · ( )
@ViewBuilder
private var liveActivityRow: some View {
row("잠금화면 실시간 현황 (측정 종료 버튼)") {
LockScreenActivityView(state: .init(
actionName: String(localized: "독서"),
symbolName: "book.fill",
colorHex: "#4A7A9D",
startedAt: .now.addingTimeInterval(-25 * 60),
extraCount: 1,
actionID: UUID().uuidString
))
.background(.background, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.frame(width: 372)
}
}
@ViewBuilder
private var actionRows: some View {
if let small = actionSmall, let medium = actionMedium {

View File

@ -18,6 +18,10 @@ nonisolated struct TrackingActivityAttributes: ActivityAttributes {
var startedAt: Date
/// (+1, +2 )
var extraCount: Int
/// Action.uuid (RunActionIntent) .
/// optional : ( )
/// (WatchSnapshot.runningActionID )
var actionID: String? = nil
}
}

View File

@ -87,41 +87,6 @@ struct TrackingLiveActivity: Widget {
}
}
// MARK: -
struct LockScreenActivityView: View {
let state: TrackingActivityAttributes.ContentState
var body: some View {
HStack(spacing: 12) {
Image(systemName: state.symbolName)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 40, height: 40)
.background(
state.tintColor,
in: RoundedRectangle(cornerRadius: 11, style: .continuous)
)
VStack(alignment: .leading, spacing: 2) {
Text(state.actionName)
.font(.headline)
.lineLimit(1)
if state.extraCount > 0 {
Text("+\(state.extraCount)개 함께 추적 중")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("측정 중")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Text(timerInterval: state.timerRange, countsDown: false)
.font(.title2.weight(.bold).monospacedDigit())
.multilineTextAlignment(.trailing)
.frame(maxWidth: 90)
}
.padding(14)
}
}
// (LockScreenActivityView) NowRunningWidget.swift
// @main ,
// (-widgetPreview) ( )

View File

@ -448,3 +448,49 @@ struct NowRunningWidget: Widget {
.contentMarginsDisabled()
}
}
// MARK: -
// (HaruDanimWidgets.swift @main
// . ActivityConfiguration TrackingLiveActivity)
struct LockScreenActivityView: View {
let state: TrackingActivityAttributes.ContentState
var body: some View {
HStack(spacing: 12) {
Image(systemName: state.symbolName)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 40, height: 40)
.background(
state.tintColor,
in: RoundedRectangle(cornerRadius: 11, style: .continuous)
)
VStack(alignment: .leading, spacing: 2) {
Text(state.actionName)
.font(.headline)
.lineLimit(1)
if state.extraCount > 0 {
Text("+\(state.extraCount)개 함께 추적 중")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("측정 중")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Text(timerInterval: state.timerRange, countsDown: false)
.font(.title2.weight(.bold).monospacedDigit())
.multilineTextAlignment(.trailing)
.frame(maxWidth: 90)
// (RunActionIntent ).
// (actionID )
if let id = state.actionID.flatMap(UUID.init(uuidString:)) {
NowRunningStopButton(actionID: id, size: 36)
}
}
.padding(14)
}
}