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:
parent
7c8e882cda
commit
e1537b32f3
@ -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) 최상단에 배너 박스 포함
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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で表示されます。ロック画面バナーの黄色い停止ボタンをタップすると、アプリを開かずに代表の計測をすぐ終了できます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -343,7 +343,7 @@ struct HelpView: View {
|
||||
HelpTopic(
|
||||
symbol: "bolt.horizontal.circle",
|
||||
title: "다이나믹 아일랜드·잠금화면",
|
||||
body: "시간을 측정 중이면 다이나믹 아일랜드와 잠금화면에 진행 시간이 표시돼요. 여러 개를 동시에 측정하면 설정의 '대표 시간 기준'(먼저/나중에 시작한 행동)에 따라 하나가 대표로 보이고 나머지는 +N으로 표시돼요."
|
||||
body: "시간을 측정 중이면 다이나믹 아일랜드와 잠금화면에 진행 시간이 표시돼요. 여러 개를 동시에 측정하면 설정의 '대표 시간 기준'(먼저/나중에 시작한 행동)에 따라 하나가 대표로 보이고 나머지는 +N으로 표시돼요. 잠금화면 배너의 노란 정지 버튼을 누르면 앱을 열지 않고도 대표 측정이 바로 종료돼요."
|
||||
),
|
||||
HelpTopic(
|
||||
symbol: "switch.2",
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)에서도 렌더해야 하기 때문 (종료 버튼 검증)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user