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:
parent
7720ed5925
commit
49cada45c7
@ -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, 앱 프로세스 실행) |
|
||||
|
||||
|
||||
@ -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
@ -233,7 +233,7 @@ struct HelpView: View {
|
||||
HelpTopic(
|
||||
symbol: "square.grid.2x2.fill",
|
||||
title: "홈 화면·잠금화면 위젯 (프리미엄)",
|
||||
body: "홈 화면을 길게 눌러 '하루 다님' 위젯을 추가하세요. 행동 실행(눌러서 시작·+1), 목표 진행률, 다짐 진행률(원형 링·누적값), 다짐 현황, 행동 통계 그래프와 잠금화면용 목표 달성률이 있어요. 위젯을 길게 눌러 '위젯 편집'에서 표시할 행동·목표·다짐과 기간, 테마를 고를 수 있어요."
|
||||
body: "홈 화면을 길게 눌러 '하루 다님' 위젯을 추가하세요. 행동 실행(눌러서 시작·+1), 현재 진행 중(측정 중인 행동의 실시간 타이머와 종료 버튼), 목표 진행률, 다짐 진행률(원형 링·누적값), 다짐 현황, 행동 통계 그래프와 잠금화면용 목표 달성률이 있어요. 위젯을 길게 눌러 '위젯 편집'에서 표시할 행동·목표·다짐과 기간, 테마를 고를 수 있어요."
|
||||
),
|
||||
HelpTopic(
|
||||
symbol: "bolt.horizontal.circle",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() // 제어 센터 컨트롤 (행동 실행)
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
446
myApp/HaruDanim/Widgets/NowRunningWidget.swift
Normal file
446
myApp/HaruDanim/Widgets/NowRunningWidget.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user