feat(widget): add Now Tracking home widget — live timer with a stop button

새 홈 위젯 ⑥ '현재 진행 중'(HaruNowRunningWidget, 소형/중형/대형):

- 측정 중이 아니면 빈 상태 안내("지금 측정 중인 행동이 없어요")
- 측정이 시작되면 대표 행동(설정 '대표 시간 기준' — 다이나믹
  아일랜드와 같은 규칙)의 꼬리표 색 배경 위에 이름·시작 시각·
  실시간 타이머(Text(style:.timer))·종료 버튼을 표시
- 여러 개 측정 중이면 소형/중형은 "+N개 함께 추적 중", 대형은
  함께 측정 중인 행동을 줄로 나열하고 각각 종료 가능(4개 초과분은
  "외 N개"), 대표 하나뿐이면 오늘 누적(실시간)·시작 시각 카드
- 종료는 기존 RunActionIntent(시간형 토글) 재사용 — 짧은 기록
  무시 규칙·프리미엄 게이트·히트 영역(contentShape) 규칙 동일 적용
- 타임라인은 ①과 같은 예산 전략(주기 폴링 없음, 날짜 경계 안전망),
  탭 즉시 피드백(invalidatableContent), 틴트/클리어 렌더링 모드 대응
- 갤러리 미리보기는 측정 중이 아니어도 샘플로 모습 표시

검증: Debug·Store 빌드 성공, 앱 내 위젯 미리보기(-widgetPreview,
앵커 R/RL/RM/RE)로 소·중·대형/여러 개/빈 상태 스크린샷 확인.
l10n: 신규 문자열 ko/en/ja 번역(IOS·Widgets 카탈로그 missing 0,
stale 0 — 문구 수정으로 생긴 stale 1건 삭제, 잘못 stale로 남아
있던 위젯 전용 키 3건은 정상 상태로 복구). 도움말 위젯 항목과
CLAUDE.md §10에 ⑥ 추가.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
songyc macbook 2026-07-14 17:24:07 +09:00
parent 7720ed5925
commit 49cada45c7
8 changed files with 7979 additions and 7255 deletions

View File

@ -237,7 +237,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${files[@]}"
## 10. 홈·잠금화면 위젯 (`Widgets/` — 모두 프리미엄 게이트)
번들: TrackingLiveActivity + 5종 홈 위젯 + 잠금화면 1종. 모든 홈 위젯 공통 설정: **테마(앱 일치/라이트/다크)**. 홈 화면이 틴트/클리어(리퀴드 글라스) 렌더링 모드면 커스텀 배경·강제 스킴을 얹지 않고 시스템 바이브런트에 맡김(white-on-white 방어, `WidgetTheme.swift`).
번들: TrackingLiveActivity + 6종 홈 위젯 + 잠금화면 1종. 모든 홈 위젯 공통 설정: **테마(앱 일치/라이트/다크)**. 홈 화면이 틴트/클리어(리퀴드 글라스) 렌더링 모드면 커스텀 배경·강제 스킴을 얹지 않고 시스템 바이브런트에 맡김(white-on-white 방어, `WidgetTheme.swift`).
| 위젯 (kind) | 내용 |
|---|---|
@ -246,6 +246,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${files[@]}"
| ③ 다짐 진행률 `HaruQuestRingWidget` | 원형 링(행동 대상이면 눌러 실행). 기간 선택. 소형1/중형2/대형4. atMost는 "한도 지킴/초과". 퍼센트(한도) 문구 아래에 선택 기간의 **실제 누적값**("1시간 20분"/"3회") 한 줄 — atMost는 퍼센트가 없어 이 줄이 유일한 현재 수치(진행률 캡과 무관한 원본 값, 미리보기 앵커 CM) |
| ④ 다짐 현황 `HaruGoalQuestGridWidget` | 표시 전용 링 그리드 + 목표 헤더. 소형 2×2 / 중형 4×2 / 대형: 다짐16 / 목표4 / 목표2 |
| ⑤ 행동 통계 `HaruStatsChartWidget` | 꺾은선(행동 색). 기간: 일주일/한 달(일별, 소형 제외→주별 대체)/한 달(주별). 선 수 제한 소2/중4/대6 |
| ⑥ 현재 진행 중 `HaruNowRunningWidget` | 측정 중인 대표 행동(설정 '대표 시간 기준' — 다이나믹 아일랜드와 같은 규칙)을 꼬리표 색 배경 + 실시간 타이머(`Text(style:.timer)`) + 종료 버튼(`RunActionIntent` 토글의 종료 방향)으로 표시. 소형/중형: 대표 1개 + "+N개 함께 추적 중". 대형: 대표 카드 + 함께 측정 중 나열(각각 종료 버튼, 4개 초과분은 "외 N개") / 단독이면 오늘 누적(실시간)·시작 시각 카드. 측정 중 아니면 빈 상태 안내, 갤러리 미리보기는 샘플 표시 |
| 잠금화면 `HaruLockGoalWidget` | 목표 1개 달성률 — 원형 게이지/숫자만/다짐 점(달성=채움). circular/rectangular/inline |
| 제어 센터 `HaruActionRunControl` | iOS 18 ControlWidget — 행동 1개 토글(시간형 시작/종료, 횟수형 +1). `ToggleActionControlIntent`(SetValueIntent+LiveActivityIntent, 앱 프로세스 실행) |

View File

@ -111,13 +111,6 @@
);
target = 725A9E832FFCC65000846FFB /* Haru_Danim */;
};
AA0000000000000000000013 /* Exceptions for "IOS" folder in "Haru_Danim" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 725A9E832FFCC65000846FFB /* Haru_Danim */;
};
AA0000000000000000000012 /* Exceptions for "Haru_DanimWatchWidgets" folder in "Haru_DanimWatch Watch App" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
@ -129,6 +122,13 @@
);
target = 7263D0DF3000433300EA47FC /* Haru_DanimWatch Watch App */;
};
AA0000000000000000000013 /* Exceptions for "IOS" folder in "Haru_Danim" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 725A9E832FFCC65000846FFB /* Haru_Danim */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */

File diff suppressed because it is too large Load Diff

View File

@ -233,7 +233,7 @@ struct HelpView: View {
HelpTopic(
symbol: "square.grid.2x2.fill",
title: "홈 화면·잠금화면 위젯 (프리미엄)",
body: "홈 화면을 길게 눌러 '하루 다님' 위젯을 추가하세요. 행동 실행(눌러서 시작·+1), 목표 진행률, 다짐 진행률(원형 링·누적값), 다짐 현황, 행동 통계 그래프와 잠금화면용 목표 달성률이 있어요. 위젯을 길게 눌러 '위젯 편집'에서 표시할 행동·목표·다짐과 기간, 테마를 고를 수 있어요."
body: "홈 화면을 길게 눌러 '하루 다님' 위젯을 추가하세요. 행동 실행(눌러서 시작·+1), 현재 진행 중(측정 중인 행동의 실시간 타이머와 종료 버튼), 목표 진행률, 다짐 진행률(원형 링·누적값), 다짐 현황, 행동 통계 그래프와 잠금화면용 목표 달성률이 있어요. 위젯을 길게 눌러 '위젯 편집'에서 표시할 행동·목표·다짐과 기간, 테마를 고를 수 있어요."
),
HelpTopic(
symbol: "bolt.horizontal.circle",

View File

@ -42,6 +42,10 @@ struct WidgetPreviewScreen: View {
@State private var statsSmall: StatsChartEntry?
@State private var statsMedium: StatsChartEntry?
@State private var statsLarge: StatsChartEntry?
//
@State private var nowEntry: NowRunningEntry?
@State private var nowMulti: NowRunningEntry?
@State private var nowEmpty: NowRunningEntry?
//
@State private var lockEntries: [(String, LockGoalEntry)] = []
@ -142,6 +146,12 @@ struct WidgetPreviewScreen: View {
statsSmall = StatsChartProvider.makeEntry(statsConfig, family: .systemSmall)
statsMedium = StatsChartProvider.makeEntry(statsConfig, family: .systemMedium)
statsLarge = StatsChartProvider.makeEntry(statsConfig, family: .systemLarge)
// : ( ) + /
nowEntry = NowRunningProvider.makeEntry(NowRunningConfigIntent())
nowMulti = .sample()
nowEmpty = NowRunningEntry(date: .now, locked: false, theme: .matchApp,
primary: nil, others: [], todayTickingBase: nil)
}
// MARK:
@ -153,6 +163,7 @@ struct WidgetPreviewScreen: View {
ringRows
statusRows
statsRows
nowRunningRows
}
@ViewBuilder
@ -281,6 +292,34 @@ struct WidgetPreviewScreen: View {
}
}
@ViewBuilder
private var nowRunningRows: some View {
if let entry = nowEntry {
row("⑥ 현재 진행 중 · 소형/중형") {
widgetBox(.systemSmall) { NowRunningWidgetView(previewFamily: .systemSmall, entry: entry) }
widgetBox(.systemMedium) { NowRunningWidgetView(previewFamily: .systemMedium, entry: entry) }
}
.id("R")
row("⑥ 현재 진행 중 · 대형") {
widgetBox(.systemLarge) { NowRunningWidgetView(previewFamily: .systemLarge, entry: entry) }
}
.id("RL")
}
if let multi = nowMulti {
row("⑥ 현재 진행 중 · 대형(여러 개, 샘플)") {
widgetBox(.systemLarge) { NowRunningWidgetView(previewFamily: .systemLarge, entry: multi) }
}
.id("RM")
}
if let empty = nowEmpty {
row("⑥ 현재 진행 중 · 빈 상태(소형/중형)") {
widgetBox(.systemSmall) { NowRunningWidgetView(previewFamily: .systemSmall, entry: empty) }
widgetBox(.systemMedium) { NowRunningWidgetView(previewFamily: .systemMedium, entry: empty) }
}
.id("RE")
}
}
// MARK:
@ViewBuilder

View File

@ -13,12 +13,13 @@ import ActivityKit
struct HaruDanimWidgetsBundle: WidgetBundle {
var body: some Widget {
TrackingLiveActivity()
// 5 Configuration AppIntent (CLAUDE.md §8)
// 6 Configuration AppIntent (CLAUDE.md §8)
ActionRunWidget() //
GoalProgressWidget() //
QuestRingWidget() // ()
QuestStatusWidget() // ( )
StatsChartWidget() //
NowRunningWidget() // ( + )
LockGoalWidget() //
ActionRunControl() // ( )
}

View File

@ -1,98 +1,6 @@
{
"sourceLanguage": "ko",
"strings": {
"'%@ · %@' %@ 진행률: %@": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "'%@ · %@' %@ progress: %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "「%@ · %@」の%@進捗率: %@"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "'%1$@ · %2$@' %3$@ 진행률: %4$@"
}
}
}
},
"'%@' %@ 진행률은 %lld%%예요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "'%@' %@ progress is %lld%%."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "「%@」の%@進捗率は%lld%%です。"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "'%1$@' %2$@ 진행률은 %3$lld%%예요."
}
}
}
},
"'%@' 목표에는 아직 다짐이 없어요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "The goal '%@' has no quests yet."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "目標「%@」にはまだクエストがありません。"
}
}
}
},
"'%@'은(는) 시간 측정 행동이에요. 측정 시작/종료를 사용해 주세요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "'%@' is a time-tracking action. Use start/stop tracking instead."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "「%@」は時間計測タイプです。計測の開始/終了を使ってください。"
}
}
}
},
"'%@'은(는) 횟수 기록 행동이에요. 횟수 추가를 사용해 주세요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "'%@' is a count action. Use add count instead."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "「%@」は回数記録タイプです。回数の追加を使ってください。"
}
}
}
},
"%@": {
"localizations": {
"en": {
@ -109,28 +17,6 @@
}
}
},
"%@ · %@": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "%@ · %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%@ · %@"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "%1$@ · %2$@"
}
}
}
},
"%@ %@": {
"localizations": {
"en": {
@ -241,6 +127,44 @@
}
}
},
"%@ · %@": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "%@ · %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%@ · %@"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "%1$@ · %2$@"
}
}
}
},
"%@ 시작": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Started %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%@開始"
}
}
}
},
"%@ 측정을 시작했어요.": {
"localizations": {
"en": {
@ -510,6 +434,98 @@
}
}
},
"'%@ · %@' %@ 진행률: %@": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "'%@ · %@' %@ progress: %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "「%@ · %@」の%@進捗率: %@"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "'%1$@ · %2$@' %3$@ 진행률: %4$@"
}
}
}
},
"'%@' %@ 진행률은 %lld%%예요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "'%@' %@ progress is %lld%%."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "「%@」の%@進捗率は%lld%%です。"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "'%1$@' %2$@ 진행률은 %3$lld%%예요."
}
}
}
},
"'%@' 목표에는 아직 다짐이 없어요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "The goal '%@' has no quests yet."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "目標「%@」にはまだクエストがありません。"
}
}
}
},
"'%@'은(는) 시간 측정 행동이에요. 측정 시작/종료를 사용해 주세요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "'%@' is a time-tracking action. Use start/stop tracking instead."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "「%@」は時間計測タイプです。計測の開始/終了を使ってください。"
}
}
}
},
"'%@'은(는) 횟수 기록 행동이에요. 횟수 추가를 사용해 주세요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "'%@' is a count action. Use add count instead."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "「%@」は回数記録タイプです。回数の追加を使ってください。"
}
}
}
},
"+%lld": {
"localizations": {
"en": {
@ -542,6 +558,54 @@
}
}
},
"0분": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "0m"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "0分"
}
}
}
},
"10초": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "10 sec"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "10秒"
}
}
}
},
"12시간": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "12 hours"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "12時間"
}
}
}
},
"1분": {
"localizations": {
"en": {
@ -590,6 +654,22 @@
}
}
},
"30초": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "30 sec"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "30秒"
}
}
}
},
"3시간": {
"localizations": {
"en": {
@ -654,54 +734,6 @@
}
}
},
"10초": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "10 sec"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "10秒"
}
}
}
},
"12시간": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "12 hours"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "12時間"
}
}
}
},
"30초": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "30 sec"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "30秒"
}
}
}
},
"가장 나중에 시작한 행동": {
"localizations": {
"en": {
@ -1297,12 +1329,6 @@
},
"매달 %lld번째 %@요일": {
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "매달 %1$lld번째 %2$@요일"
}
},
"en": {
"stringUnit": {
"state": "translated",
@ -1314,6 +1340,12 @@
"state": "translated",
"value": "毎月第%lld%@曜日"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "매달 %1$lld번째 %2$@요일"
}
}
}
},
@ -1414,22 +1446,6 @@
}
}
},
"목표 1개 · 다짐 16개": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "1 goal · 16 quests"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "目標1件・クエスト16件"
}
}
}
},
"목표 1개 + 다짐 진행률": {
"localizations": {
"en": {
@ -1462,6 +1478,22 @@
}
}
},
"목표 1개 · 다짐 16개": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "1 goal · 16 quests"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "目標1件・クエスト16件"
}
}
}
},
"목표 2개": {
"localizations": {
"en": {
@ -1654,22 +1686,6 @@
}
}
},
"목표의 하루·주간·월간 진행률을 진행바로 확인해요. (프리미엄)": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Track a goal's daily, weekly, and monthly progress with bars. (Premium)"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "目標の日・週・月の進捗をバーで確認。(プレミアム)"
}
}
}
},
"목표의 하루/주간/월간 진행률(%)을 가져와요. 소속 다짐들의 진행률 평균이에요.": {
"localizations": {
"en": {
@ -1686,6 +1702,22 @@
}
}
},
"목표의 하루·주간·월간 진행률을 진행바로 확인해요. (프리미엄)": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Track a goal's daily, weekly, and monthly progress with bars. (Premium)"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "目標の日・週・月の進捗をバーで確認。(プレミアム)"
}
}
}
},
"물 마시기": {
"localizations": {
"en": {
@ -1943,6 +1975,22 @@
}
}
},
"시작 시각": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Start time"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "開始時刻"
}
}
}
},
"실행 상태": {
"localizations": {
"en": {
@ -2103,6 +2151,22 @@
}
}
},
"외 %lld개 측정 중": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "and %lld more tracking"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ほかに%lld件計測中"
}
}
}
},
"요일 지정": {
"localizations": {
"en": {
@ -2184,55 +2248,6 @@
}
}
},
"이번 주 누적": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "This week's total"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "今週の累計"
}
}
}
},
"이상 달성": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Reach at least"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "以上を達成"
}
}
}
},
"일": {
"comment": "요일 축약(일요일)",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Sun"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "日"
}
}
}
},
"이번 달": {
"localizations": {
"en": {
@ -2281,6 +2296,38 @@
}
}
},
"이번 주 누적": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "This week's total"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "今週の累計"
}
}
}
},
"이상 달성": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Reach at least"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "以上を達成"
}
}
}
},
"이하 유지": {
"localizations": {
"en": {
@ -2297,6 +2344,23 @@
}
}
},
"일": {
"comment": "요일 축약(일요일)",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Sun"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "日"
}
}
}
},
"일주일 (하루 단위)": {
"localizations": {
"en": {
@ -2489,6 +2553,22 @@
}
}
},
"지금 측정 중인 행동이 없어요": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "No action is being tracked right now"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "いま計測中の行動はありません"
}
}
}
},
"진행 중": {
"localizations": {
"en": {
@ -2569,6 +2649,38 @@
}
}
},
"측정 중인 행동을 실시간으로 보고 위젯에서 바로 종료해요. (프리미엄)": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "See what you are tracking in real time and stop it right from the widget. (Premium)"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "計測中の行動をリアルタイムで確認し、ウィジェットから即終了。(プレミアム)"
}
}
}
},
"측정 중인 행동을 실시간으로 보여 주고 바로 종료할 수 있어요. 테마를 선택하세요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Shows the action being tracked in real time and lets you stop it right away. Choose a theme."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "計測中の行動をリアルタイムで表示し、すぐに終了できます。テーマを選んでください。"
}
}
}
},
"측정 중인 행동의 시간 측정을 종료해요.": {
"localizations": {
"en": {
@ -3114,6 +3226,22 @@
}
}
},
"행동을 시작하면 여기에 실시간으로 표시돼요": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Start an action and it will appear here in real time"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "行動を始めるとここにリアルタイムで表示されます"
}
}
}
},
"행동을 위젯에서 바로 실행하고 누적값을 확인해요. (프리미엄)": {
"localizations": {
"en": {
@ -3162,6 +3290,38 @@
}
}
},
"현재 진행 중": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Now Tracking"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "現在計測中"
}
}
}
},
"현재 진행 중 위젯": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Now Tracking Widget"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "現在計測中ウィジェット"
}
}
}
},
"화": {
"comment": "요일 축약(화요일)",
"localizations": {
@ -3242,22 +3402,6 @@
}
}
}
},
"0분": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "0m"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "0分"
}
}
}
}
},
"version": "1.0"

View File

@ -0,0 +1,446 @@
//
// NowRunningWidget.swift
// Haru_DanimWidgets
//
// (CLAUDE.md §10)
// , (Interactive).
// - :
// - : ( ' ' )
// + + +
// - : · 1 + "+N ",
//
//
import SwiftUI
import SwiftData
import WidgetKit
import AppIntents
// MARK: -
struct NowRunningConfigIntent: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "현재 진행 중 위젯"
static let description = IntentDescription("측정 중인 행동을 실시간으로 보여 주고 바로 종료할 수 있어요. 테마를 선택하세요.")
@Parameter(title: "테마", default: .matchApp)
var theme: WidgetThemeOption
}
// MARK: -
///
struct RunningItemSnapshot: Identifiable {
/// Action.uuid (RunActionIntent)
let id: UUID
let name: String
let symbolName: String
let colorHex: String
let startAt: Date
var color: Color { Color(hex: colorHex) }
}
struct NowRunningEntry: TimelineEntry {
let date: Date
let locked: Bool
let theme: WidgetThemeOption
/// ( ' ' ). nil =
let primary: RunningItemSnapshot?
/// ( )
let others: [RunningItemSnapshot]
/// (now , )
let todayTickingBase: Date?
}
extension NowRunningEntry {
/// · ( 2 )
static func sample(theme: WidgetThemeOption = .matchApp) -> NowRunningEntry {
let now = Date.now
return NowRunningEntry(
date: now, locked: false, theme: theme,
primary: RunningItemSnapshot(id: UUID(), name: String(localized: "독서"),
symbolName: "book.fill", colorHex: "#2F6B4F",
startAt: now.addingTimeInterval(-45 * 60)),
others: [
RunningItemSnapshot(id: UUID(), name: String(localized: "달리기"),
symbolName: "figure.run", colorHex: "#9D5B4A",
startAt: now.addingTimeInterval(-12 * 60)),
],
todayTickingBase: now.addingTimeInterval(-65 * 60)
)
}
}
struct NowRunningProvider: AppIntentTimelineProvider {
@MainActor
static func makeEntry(_ configuration: NowRunningConfigIntent, now: Date = .now) -> NowRunningEntry {
IntentStore.refresh()
guard WidgetStore.isUnlocked else {
return NowRunningEntry(date: now, locked: true, theme: configuration.theme,
primary: nil, others: [], todayTickingBase: nil)
}
let descriptor = FetchDescriptor<TimeSession>(
predicate: #Predicate { $0.endAt == nil },
sortBy: [SortDescriptor(\.startAt, order: .forward)]
)
let running = ((try? IntentStore.context.fetch(descriptor)) ?? []).filter { $0.action != nil }
// (LiveActivityManager)
let mode = LiveActivityMode(
rawValue: AppGroup.defaults.string(forKey: SettingsKeys.liveActivityMode)
?? UserDefaults.standard.string(forKey: SettingsKeys.liveActivityMode) ?? ""
) ?? .latest
guard let session = (mode == .earliest ? running.first : running.last),
let action = session.action else {
return NowRunningEntry(date: now, locked: false, theme: configuration.theme,
primary: nil, others: [], todayTickingBase: nil)
}
let others: [RunningItemSnapshot] = running.filter { $0 !== session }.compactMap { other in
guard let otherAction = other.action else { return nil }
return RunningItemSnapshot(
id: otherAction.uuid, name: otherAction.name, symbolName: otherAction.symbolName,
colorHex: otherAction.sortedTags.first?.colorHex ?? "#2F6B4F", startAt: other.startAt
)
}
let math = DayMath()
let todaySeconds = Aggregator(math: math)
.seconds(for: action, in: math.dayRange(containing: now), now: now)
return NowRunningEntry(
date: now, locked: false, theme: configuration.theme,
primary: RunningItemSnapshot(
id: action.uuid, name: action.name, symbolName: action.symbolName,
colorHex: action.sortedTags.first?.colorHex ?? "#2F6B4F", startAt: session.startAt
),
others: others,
todayTickingBase: now.addingTimeInterval(-todaySeconds)
)
}
func placeholder(in context: Context) -> NowRunningEntry {
.sample()
}
@MainActor
func snapshot(for configuration: NowRunningConfigIntent, in context: Context) async -> NowRunningEntry {
let entry = Self.makeEntry(configuration)
//
if context.isPreview, entry.locked || entry.primary == nil {
return .sample(theme: configuration.theme)
}
return entry
}
@MainActor
func timeline(for configuration: NowRunningConfigIntent, in context: Context) async -> Timeline<NowRunningEntry> {
// Text(style:.timer) , /
// DataChange.commit/ ( )
let first = Self.makeEntry(configuration)
return WidgetRefresh.timeline(first: first, live: false) { _ in first }
}
}
// MARK: -
struct NowRunningWidgetView: View {
@Environment(\.widgetFamily) private var envFamily
/// DEBUG
var previewFamily: WidgetFamily? = nil
private var family: WidgetFamily { previewFamily ?? envFamily }
let entry: NowRunningEntry
var body: some View {
Group {
if entry.locked {
WidgetLockedView()
.padding(12)
} else if let primary = entry.primary {
switch family {
case .systemMedium:
NowRunningHeroView(primary: primary, extraCount: entry.others.count, fullBleed: true)
case .systemLarge:
NowRunningLargeView(primary: primary, others: entry.others,
todayTickingBase: entry.todayTickingBase)
default:
NowRunningSmallView(primary: primary, extraCount: entry.others.count)
}
} else {
emptyView
}
}
.widgetAppTheme(entry.theme)
}
///
private var emptyView: some View {
VStack(spacing: 6) {
Image(systemName: "timer")
.font(.title3)
.foregroundStyle(.secondary)
Text("지금 측정 중인 행동이 없어요")
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if family != .systemSmall {
Text("행동을 시작하면 여기에 실시간으로 표시돼요")
.font(.caption2)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
}
}
.padding(12)
}
}
/// ( '' )
struct NowRunningStopButton: View {
@Environment(\.widgetRenderingMode) private var renderingMode
let actionID: UUID
var size: CGFloat = 32
var body: some View {
Button(intent: RunActionIntent(actionID: actionID.uuidString)) {
Image(systemName: "stop.fill")
.font(.system(size: size * 0.38, weight: .bold))
.foregroundStyle(renderingMode == .fullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.frame(width: size, height: size)
.background(
renderingMode == .fullColor
? AnyShapeStyle(AppTheme.yellow)
: AnyShapeStyle(Color.white.opacity(0.14)),
in: Circle()
)
// ' ' ( )
.invalidatableContent()
// (§10 )
.contentShape(.rect)
}
.buttonStyle(.plain)
}
}
/// : ()
struct NowRunningSmallView: View {
@Environment(\.widgetRenderingMode) private var renderingMode
let primary: RunningItemSnapshot
let extraCount: Int
private var isFullColor: Bool { renderingMode == .fullColor }
var body: some View {
VStack(alignment: .leading, spacing: 3) {
HStack {
Image(systemName: primary.symbolName)
.font(.system(size: 18, weight: .semibold))
Spacer()
Image(systemName: "record.circle")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(isFullColor ? AnyShapeStyle(AppTheme.yellow) : AnyShapeStyle(.primary))
}
Spacer(minLength: 0)
Text(primary.name)
.font(.caption.weight(.semibold))
.lineLimit(1)
HStack(alignment: .bottom) {
VStack(alignment: .leading, spacing: 1) {
Text(primary.startAt, style: .timer)
.font(.title3.weight(.bold).monospacedDigit())
.lineLimit(1)
.minimumScaleFactor(0.6)
.invalidatableContent()
if extraCount > 0 {
Text("+\(extraCount)개 함께 추적 중")
.font(.system(size: 9))
.opacity(0.85)
.lineLimit(1)
}
}
Spacer(minLength: 4)
NowRunningStopButton(actionID: primary.id, size: 30)
}
}
.foregroundStyle(isFullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.padding(14)
.background(WidgetTintedCellBackground(
color: isFullColor ? primary.color : primary.color.opacity(0.2),
fullBleed: true
))
}
}
/// (fullBleed),
struct NowRunningHeroView: View {
@Environment(\.widgetRenderingMode) private var renderingMode
let primary: RunningItemSnapshot
/// "+N " ( 0)
var extraCount = 0
var fullBleed = false
private var isFullColor: Bool { renderingMode == .fullColor }
var body: some View {
HStack(spacing: 12) {
Image(systemName: primary.symbolName)
.font(.system(size: 22, weight: .semibold))
.frame(width: 46, height: 46)
.background(
Color.white.opacity(isFullColor ? 0.22 : 0.14),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 5) {
Text(primary.name)
.font(.headline)
.lineLimit(1)
Image(systemName: "record.circle")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(isFullColor ? AnyShapeStyle(AppTheme.yellow) : AnyShapeStyle(.primary))
}
Text("\(primary.startAt, format: .dateTime.hour().minute()) 시작")
.font(.caption)
.opacity(0.85)
if extraCount > 0 {
Text("+\(extraCount)개 함께 추적 중")
.font(.caption2)
.opacity(0.85)
}
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 8) {
Text(primary.startAt, style: .timer)
.font(.title2.weight(.bold).monospacedDigit())
.multilineTextAlignment(.trailing)
.frame(maxWidth: 105, alignment: .trailing)
.lineLimit(1)
.minimumScaleFactor(0.6)
.invalidatableContent()
NowRunningStopButton(actionID: primary.id, size: 32)
}
}
.foregroundStyle(isFullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.padding(16)
.background(WidgetTintedCellBackground(
color: isFullColor ? primary.color : primary.color.opacity(0.2),
fullBleed: fullBleed
))
}
}
/// : + [ ( )] [ · ]
struct NowRunningLargeView: View {
let primary: RunningItemSnapshot
let others: [RunningItemSnapshot]
let todayTickingBase: Date?
/// ( " N")
private static let maxRows = 4
var body: some View {
VStack(spacing: 8) {
NowRunningHeroView(primary: primary)
if others.isEmpty {
todayCard
} else {
othersList
}
}
.padding(10)
}
/// ( )
private var othersList: some View {
VStack(spacing: 6) {
ForEach(Array(others.prefix(Self.maxRows).enumerated()), id: \.offset) { _, item in
NowRunningRowView(item: item)
}
if others.count > Self.maxRows {
Text("\(others.count - Self.maxRows)개 측정 중")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
/// : ()
private var todayCard: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("오늘 누적")
.font(.caption)
.foregroundStyle(.secondary)
if let base = todayTickingBase {
Text(base, style: .timer)
.font(.headline.monospacedDigit())
.invalidatableContent()
}
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("시작 시각")
.font(.caption)
.foregroundStyle(.secondary)
Text(primary.startAt, format: .dateTime.hour().minute())
.font(.headline.monospacedDigit())
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(WidgetCellBackground())
}
}
/// ' ' +
struct NowRunningRowView: View {
@Environment(\.widgetRenderingMode) private var renderingMode
let item: RunningItemSnapshot
private var isFullColor: Bool { renderingMode == .fullColor }
var body: some View {
HStack(spacing: 10) {
Image(systemName: item.symbolName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(isFullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.frame(width: 30, height: 30)
.background(
isFullColor ? AnyShapeStyle(item.color) : AnyShapeStyle(item.color.opacity(0.2)),
in: RoundedRectangle(cornerRadius: 9, style: .continuous)
)
Text(item.name)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Spacer(minLength: 6)
Text(item.startAt, style: .timer)
.font(.subheadline.weight(.semibold).monospacedDigit())
.multilineTextAlignment(.trailing)
.frame(maxWidth: 80, alignment: .trailing)
.lineLimit(1)
.minimumScaleFactor(0.6)
.invalidatableContent()
NowRunningStopButton(actionID: item.id, size: 28)
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(WidgetCellBackground())
}
}
// MARK: -
struct NowRunningWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: "HaruNowRunningWidget",
intent: NowRunningConfigIntent.self,
provider: NowRunningProvider()
) { entry in
NowRunningWidgetView(entry: entry)
}
.configurationDisplayName("현재 진행 중")
.description("측정 중인 행동을 실시간으로 보고 위젯에서 바로 종료해요. (프리미엄)")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
.contentMarginsDisabled()
}
}