feat: major app overhaul with new features, UI fixes, and branding
[Bug Fixes] - fix: correct delete popup positioning - fix: hide bottom tab bar in detail menus to prevent UI overlap - fix: expand touchable area for icon selection buttons - fix: localize date display in the record tab based on device settings [New Features] - feat: open calendar popup when clicking the date in the record tab - feat: add memo option upon timer completion and display in records - feat: pin favorite actions to top and add tag-based ordering - feat: allow goal reordering and add collapse/expand toggle for sub-goals [Refactoring & Settings] - refactor: streamline bottom navigation to max 4 tabs with a 'More' tab - feat: allow users to customize visible tabs and count in settings [Branding] - chore: update app name to 'Haru Danim' (하루다님) - chore: add '당신의 하루에 다녀가다' slogan to the splash screen
This commit is contained in:
parent
3000ce6713
commit
f6e2963c88
@ -177,10 +177,12 @@
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 725A9E7F2FFCC65000846FFB /* Build configuration list for PBXProject "Haru_Danim" */;
|
||||
developmentRegion = en;
|
||||
developmentRegion = ko;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
ko,
|
||||
en,
|
||||
ja,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 725A9E7B2FFCC65000846FFB;
|
||||
@ -368,6 +370,7 @@
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "하루다님";
|
||||
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
@ -401,6 +404,7 @@
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "하루다님";
|
||||
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
|
||||
Binary file not shown.
@ -2,7 +2,7 @@
|
||||
// ContentView.swift
|
||||
// Haru_Danim
|
||||
//
|
||||
// 스플래시 + 6개 탭 구조 (CLAUDE.md §5)
|
||||
// 스플래시 + 탭 구조 (CLAUDE.md §5, 탭바 구성은 설정에서 변경 가능)
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
@ -37,7 +37,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 스플래시 (앱 로고 + 앱 이름, CLAUDE.md §5.1)
|
||||
// MARK: - 스플래시 (앱 로고 + 앱 이름 + 브랜드 멘트, CLAUDE.md §5.1)
|
||||
|
||||
struct SplashView: View {
|
||||
@State private var appeared = false
|
||||
@ -55,11 +55,11 @@ struct SplashView: View {
|
||||
.shadow(color: .black.opacity(0.12), radius: 14, y: 6)
|
||||
.scaleEffect(appeared ? 1 : 0.85)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
Text("하루 다님")
|
||||
Text("하루다님", comment: "앱 이름")
|
||||
.font(.largeTitle.bold())
|
||||
.foregroundStyle(AppTheme.green)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
Text("하루 습관 및 시간 추적")
|
||||
Text("당신의 하루에 다녀가다", comment: "스플래시 브랜드 멘트")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
@ -73,7 +73,7 @@ struct SplashView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 탭 구조 (총 6개, 순서 고정 — 시스템 탭바는 5개 초과 시 접히므로 커스텀 탭바 사용)
|
||||
// MARK: - 탭 정의
|
||||
|
||||
enum AppTab: String, CaseIterable, Identifiable {
|
||||
case main, action, tag, goal, history, settings
|
||||
@ -101,69 +101,106 @@ enum AppTab: String, CaseIterable, Identifiable {
|
||||
case .settings: return "gearshape.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MainTabView: View {
|
||||
@State private var selection: AppTab = {
|
||||
#if DEBUG
|
||||
if let raw = UserDefaults.standard.string(forKey: "startTab"),
|
||||
let tab = AppTab(rawValue: raw) {
|
||||
return tab
|
||||
}
|
||||
#endif
|
||||
return .main
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
switch selection {
|
||||
case .main: MainView()
|
||||
case .action: ActionListView()
|
||||
case .tag: TagListView()
|
||||
case .goal: GoalListView()
|
||||
case .history: HistoryView()
|
||||
case .settings: SettingsView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
tabBar
|
||||
}
|
||||
.background(AppTheme.background)
|
||||
/// 저장 문자열("main,action,history")을 스펙 순서를 유지한 탭 배열로 변환
|
||||
static func visibleTabs(from raw: String) -> [AppTab] {
|
||||
let chosen = Set(raw.split(separator: ",").map(String.init))
|
||||
let tabs = AppTab.allCases.filter { chosen.contains($0.rawValue) }
|
||||
return tabs.isEmpty ? [.main] : Array(tabs.prefix(TabBarConfig.maxVisible))
|
||||
}
|
||||
|
||||
/// 리퀴드 글라스 스타일의 플로팅 탭바
|
||||
private var tabBar: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(AppTab.allCases) { tab in
|
||||
Button {
|
||||
withAnimation(.spring(duration: 0.25)) {
|
||||
selection = tab
|
||||
@ViewBuilder
|
||||
var rootView: some View {
|
||||
switch self {
|
||||
case .main: MainView()
|
||||
case .action: ActionListView()
|
||||
case .tag: TagListView()
|
||||
case .goal: GoalListView()
|
||||
case .history: HistoryView()
|
||||
case .settings: SettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 탭 구조 (노출 탭 1~3개 + 더보기, 시스템 탭바 = 리퀴드 글라스)
|
||||
|
||||
struct MainTabView: View {
|
||||
@AppStorage(SettingsKeys.visibleTabs) private var visibleTabsRaw = TabBarConfig.defaultVisibleTabs
|
||||
|
||||
private static let moreTabValue = "more"
|
||||
|
||||
@State private var selection: String = {
|
||||
#if DEBUG
|
||||
if let raw = UserDefaults.standard.string(forKey: "startTab") {
|
||||
return raw
|
||||
}
|
||||
#endif
|
||||
return AppTab.main.rawValue
|
||||
}()
|
||||
|
||||
private var visibleTabs: [AppTab] {
|
||||
AppTab.visibleTabs(from: visibleTabsRaw)
|
||||
}
|
||||
|
||||
private var moreTabs: [AppTab] {
|
||||
AppTab.allCases.filter { !visibleTabs.contains($0) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selection) {
|
||||
ForEach(visibleTabs) { tab in
|
||||
Tab(tab.label, systemImage: tab.symbol, value: tab.rawValue) {
|
||||
NavigationStack {
|
||||
tab.rootView
|
||||
}
|
||||
} label: {
|
||||
VStack(spacing: 3) {
|
||||
Image(systemName: tab.symbol)
|
||||
.font(.system(size: 19, weight: .medium))
|
||||
Text(tab.label)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background {
|
||||
if selection == tab {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(AppTheme.green.opacity(0.16))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(selection == tab ? AppTheme.green : Color.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Tab("더보기", systemImage: "ellipsis", value: Self.moreTabValue) {
|
||||
MoreTabView(tabs: moreTabs)
|
||||
}
|
||||
}
|
||||
.padding(6)
|
||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 26, style: .continuous))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 4)
|
||||
.onChange(of: visibleTabsRaw) {
|
||||
// 노출 탭에서 빠진 탭이 선택돼 있으면 더보기로 이동
|
||||
let valid = visibleTabs.map(\.rawValue) + [Self.moreTabValue]
|
||||
if !valid.contains(selection) {
|
||||
selection = Self.moreTabValue
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
#if DEBUG
|
||||
// 검증용: -startTab이 노출 탭에 없으면 더보기 대신 그 탭 화면으로 안내되도록 보정
|
||||
let valid = visibleTabs.map(\.rawValue) + [Self.moreTabValue]
|
||||
if !valid.contains(selection) {
|
||||
selection = Self.moreTabValue
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 더보기 탭
|
||||
|
||||
struct MoreTabView: View {
|
||||
let tabs: [AppTab]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(tabs) { tab in
|
||||
NavigationLink {
|
||||
tab.rootView
|
||||
} label: {
|
||||
Label {
|
||||
Text(tab.label)
|
||||
} icon: {
|
||||
Image(systemName: tab.symbol)
|
||||
.foregroundStyle(AppTheme.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(AppTheme.background)
|
||||
.navigationTitle("더보기")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -35,6 +35,7 @@ enum DebugSeed {
|
||||
|
||||
let reading = Action(name: "독서", symbolName: "book.fill", trackingType: .time, sortOrder: 0)
|
||||
reading.tags = [study]
|
||||
reading.isFavorite = true
|
||||
let english = Action(name: "영어 공부", symbolName: "graduationcap.fill", trackingType: .time, sortOrder: 1)
|
||||
english.tags = [study]
|
||||
let running = Action(name: "달리기", symbolName: "figure.run", trackingType: .time, sortOrder: 2)
|
||||
@ -64,8 +65,10 @@ enum DebugSeed {
|
||||
context.insert(TimeSession(action: reading, startAt: at(daysAgo: daysAgo, hour: 21), endAt: at(daysAgo: daysAgo, hour: 22, minute: 15)))
|
||||
}
|
||||
}
|
||||
// 자정을 걸치는 세션 (하루 경계 분할 확인용)
|
||||
context.insert(TimeSession(action: reading, startAt: at(daysAgo: 1, hour: 23, minute: 20), endAt: at(daysAgo: 0, hour: 0, minute: 40)))
|
||||
// 자정을 걸치는 세션 (하루 경계 분할 확인용) + 메모 표시 확인용
|
||||
let crossing = TimeSession(action: reading, startAt: at(daysAgo: 1, hour: 23, minute: 20), endAt: at(daysAgo: 0, hour: 0, minute: 40))
|
||||
crossing.note = "자기 전에 소설 읽음. 재밌어서 늦게 잠"
|
||||
context.insert(crossing)
|
||||
// 진행 중 세션
|
||||
context.insert(TimeSession(action: reading, startAt: now.addingTimeInterval(-25 * 60)))
|
||||
|
||||
|
||||
@ -38,6 +38,8 @@ enum TrackingType: String, CaseIterable, Identifiable {
|
||||
final class Tag {
|
||||
var name: String = ""
|
||||
var colorHex: String = "#2F6B4F"
|
||||
/// 행동 탭 그룹/꼬리표 탭 목록의 표시 순서
|
||||
var sortOrder: Int = 0
|
||||
var createdAt: Date = Date()
|
||||
|
||||
var actions: [Action] = []
|
||||
@ -68,6 +70,8 @@ final class Action {
|
||||
var symbolName: String = "star.fill"
|
||||
var trackingTypeRaw: String = TrackingType.time.rawValue
|
||||
var sortOrder: Int = 0
|
||||
/// 행동 탭에서 최상단 "즐겨찾기" 섹션에 표시
|
||||
var isFavorite: Bool = false
|
||||
var createdAt: Date = Date()
|
||||
|
||||
@Relationship(inverse: \Tag.actions)
|
||||
@ -120,6 +124,8 @@ extension Action {
|
||||
final class TimeSession {
|
||||
var startAt: Date = Date()
|
||||
var endAt: Date?
|
||||
/// 측정 종료 시 남기는 메모 (선택)
|
||||
var note: String = ""
|
||||
|
||||
var action: Action?
|
||||
|
||||
@ -176,6 +182,8 @@ final class Goal {
|
||||
var startDate: Date = Date()
|
||||
var endDate: Date?
|
||||
var statusRaw: String = GoalStatus.inProgress.rawValue
|
||||
/// 목표 탭에서 하위 다짐 목록 접힘 여부
|
||||
var isCollapsed: Bool = false
|
||||
var createdAt: Date = Date()
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \Quest.goal)
|
||||
@ -201,7 +209,10 @@ extension Goal {
|
||||
var color: Color { Color(hex: colorHex) }
|
||||
|
||||
var sortedQuests: [Quest] {
|
||||
quests.sorted { $0.createdAt < $1.createdAt }
|
||||
quests.sorted {
|
||||
if $0.sortOrder != $1.sortOrder { return $0.sortOrder < $1.sortOrder }
|
||||
return $0.createdAt < $1.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
/// 종료일이 지났는지 (종료일 당일까지는 진행 중으로 취급)
|
||||
@ -309,6 +320,8 @@ final class Quest {
|
||||
var targetCount: Int = 1
|
||||
var directionRaw: String = QuestDirection.atLeast.rawValue
|
||||
|
||||
/// 목표 안에서의 표시 순서
|
||||
var sortOrder: Int = 0
|
||||
var createdAt: Date = Date()
|
||||
|
||||
init(goal: Goal) {
|
||||
|
||||
@ -24,6 +24,15 @@ enum SettingsKeys {
|
||||
static let gridColumns = "settings.gridColumns"
|
||||
/// 이 시간(초)보다 짧게 측정된 세션은 기록하지 않음. 0 = 사용 안 함
|
||||
static let minSessionSeconds = "settings.minSessionSeconds"
|
||||
/// 탭바에 직접 노출할 탭(더보기 제외 1~3개). AppTab rawValue를 쉼표로 연결한 문자열
|
||||
static let visibleTabs = "settings.visibleTabs"
|
||||
}
|
||||
|
||||
enum TabBarConfig {
|
||||
/// 기본 노출 탭: 메인 · 행동 · 기록 (+더보기)
|
||||
static let defaultVisibleTabs = "main,action,history"
|
||||
static let maxVisible = 3
|
||||
static let minVisible = 1
|
||||
}
|
||||
|
||||
/// 짧은 기록 무시 옵션 선택지 (초)
|
||||
|
||||
41
myApp/Haru_Danim/IOS/InfoPlist.xcstrings
Normal file
41
myApp/Haru_Danim/IOS/InfoPlist.xcstrings
Normal file
@ -0,0 +1,41 @@
|
||||
{
|
||||
"sourceLanguage" : "ko",
|
||||
"strings" : {
|
||||
"CFBundleDisplayName" : {
|
||||
"comment" : "홈 화면에 표시되는 앱 이름",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Haru Danim"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Haru Danim"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "하루다님"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CFBundleName" : {
|
||||
"comment" : "Bundle name",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Haru_Danim"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
643
myApp/Haru_Danim/IOS/Localizable.xcstrings
Normal file
643
myApp/Haru_Danim/IOS/Localizable.xcstrings
Normal file
@ -0,0 +1,643 @@
|
||||
{
|
||||
"sourceLanguage" : "ko",
|
||||
"strings" : {
|
||||
"‘%@’ 꼬리표를 삭제할까요? 행동은 삭제되지 않아요." : {
|
||||
|
||||
},
|
||||
"‘%@’ 목표를 달성했나요?" : {
|
||||
|
||||
},
|
||||
"‘%@’ 목표를 삭제할까요? 다짐도 함께 삭제됩니다." : {
|
||||
|
||||
},
|
||||
"‘%@’ 행동을 삭제할까요? 기록도 함께 삭제됩니다." : {
|
||||
|
||||
},
|
||||
"%@ · %@ %@" : {
|
||||
"localizations" : {
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$@ · %2$@ %3$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@ ~ %@" : {
|
||||
"localizations" : {
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$@ ~ %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@ 기록" : {
|
||||
|
||||
},
|
||||
"%@ 시작" : {
|
||||
|
||||
},
|
||||
"%@ 측정 완료" : {
|
||||
|
||||
},
|
||||
"%@요일" : {
|
||||
|
||||
},
|
||||
"%lld" : {
|
||||
|
||||
},
|
||||
"%lld개" : {
|
||||
|
||||
},
|
||||
"%lld분" : {
|
||||
|
||||
},
|
||||
"%lld시간" : {
|
||||
|
||||
},
|
||||
"%lld째 주" : {
|
||||
|
||||
},
|
||||
"%lld회" : {
|
||||
|
||||
},
|
||||
"+%lld" : {
|
||||
|
||||
},
|
||||
"1.0" : {
|
||||
|
||||
},
|
||||
"① 대상 선택" : {
|
||||
|
||||
},
|
||||
"② 주기 선택" : {
|
||||
|
||||
},
|
||||
"③ 목표량과 방향" : {
|
||||
|
||||
},
|
||||
"iCloud 동기화는 준비 중이에요." : {
|
||||
|
||||
},
|
||||
"건너뛰기" : {
|
||||
|
||||
},
|
||||
"결제 기능은 App Store 배포 준비와 함께 제공될 예정이에요." : {
|
||||
|
||||
},
|
||||
"기간" : {
|
||||
|
||||
},
|
||||
"기간 진행률 %@" : {
|
||||
|
||||
},
|
||||
"기기 간 동기화" : {
|
||||
|
||||
},
|
||||
"기록" : {
|
||||
|
||||
},
|
||||
"기록 (최신순)" : {
|
||||
|
||||
},
|
||||
"기록 삭제" : {
|
||||
|
||||
},
|
||||
"기록 삭제 (증가 취소)" : {
|
||||
|
||||
},
|
||||
"기록 시각" : {
|
||||
|
||||
},
|
||||
"기록 직접 추가" : {
|
||||
|
||||
},
|
||||
"기록이 없어요" : {
|
||||
|
||||
},
|
||||
"기본 정보" : {
|
||||
|
||||
},
|
||||
"꼬리표" : {
|
||||
|
||||
},
|
||||
"꼬리표 (복수 선택 가능)" : {
|
||||
|
||||
},
|
||||
"꼬리표 삭제" : {
|
||||
|
||||
},
|
||||
"꼬리표 수정" : {
|
||||
|
||||
},
|
||||
"꼬리표 순서" : {
|
||||
|
||||
},
|
||||
"꼬리표 순서 변경" : {
|
||||
|
||||
},
|
||||
"꼬리표 없음" : {
|
||||
|
||||
},
|
||||
"꼬리표 이름 (예: 공부)" : {
|
||||
|
||||
},
|
||||
"꼬리표 추가" : {
|
||||
|
||||
},
|
||||
"꼬리표가 없어요" : {
|
||||
|
||||
},
|
||||
"끝" : {
|
||||
|
||||
},
|
||||
"날짜" : {
|
||||
|
||||
},
|
||||
"날짜 기준" : {
|
||||
|
||||
},
|
||||
"날짜 선택" : {
|
||||
|
||||
},
|
||||
"누적 기록" : {
|
||||
|
||||
},
|
||||
"다이나믹 아일랜드 · 잠금화면" : {
|
||||
|
||||
},
|
||||
"다짐" : {
|
||||
|
||||
},
|
||||
"다짐 %lld개" : {
|
||||
|
||||
},
|
||||
"다짐 수정" : {
|
||||
|
||||
},
|
||||
"다짐 추가" : {
|
||||
|
||||
},
|
||||
"다짐이 없는 목표예요. 달성했나요?" : {
|
||||
|
||||
},
|
||||
"다크" : {
|
||||
|
||||
},
|
||||
"닫기" : {
|
||||
|
||||
},
|
||||
"달성하지 못했어요" : {
|
||||
|
||||
},
|
||||
"달성했나요? 탭해서 선택" : {
|
||||
|
||||
},
|
||||
"달성했어요" : {
|
||||
|
||||
},
|
||||
"당신의 하루에 다녀가다" : {
|
||||
"comment" : "스플래시 브랜드 멘트",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Stopping by your day"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "あなたの一日に、立ち寄る"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"대상 종류" : {
|
||||
|
||||
},
|
||||
"대표 시간 기준" : {
|
||||
|
||||
},
|
||||
"더보기" : {
|
||||
|
||||
},
|
||||
"동기화" : {
|
||||
|
||||
},
|
||||
"등록된 꼬리표가 없어요" : {
|
||||
|
||||
},
|
||||
"등록된 행동이 없어요" : {
|
||||
|
||||
},
|
||||
"라이트" : {
|
||||
|
||||
},
|
||||
"메모" : {
|
||||
|
||||
},
|
||||
"메모 남기기" : {
|
||||
|
||||
},
|
||||
"몇째 주" : {
|
||||
|
||||
},
|
||||
"목표" : {
|
||||
|
||||
},
|
||||
"목표 내용" : {
|
||||
|
||||
},
|
||||
"목표 삭제" : {
|
||||
|
||||
},
|
||||
"목표 수동 종료" : {
|
||||
|
||||
},
|
||||
"목표 수정" : {
|
||||
|
||||
},
|
||||
"목표 추가" : {
|
||||
|
||||
},
|
||||
"목표 확인" : {
|
||||
|
||||
},
|
||||
"목표 횟수" : {
|
||||
|
||||
},
|
||||
"목표가 없어요" : {
|
||||
|
||||
},
|
||||
"목표를 지금 종료할까요?" : {
|
||||
|
||||
},
|
||||
"무료 버전에서는 목표당 다짐을 최대 %lld개까지 만들 수 있어요." : {
|
||||
|
||||
},
|
||||
"무료 버전에서는 목표를 최대 %lld개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다." : {
|
||||
|
||||
},
|
||||
"무료 버전에서는 행동을 최대 %lld개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다." : {
|
||||
|
||||
},
|
||||
"무료 사용 중" : {
|
||||
|
||||
},
|
||||
"무료 사용 한도" : {
|
||||
|
||||
},
|
||||
"무료: 행동 %lld개 · 목표 %lld개 · 목표당 다짐 %lld개까지" : {
|
||||
"localizations" : {
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "무료: 행동 %1$lld개 · 목표 %2$lld개 · 목표당 다짐 %3$lld개까지"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"미리 보기" : {
|
||||
|
||||
},
|
||||
"반복 주기" : {
|
||||
|
||||
},
|
||||
"방향" : {
|
||||
|
||||
},
|
||||
"배치 편집" : {
|
||||
|
||||
},
|
||||
"버전" : {
|
||||
|
||||
},
|
||||
"버튼을 길게 눌러 끌면 순서를 바꿀 수 있어요." : {
|
||||
|
||||
},
|
||||
"범위" : {
|
||||
|
||||
},
|
||||
"보기" : {
|
||||
|
||||
},
|
||||
"분" : {
|
||||
|
||||
},
|
||||
"사용 중" : {
|
||||
|
||||
},
|
||||
"삭제" : {
|
||||
|
||||
},
|
||||
"새 꼬리표 만들기" : {
|
||||
|
||||
},
|
||||
"색" : {
|
||||
|
||||
},
|
||||
"선택" : {
|
||||
|
||||
},
|
||||
"선택한 탭(최대 %lld개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요." : {
|
||||
|
||||
},
|
||||
"설정" : {
|
||||
|
||||
},
|
||||
"설정한 시간보다 짧게 측정하고 종료한 기록은 저장하지 않아요. 버튼을 실수로 눌렀을 때 유용해요." : {
|
||||
|
||||
},
|
||||
"수량" : {
|
||||
|
||||
},
|
||||
"수정" : {
|
||||
|
||||
},
|
||||
"순서" : {
|
||||
|
||||
},
|
||||
"시각" : {
|
||||
|
||||
},
|
||||
"시간" : {
|
||||
|
||||
},
|
||||
"시간 기록" : {
|
||||
|
||||
},
|
||||
"시간 기록 수정" : {
|
||||
|
||||
},
|
||||
"시간 기록 추가" : {
|
||||
|
||||
},
|
||||
"시간 기록이 없어요" : {
|
||||
|
||||
},
|
||||
"시간(h)" : {
|
||||
|
||||
},
|
||||
"시작" : {
|
||||
|
||||
},
|
||||
"시작 시각" : {
|
||||
|
||||
},
|
||||
"시작·종료 시각 수동 입력" : {
|
||||
|
||||
},
|
||||
"시작일 (과거 가능)" : {
|
||||
|
||||
},
|
||||
"심볼 이름 검색 (영문)" : {
|
||||
|
||||
},
|
||||
"아이콘" : {
|
||||
|
||||
},
|
||||
"아이콘 선택" : {
|
||||
|
||||
},
|
||||
"아이콘과 색" : {
|
||||
|
||||
},
|
||||
"아직 등록된 행동이 없어요" : {
|
||||
|
||||
},
|
||||
"언어" : {
|
||||
|
||||
},
|
||||
"여러 행동을 동시에 추적 중일 때 대표로 표시할 시간이에요. (Live Activity 지원은 추후 추가 예정)" : {
|
||||
|
||||
},
|
||||
"예: 토익 700점 이상 받기" : {
|
||||
|
||||
},
|
||||
"오늘" : {
|
||||
|
||||
},
|
||||
"오른쪽 위 ‘순서’를 누르면 다짐 순서를 바꿀 수 있어요." : {
|
||||
|
||||
},
|
||||
"오른쪽 위 + 버튼으로 꼬리표를 추가하세요." : {
|
||||
|
||||
},
|
||||
"오른쪽 위 + 버튼으로 행동을 추가하세요." : {
|
||||
|
||||
},
|
||||
"완료" : {
|
||||
|
||||
},
|
||||
"요일" : {
|
||||
|
||||
},
|
||||
"이 기록에 대한 메모 (선택)" : {
|
||||
|
||||
},
|
||||
"이 목표를 이루기 위한 다짐(행동/꼬리표 + 주기 + 목표량)을 추가하세요." : {
|
||||
|
||||
},
|
||||
"이름" : {
|
||||
|
||||
},
|
||||
"이번 측정에 대한 메모 (선택)" : {
|
||||
|
||||
},
|
||||
"일주일" : {
|
||||
|
||||
},
|
||||
"저장" : {
|
||||
|
||||
},
|
||||
"적용일" : {
|
||||
|
||||
},
|
||||
"종료" : {
|
||||
|
||||
},
|
||||
"종료 시 하위 다짐들의 달성 여부에 따라 달성/미달성이 결정돼요." : {
|
||||
|
||||
},
|
||||
"종료 시각" : {
|
||||
|
||||
},
|
||||
"종료 시각을 끄면 진행 중 상태가 돼요." : {
|
||||
|
||||
},
|
||||
"종료됨" : {
|
||||
|
||||
},
|
||||
"종료일" : {
|
||||
|
||||
},
|
||||
"종료일 지정" : {
|
||||
|
||||
},
|
||||
"주 시작 요일" : {
|
||||
|
||||
},
|
||||
"주기마다 목표량 이상을 달성하는 것이 목표예요." : {
|
||||
|
||||
},
|
||||
"주기마다 목표량을 넘지 않는 것이 목표예요." : {
|
||||
|
||||
},
|
||||
"즐겨찾기" : {
|
||||
|
||||
},
|
||||
"즐겨찾기 해제" : {
|
||||
|
||||
},
|
||||
"지금 시각" : {
|
||||
|
||||
},
|
||||
"지정된 꼬리표 없음" : {
|
||||
|
||||
},
|
||||
"직접 선택" : {
|
||||
|
||||
},
|
||||
"진행 중" : {
|
||||
|
||||
},
|
||||
"짧은 기록 무시" : {
|
||||
|
||||
},
|
||||
"추가" : {
|
||||
|
||||
},
|
||||
"추가할 횟수" : {
|
||||
|
||||
},
|
||||
"추적 방식" : {
|
||||
|
||||
},
|
||||
"취소" : {
|
||||
|
||||
},
|
||||
"측정" : {
|
||||
|
||||
},
|
||||
"측정 기준" : {
|
||||
|
||||
},
|
||||
"큰 목표를 세우고, 그 안에 다짐을 추가해 보세요." : {
|
||||
|
||||
},
|
||||
"탭바 구성" : {
|
||||
|
||||
},
|
||||
"테마" : {
|
||||
|
||||
},
|
||||
"특정 시각 지정" : {
|
||||
|
||||
},
|
||||
"표시할 기록이 없어요" : {
|
||||
|
||||
},
|
||||
"프리미엄" : {
|
||||
|
||||
},
|
||||
"프리미엄 구매 (준비 중)" : {
|
||||
|
||||
},
|
||||
"프리미엄 기능" : {
|
||||
|
||||
},
|
||||
"프리미엄으로 할 수 있는 것" : {
|
||||
|
||||
},
|
||||
"하루" : {
|
||||
|
||||
},
|
||||
"하루 시작 시간" : {
|
||||
|
||||
},
|
||||
"하루 시작 시간을 걸치는 기록은 통계에서 자동으로 날짜별로 나누어 계산돼요." : {
|
||||
|
||||
},
|
||||
"하루다님" : {
|
||||
"comment" : "앱 이름",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Haru Danim"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Haru Danim"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"한 줄에 표시할 개수" : {
|
||||
|
||||
},
|
||||
"행동" : {
|
||||
|
||||
},
|
||||
"행동 %lld개" : {
|
||||
|
||||
},
|
||||
"행동 버튼의 색은 지정한 꼬리표의 색을 따라요." : {
|
||||
|
||||
},
|
||||
"행동 삭제" : {
|
||||
|
||||
},
|
||||
"행동 설정 수정" : {
|
||||
|
||||
},
|
||||
"행동 수정" : {
|
||||
|
||||
},
|
||||
"행동 이름 (예: 독서)" : {
|
||||
|
||||
},
|
||||
"행동 추가" : {
|
||||
|
||||
},
|
||||
"행동 탭에서 추적할 행동을 추가해 보세요." : {
|
||||
|
||||
},
|
||||
"현재 진행 중" : {
|
||||
|
||||
},
|
||||
"화면" : {
|
||||
|
||||
},
|
||||
"확인" : {
|
||||
|
||||
},
|
||||
"횟수" : {
|
||||
|
||||
},
|
||||
"횟수 기록" : {
|
||||
|
||||
},
|
||||
"횟수 기록 수정" : {
|
||||
|
||||
},
|
||||
"횟수 기록이 없어요" : {
|
||||
|
||||
},
|
||||
"횟수 직접 입력·수정" : {
|
||||
|
||||
},
|
||||
"횟수 직접 추가" : {
|
||||
|
||||
},
|
||||
"횟수 추가" : {
|
||||
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
@ -12,81 +12,120 @@ import SwiftData
|
||||
|
||||
struct ActionListView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Query(sort: \Tag.createdAt) private var tags: [Tag]
|
||||
@Query(sort: [SortDescriptor(\Tag.sortOrder), SortDescriptor(\Tag.createdAt)]) private var tags: [Tag]
|
||||
@Query(sort: \Action.sortOrder) private var actions: [Action]
|
||||
@AppStorage(SettingsKeys.isPremium) private var isPremium = false
|
||||
|
||||
@State private var showingAdd = false
|
||||
@State private var showLimitAlert = false
|
||||
@State private var showingTagOrder = false
|
||||
|
||||
private var favoriteActions: [Action] {
|
||||
actions.filter(\.isFavorite)
|
||||
}
|
||||
|
||||
private var untaggedActions: [Action] {
|
||||
actions.filter { $0.tags.isEmpty }
|
||||
actions.filter { $0.tags.isEmpty && !$0.isFavorite }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(tags) { tag in
|
||||
if !tag.actions.isEmpty {
|
||||
Section {
|
||||
ForEach(tag.sortedActions) { action in
|
||||
NavigationLink {
|
||||
ActionDetailView(action: action)
|
||||
} label: {
|
||||
ActionRow(action: action)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(tag.color).frame(width: 10, height: 10)
|
||||
Text(tag.name)
|
||||
}
|
||||
}
|
||||
List {
|
||||
if !favoriteActions.isEmpty {
|
||||
Section {
|
||||
ForEach(favoriteActions) { action in
|
||||
actionLink(action)
|
||||
}
|
||||
}
|
||||
if !untaggedActions.isEmpty {
|
||||
Section("꼬리표 없음") {
|
||||
ForEach(untaggedActions) { action in
|
||||
NavigationLink {
|
||||
ActionDetailView(action: action)
|
||||
} label: {
|
||||
ActionRow(action: action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if actions.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"등록된 행동이 없어요",
|
||||
systemImage: "figure.walk",
|
||||
description: Text("오른쪽 위 + 버튼으로 행동을 추가하세요.")
|
||||
)
|
||||
} header: {
|
||||
Label("즐겨찾기", systemImage: "star.fill")
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(AppTheme.background)
|
||||
.navigationTitle("행동")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
ForEach(tags) { tag in
|
||||
let group = tag.sortedActions.filter { !$0.isFavorite }
|
||||
if !group.isEmpty {
|
||||
Section {
|
||||
ForEach(group) { action in
|
||||
actionLink(action)
|
||||
}
|
||||
} header: {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(tag.color).frame(width: 10, height: 10)
|
||||
Text(tag.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !untaggedActions.isEmpty {
|
||||
Section("꼬리표 없음") {
|
||||
ForEach(untaggedActions) { action in
|
||||
actionLink(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
if actions.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"등록된 행동이 없어요",
|
||||
systemImage: "figure.walk",
|
||||
description: Text("오른쪽 위 + 버튼으로 행동을 추가하세요.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(AppTheme.background)
|
||||
.navigationTitle("행동")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button {
|
||||
if !isPremium && actions.count >= FreeLimits.actions {
|
||||
showLimitAlert = true
|
||||
} else {
|
||||
showingAdd = true
|
||||
}
|
||||
showingTagOrder = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
Label("꼬리표 순서 변경", systemImage: "arrow.up.arrow.down")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAdd) {
|
||||
ActionEditorView(action: nil)
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
if !isPremium && actions.count >= FreeLimits.actions {
|
||||
showLimitAlert = true
|
||||
} else {
|
||||
showingAdd = true
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
.alert("무료 사용 한도", isPresented: $showLimitAlert) {
|
||||
Button("확인", role: .cancel) {}
|
||||
} message: {
|
||||
Text("무료 버전에서는 행동을 최대 \(FreeLimits.actions)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.")
|
||||
}
|
||||
.sheet(isPresented: $showingAdd) {
|
||||
ActionEditorView(action: nil)
|
||||
}
|
||||
.sheet(isPresented: $showingTagOrder) {
|
||||
TagOrderSheet()
|
||||
}
|
||||
.alert("무료 사용 한도", isPresented: $showLimitAlert) {
|
||||
Button("확인", role: .cancel) {}
|
||||
} message: {
|
||||
Text("무료 버전에서는 행동을 최대 \(FreeLimits.actions)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.")
|
||||
}
|
||||
}
|
||||
|
||||
private func actionLink(_ action: Action) -> some View {
|
||||
NavigationLink {
|
||||
ActionDetailView(action: action)
|
||||
} label: {
|
||||
ActionRow(action: action)
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
Button {
|
||||
action.isFavorite.toggle()
|
||||
} label: {
|
||||
Label(
|
||||
action.isFavorite ? "즐겨찾기 해제" : "즐겨찾기",
|
||||
systemImage: action.isFavorite ? "star.slash" : "star.fill"
|
||||
)
|
||||
}
|
||||
.tint(AppTheme.yellow)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -102,8 +141,15 @@ struct ActionRow: View {
|
||||
.frame(width: 34, height: 34)
|
||||
.background(action.color, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(action.name)
|
||||
.font(.body.weight(.medium))
|
||||
HStack(spacing: 4) {
|
||||
Text(action.name)
|
||||
.font(.body.weight(.medium))
|
||||
if action.isFavorite {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
}
|
||||
}
|
||||
Text(action.trackingType.label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
@ -112,6 +158,42 @@ struct ActionRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 꼬리표 순서 변경 시트 (행동 탭 그룹 순서에 반영)
|
||||
|
||||
struct TagOrderSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Query(sort: [SortDescriptor(\Tag.sortOrder), SortDescriptor(\Tag.createdAt)]) private var tags: [Tag]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(tags) { tag in
|
||||
HStack(spacing: 10) {
|
||||
Circle().fill(tag.color).frame(width: 14, height: 14)
|
||||
Text(tag.name)
|
||||
}
|
||||
}
|
||||
.onMove { source, destination in
|
||||
var ordered = tags
|
||||
ordered.move(fromOffsets: source, toOffset: destination)
|
||||
for (index, tag) in ordered.enumerated() {
|
||||
tag.sortOrder = index
|
||||
}
|
||||
}
|
||||
}
|
||||
.environment(\.editMode, .constant(.active))
|
||||
.navigationTitle("꼬리표 순서")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("완료") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 행동 상세
|
||||
|
||||
struct ActionDetailView: View {
|
||||
@ -130,6 +212,14 @@ struct ActionDetailView: View {
|
||||
Spacer()
|
||||
Text(action.name).foregroundStyle(.secondary)
|
||||
}
|
||||
Toggle(isOn: Bindable(action).isFavorite) {
|
||||
Label {
|
||||
Text("즐겨찾기")
|
||||
} icon: {
|
||||
Image(systemName: "star.fill")
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Text("아이콘")
|
||||
Spacer()
|
||||
@ -170,6 +260,7 @@ struct ActionDetailView: View {
|
||||
.background(AppTheme.background)
|
||||
.navigationTitle(action.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("수정") { showingEdit = true }
|
||||
@ -178,16 +269,14 @@ struct ActionDetailView: View {
|
||||
.sheet(isPresented: $showingEdit) {
|
||||
ActionEditorView(action: action)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"‘\(action.name)’ 행동을 삭제할까요? 기록도 함께 삭제됩니다.",
|
||||
isPresented: $showingDelete,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
.alert("행동 삭제", isPresented: $showingDelete) {
|
||||
Button("삭제", role: .destructive) {
|
||||
context.delete(action)
|
||||
dismiss()
|
||||
}
|
||||
Button("취소", role: .cancel) {}
|
||||
} message: {
|
||||
Text("‘\(action.name)’ 행동을 삭제할까요? 기록도 함께 삭제됩니다.")
|
||||
}
|
||||
}
|
||||
|
||||
@ -256,6 +345,7 @@ struct ActionEditorView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@ -275,6 +365,7 @@ struct ActionEditorView: View {
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
Button {
|
||||
|
||||
@ -19,59 +19,82 @@ struct GoalListView: View {
|
||||
@State private var showLimitAlert = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(goals) { goal in
|
||||
Section {
|
||||
NavigationLink {
|
||||
GoalDetailView(goal: goal)
|
||||
} label: {
|
||||
GoalRow(goal: goal)
|
||||
}
|
||||
List {
|
||||
ForEach(goals) { goal in
|
||||
Section {
|
||||
NavigationLink {
|
||||
GoalDetailView(goal: goal)
|
||||
} label: {
|
||||
GoalRow(goal: goal)
|
||||
}
|
||||
if !goal.isCollapsed {
|
||||
ForEach(goal.sortedQuests) { quest in
|
||||
QuestRow(quest: quest)
|
||||
}
|
||||
}
|
||||
}
|
||||
if goals.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"목표가 없어요",
|
||||
systemImage: "flag.checkered",
|
||||
description: Text("큰 목표를 세우고, 그 안에 다짐을 추가해 보세요.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(AppTheme.background)
|
||||
.navigationTitle("목표")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
if !isPremium && goals.count >= FreeLimits.goals {
|
||||
showLimitAlert = true
|
||||
} else {
|
||||
showingAdd = true
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
} header: {
|
||||
if !goal.quests.isEmpty {
|
||||
collapseHeader(goal)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAdd) {
|
||||
GoalEditorView(goal: nil)
|
||||
if goals.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"목표가 없어요",
|
||||
systemImage: "flag.checkered",
|
||||
description: Text("큰 목표를 세우고, 그 안에 다짐을 추가해 보세요.")
|
||||
)
|
||||
}
|
||||
.alert("무료 사용 한도", isPresented: $showLimitAlert) {
|
||||
Button("확인", role: .cancel) {}
|
||||
} message: {
|
||||
Text("무료 버전에서는 목표를 최대 \(FreeLimits.goals)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.")
|
||||
}
|
||||
.onAppear {
|
||||
let math = DayMath()
|
||||
for goal in goals {
|
||||
goal.evaluateIfEnded(math: math)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(AppTheme.background)
|
||||
.navigationTitle("목표")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
if !isPremium && goals.count >= FreeLimits.goals {
|
||||
showLimitAlert = true
|
||||
} else {
|
||||
showingAdd = true
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAdd) {
|
||||
GoalEditorView(goal: nil)
|
||||
}
|
||||
.alert("무료 사용 한도", isPresented: $showLimitAlert) {
|
||||
Button("확인", role: .cancel) {}
|
||||
} message: {
|
||||
Text("무료 버전에서는 목표를 최대 \(FreeLimits.goals)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.")
|
||||
}
|
||||
.onAppear {
|
||||
let math = DayMath()
|
||||
for goal in goals {
|
||||
goal.evaluateIfEnded(math: math)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 다짐 접기/펼치기 토글 헤더
|
||||
private func collapseHeader(_ goal: Goal) -> some View {
|
||||
Button {
|
||||
withAnimation(.spring(duration: 0.3)) {
|
||||
goal.isCollapsed.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("다짐 \(goal.quests.count)개")
|
||||
Image(systemName: goal.isCollapsed ? "chevron.down" : "chevron.up")
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppTheme.green)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.textCase(nil)
|
||||
}
|
||||
}
|
||||
|
||||
@ -121,14 +144,12 @@ struct GoalRow: View {
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.confirmationDialog(
|
||||
"‘\(goal.title)’ 목표를 달성했나요?",
|
||||
isPresented: $showingConfirm,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
.alert("목표 확인", isPresented: $showingConfirm) {
|
||||
Button("달성했어요") { goal.status = .achieved }
|
||||
Button("달성하지 못했어요", role: .destructive) { goal.status = .notAchieved }
|
||||
Button("취소", role: .cancel) {}
|
||||
} message: {
|
||||
Text("‘\(goal.title)’ 목표를 달성했나요?")
|
||||
}
|
||||
}
|
||||
|
||||
@ -221,6 +242,7 @@ struct GoalDetailView: View {
|
||||
@State private var showingManualFinish = false
|
||||
@State private var askManualResult = false
|
||||
@State private var editingQuest: Quest?
|
||||
@State private var editMode: EditMode = .inactive
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@ -241,6 +263,13 @@ struct GoalDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onMove { source, destination in
|
||||
var ordered = goal.sortedQuests
|
||||
ordered.move(fromOffsets: source, toOffset: destination)
|
||||
for (index, quest) in ordered.enumerated() {
|
||||
quest.sortOrder = index
|
||||
}
|
||||
}
|
||||
Button {
|
||||
if !isPremium && goal.quests.count >= FreeLimits.questsPerGoal {
|
||||
showQuestLimitAlert = true
|
||||
@ -256,6 +285,8 @@ struct GoalDetailView: View {
|
||||
} footer: {
|
||||
if goal.quests.isEmpty {
|
||||
Text("이 목표를 이루기 위한 다짐(행동/꼬리표 + 주기 + 목표량)을 추가하세요.")
|
||||
} else {
|
||||
Text("오른쪽 위 ‘순서’를 누르면 다짐 순서를 바꿀 수 있어요.")
|
||||
}
|
||||
}
|
||||
if goal.status == .inProgress && goal.endDate == nil {
|
||||
@ -278,11 +309,22 @@ struct GoalDetailView: View {
|
||||
.background(AppTheme.background)
|
||||
.navigationTitle(goal.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if !goal.quests.isEmpty {
|
||||
Button(editMode == .active ? "완료" : "순서") {
|
||||
withAnimation {
|
||||
editMode = editMode == .active ? .inactive : .active
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("수정") { showingEdit = true }
|
||||
}
|
||||
}
|
||||
.environment(\.editMode, $editMode)
|
||||
.sheet(isPresented: $showingEdit) {
|
||||
GoalEditorView(goal: goal)
|
||||
}
|
||||
@ -297,37 +339,31 @@ struct GoalDetailView: View {
|
||||
} message: {
|
||||
Text("무료 버전에서는 목표당 다짐을 최대 \(FreeLimits.questsPerGoal)개까지 만들 수 있어요.")
|
||||
}
|
||||
.confirmationDialog(
|
||||
"목표를 지금 종료할까요?",
|
||||
isPresented: $showingManualFinish,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
.alert("목표 수동 종료", isPresented: $showingManualFinish) {
|
||||
Button("종료") {
|
||||
if goal.manualFinish() == nil {
|
||||
askManualResult = true
|
||||
}
|
||||
}
|
||||
Button("취소", role: .cancel) {}
|
||||
} message: {
|
||||
Text("목표를 지금 종료할까요?")
|
||||
}
|
||||
.confirmationDialog(
|
||||
"다짐이 없는 목표예요. 달성했나요?",
|
||||
isPresented: $askManualResult,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
.alert("목표 확인", isPresented: $askManualResult) {
|
||||
Button("달성했어요") { goal.status = .achieved }
|
||||
Button("달성하지 못했어요", role: .destructive) { goal.status = .notAchieved }
|
||||
Button("취소", role: .cancel) {}
|
||||
} message: {
|
||||
Text("다짐이 없는 목표예요. 달성했나요?")
|
||||
}
|
||||
.confirmationDialog(
|
||||
"‘\(goal.title)’ 목표를 삭제할까요? 다짐도 함께 삭제됩니다.",
|
||||
isPresented: $showingDelete,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
.alert("목표 삭제", isPresented: $showingDelete) {
|
||||
Button("삭제", role: .destructive) {
|
||||
context.delete(goal)
|
||||
dismiss()
|
||||
}
|
||||
Button("취소", role: .cancel) {}
|
||||
} message: {
|
||||
Text("‘\(goal.title)’ 목표를 삭제할까요? 다짐도 함께 삭제됩니다.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -372,6 +408,7 @@ struct GoalEditorView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
ColorPicker("색", selection: $color, supportsOpacity: false)
|
||||
|
||||
@ -64,59 +64,58 @@ struct HistoryView: View {
|
||||
@State private var statSpan: StatSpan = .day
|
||||
@State private var editingSession: TimeSession?
|
||||
@State private var editingEntry: CountEntry?
|
||||
@State private var showingCalendar = false
|
||||
|
||||
private var math: DayMath { DayMath() }
|
||||
private var dayRange: Range<Date> { math.dayRange(forKey: selectedDayKey) }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
dateHeader
|
||||
Picker("보기", selection: $mode) {
|
||||
ForEach(HistoryMode.allCases) { m in
|
||||
Text(m.label).tag(m)
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
dateHeader
|
||||
Picker("보기", selection: $mode) {
|
||||
ForEach(HistoryMode.allCases) { m in
|
||||
Text(m.label).tag(m)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
switch mode {
|
||||
case .list:
|
||||
listView
|
||||
case .timetable:
|
||||
TimetableView(
|
||||
weekly: $timetableWeekly,
|
||||
selectedDayKey: selectedDayKey,
|
||||
segments: segments(in:),
|
||||
counts: counts(in:),
|
||||
math: math,
|
||||
onTapSession: { editingSession = $0 },
|
||||
onTapEntry: { editingEntry = $0 }
|
||||
)
|
||||
case .stats:
|
||||
StatsView(
|
||||
statSpan: $statSpan,
|
||||
selectedDayKey: selectedDayKey,
|
||||
segments: segments(in:),
|
||||
counts: counts(in:),
|
||||
math: math
|
||||
)
|
||||
}
|
||||
}
|
||||
.background(AppTheme.background)
|
||||
.navigationTitle("기록")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(item: $editingSession) { session in
|
||||
SessionEditorView(session: session)
|
||||
}
|
||||
.sheet(item: $editingEntry) { entry in
|
||||
CountEntryEditorView(entry: entry)
|
||||
switch mode {
|
||||
case .list:
|
||||
listView
|
||||
case .timetable:
|
||||
TimetableView(
|
||||
weekly: $timetableWeekly,
|
||||
selectedDayKey: selectedDayKey,
|
||||
segments: segments(in:),
|
||||
counts: counts(in:),
|
||||
math: math,
|
||||
onTapSession: { editingSession = $0 },
|
||||
onTapEntry: { editingEntry = $0 }
|
||||
)
|
||||
case .stats:
|
||||
StatsView(
|
||||
statSpan: $statSpan,
|
||||
selectedDayKey: selectedDayKey,
|
||||
segments: segments(in:),
|
||||
counts: counts(in:),
|
||||
math: math
|
||||
)
|
||||
}
|
||||
}
|
||||
.background(AppTheme.background)
|
||||
.navigationTitle("기록")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(item: $editingSession) { session in
|
||||
SessionEditorView(session: session)
|
||||
}
|
||||
.sheet(item: $editingEntry) { entry in
|
||||
CountEntryEditorView(entry: entry)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 날짜 이동 헤더
|
||||
// MARK: 날짜 이동 헤더 (날짜를 누르면 달력 팝업)
|
||||
|
||||
private var dateHeader: some View {
|
||||
HStack {
|
||||
@ -126,14 +125,37 @@ struct HistoryView: View {
|
||||
Image(systemName: "chevron.left")
|
||||
}
|
||||
Spacer()
|
||||
VStack(spacing: 1) {
|
||||
Text(Format.fullDate(selectedDayKey))
|
||||
.font(.headline)
|
||||
if math.dayKey(for: .now) == selectedDayKey {
|
||||
Text("오늘")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(AppTheme.green)
|
||||
Button {
|
||||
showingCalendar = true
|
||||
} label: {
|
||||
VStack(spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Text(Format.fullDate(selectedDayKey))
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
Image(systemName: "chevron.down.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppTheme.green)
|
||||
}
|
||||
if math.dayKey(for: .now) == selectedDayKey {
|
||||
Text("오늘")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(AppTheme.green)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.popover(isPresented: $showingCalendar) {
|
||||
DatePicker(
|
||||
"날짜 선택",
|
||||
selection: calendarSelection,
|
||||
displayedComponents: .date
|
||||
)
|
||||
.datePickerStyle(.graphical)
|
||||
.frame(minWidth: 320, minHeight: 340)
|
||||
.padding(8)
|
||||
.presentationCompactAdaptation(.popover)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
@ -153,6 +175,18 @@ struct HistoryView: View {
|
||||
.tint(AppTheme.green)
|
||||
}
|
||||
|
||||
/// 달력 팝업 선택값 ↔ 논리적 하루 키 변환 (하루 시작 시간 설정과 무관하게 정오 기준으로 매핑)
|
||||
private var calendarSelection: Binding<Date> {
|
||||
Binding {
|
||||
selectedDayKey
|
||||
} set: { picked in
|
||||
let cal = math.calendar
|
||||
let noon = cal.date(bySettingHour: 12, minute: 0, second: 0, of: picked) ?? picked
|
||||
selectedDayKey = math.dayKey(for: noon)
|
||||
showingCalendar = false
|
||||
}
|
||||
}
|
||||
|
||||
private func moveDay(_ delta: Int) {
|
||||
selectedDayKey = math.calendar.date(byAdding: .day, value: delta, to: selectedDayKey)!
|
||||
}
|
||||
@ -208,6 +242,13 @@ struct HistoryView: View {
|
||||
Text("\(Format.time(item.range.lowerBound)) ~ \(item.session.endAt == nil ? "진행 중" : Format.time(item.range.upperBound))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if !item.session.note.isEmpty {
|
||||
Label(item.session.note, systemImage: "note.text")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
.lineLimit(2)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text(Format.durationShort(item.duration))
|
||||
|
||||
@ -29,63 +29,65 @@ struct MainView: View {
|
||||
@State private var settingsEditingAction: Action?
|
||||
@State private var recordsAction: Action?
|
||||
@State private var deletingAction: Action?
|
||||
@State private var memoSession: TimeSession?
|
||||
|
||||
private var anyRunning: Bool { !runningSessions.isEmpty }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if anyRunning && !isEditing {
|
||||
runningArea
|
||||
}
|
||||
if isEditing {
|
||||
layoutControl
|
||||
}
|
||||
if actions.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
grid
|
||||
}
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if anyRunning && !isEditing {
|
||||
runningArea
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.background(AppTheme.background)
|
||||
.navigationTitle("하루 다님")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(isEditing ? "완료" : "배치 편집") {
|
||||
withAnimation {
|
||||
isEditing.toggle()
|
||||
}
|
||||
}
|
||||
.fontWeight(isEditing ? .bold : .regular)
|
||||
if isEditing {
|
||||
layoutControl
|
||||
}
|
||||
if actions.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
grid
|
||||
}
|
||||
}
|
||||
.sheet(item: $settingsEditingAction) { action in
|
||||
ActionEditorView(action: action)
|
||||
}
|
||||
.sheet(item: $recordsAction) { action in
|
||||
ActionRecordsSheet(action: action)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"‘\(deletingAction?.name ?? "")’ 행동을 삭제할까요? 기록도 함께 삭제됩니다.",
|
||||
isPresented: Binding(
|
||||
get: { deletingAction != nil },
|
||||
set: { if !$0 { deletingAction = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("삭제", role: .destructive) {
|
||||
if let action = deletingAction {
|
||||
context.delete(action)
|
||||
LiveActivityManager.sync(context: context)
|
||||
.padding()
|
||||
}
|
||||
.background(AppTheme.background)
|
||||
.navigationTitle(Text("하루다님", comment: "앱 이름"))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(isEditing ? "완료" : "배치 편집") {
|
||||
withAnimation {
|
||||
isEditing.toggle()
|
||||
}
|
||||
deletingAction = nil
|
||||
}
|
||||
Button("취소", role: .cancel) { deletingAction = nil }
|
||||
.fontWeight(isEditing ? .bold : .regular)
|
||||
}
|
||||
}
|
||||
.sheet(item: $settingsEditingAction) { action in
|
||||
ActionEditorView(action: action)
|
||||
}
|
||||
.sheet(item: $recordsAction) { action in
|
||||
ActionRecordsSheet(action: action)
|
||||
}
|
||||
.sheet(item: $memoSession) { session in
|
||||
SessionMemoSheet(session: session)
|
||||
}
|
||||
.alert(
|
||||
"행동 삭제",
|
||||
isPresented: Binding(
|
||||
get: { deletingAction != nil },
|
||||
set: { if !$0 { deletingAction = nil } }
|
||||
),
|
||||
presenting: deletingAction
|
||||
) { action in
|
||||
Button("삭제", role: .destructive) {
|
||||
context.delete(action)
|
||||
LiveActivityManager.sync(context: context)
|
||||
deletingAction = nil
|
||||
}
|
||||
Button("취소", role: .cancel) { deletingAction = nil }
|
||||
} message: { action in
|
||||
Text("‘\(action.name)’ 행동을 삭제할까요? 기록도 함께 삭제됩니다.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 현재 진행 중 영역
|
||||
@ -222,11 +224,13 @@ struct MainView: View {
|
||||
}
|
||||
|
||||
/// 세션 종료. "짧은 기록 무시" 설정보다 짧으면 기록하지 않고 삭제 (실수 방지)
|
||||
/// 정상 기록된 세션은 메모 작성 시트를 띄운다 (건너뛰기 가능).
|
||||
private func finish(_ session: TimeSession) {
|
||||
if minSessionSeconds > 0, session.duration() < Double(minSessionSeconds) {
|
||||
context.delete(session)
|
||||
} else {
|
||||
session.endAt = .now
|
||||
memoSession = session
|
||||
}
|
||||
LiveActivityManager.sync(context: context)
|
||||
}
|
||||
@ -248,6 +252,63 @@ struct MainView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 측정 종료 메모 시트
|
||||
|
||||
struct SessionMemoSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let session: TimeSession
|
||||
|
||||
@State private var text = ""
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if let action = session.action {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: action.symbolName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(action.color, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(action.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("\(Format.durationShort(session.duration())) 측정 완료")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
TextField("이번 측정에 대한 메모 (선택)", text: $text, axis: .vertical)
|
||||
.lineLimit(3...5)
|
||||
.padding(10)
|
||||
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.focused($focused)
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.background(AppTheme.background)
|
||||
.navigationTitle("메모 남기기")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("건너뛰기") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("저장") {
|
||||
session.note = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.height(280)])
|
||||
.onAppear { text = session.note }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 지글(흔들림) 애니메이션
|
||||
|
||||
struct JiggleEffect: ViewModifier {
|
||||
|
||||
@ -311,6 +311,7 @@ struct QuestEditorView: View {
|
||||
target.targetCount = targetCount
|
||||
target.direction = direction
|
||||
if quest == nil {
|
||||
target.sortOrder = (goal.quests.map(\.sortOrder).max() ?? -1) + 1
|
||||
context.insert(target)
|
||||
}
|
||||
dismiss()
|
||||
|
||||
@ -128,6 +128,12 @@ struct SessionRowLabel: View {
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AppTheme.green)
|
||||
}
|
||||
if !session.note.isEmpty {
|
||||
Label(session.note, systemImage: "note.text")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -162,6 +168,7 @@ struct SessionEditorView: View {
|
||||
@State private var startAt: Date = .now
|
||||
@State private var isFinished = true
|
||||
@State private var endAt: Date = .now
|
||||
@State private var note = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@ -179,6 +186,10 @@ struct SessionEditorView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Section("메모") {
|
||||
TextField("이 기록에 대한 메모 (선택)", text: $note, axis: .vertical)
|
||||
.lineLimit(2...5)
|
||||
}
|
||||
Section {
|
||||
Button("기록 삭제", role: .destructive) {
|
||||
context.delete(session)
|
||||
@ -197,6 +208,7 @@ struct SessionEditorView: View {
|
||||
Button("저장") {
|
||||
session.startAt = startAt
|
||||
session.endAt = isFinished ? max(endAt, startAt) : nil
|
||||
session.note = note.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
LiveActivityManager.sync(context: context)
|
||||
dismiss()
|
||||
}
|
||||
@ -204,6 +216,7 @@ struct SessionEditorView: View {
|
||||
}
|
||||
.onAppear {
|
||||
startAt = session.startAt
|
||||
note = session.note
|
||||
if let end = session.endAt {
|
||||
isFinished = true
|
||||
endAt = end
|
||||
@ -225,12 +238,17 @@ struct SessionAddView: View {
|
||||
|
||||
@State private var startAt: Date = .now.addingTimeInterval(-3600)
|
||||
@State private var endAt: Date = .now
|
||||
@State private var note = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
DatePicker("시작 시각", selection: $startAt)
|
||||
DatePicker("종료 시각", selection: $endAt, in: startAt...)
|
||||
Section("메모") {
|
||||
TextField("이 기록에 대한 메모 (선택)", text: $note, axis: .vertical)
|
||||
.lineLimit(2...5)
|
||||
}
|
||||
}
|
||||
.navigationTitle("시간 기록 추가")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@ -240,7 +258,9 @@ struct SessionAddView: View {
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("추가") {
|
||||
context.insert(TimeSession(action: action, startAt: startAt, endAt: max(endAt, startAt)))
|
||||
let session = TimeSession(action: action, startAt: startAt, endAt: max(endAt, startAt))
|
||||
session.note = note.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
context.insert(session)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,11 @@ struct SettingsView: View {
|
||||
@AppStorage(SettingsKeys.liveActivityMode) private var liveActivityMode = LiveActivityMode.latest.rawValue
|
||||
@AppStorage(SettingsKeys.isPremium) private var isPremium = false
|
||||
@AppStorage(SettingsKeys.minSessionSeconds) private var minSessionSeconds = 0
|
||||
@AppStorage(SettingsKeys.visibleTabs) private var visibleTabsRaw = TabBarConfig.defaultVisibleTabs
|
||||
|
||||
private var visibleTabs: [AppTab] {
|
||||
AppTab.visibleTabs(from: visibleTabsRaw)
|
||||
}
|
||||
|
||||
/// 하루 시작 시간을 DatePicker와 연결하기 위한 변환
|
||||
private var dayStartDate: Binding<Date> {
|
||||
@ -29,7 +34,6 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("화면") {
|
||||
Picker("테마", selection: $theme) {
|
||||
@ -42,6 +46,15 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
ForEach(AppTab.allCases) { tab in
|
||||
tabToggleRow(tab)
|
||||
}
|
||||
} header: {
|
||||
Text("탭바 구성")
|
||||
} footer: {
|
||||
Text("선택한 탭(최대 \(TabBarConfig.maxVisible)개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요.")
|
||||
}
|
||||
Section {
|
||||
Picker("주 시작 요일", selection: $weekStartWeekday) {
|
||||
ForEach(1...7, id: \.self) { weekday in
|
||||
@ -101,7 +114,55 @@ struct SettingsView: View {
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(AppTheme.background)
|
||||
.navigationTitle("설정")
|
||||
}
|
||||
|
||||
// MARK: 탭바 구성
|
||||
|
||||
private func tabToggleRow(_ tab: AppTab) -> some View {
|
||||
let isVisible = visibleTabs.contains(tab)
|
||||
let atMax = visibleTabs.count >= TabBarConfig.maxVisible
|
||||
let atMin = visibleTabs.count <= TabBarConfig.minVisible
|
||||
return Button {
|
||||
toggleTab(tab)
|
||||
} label: {
|
||||
HStack {
|
||||
Label {
|
||||
Text(tab.label)
|
||||
.foregroundStyle(.primary)
|
||||
} icon: {
|
||||
Image(systemName: tab.symbol)
|
||||
.foregroundStyle(AppTheme.green)
|
||||
}
|
||||
Spacer()
|
||||
if isVisible {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(AppTheme.green)
|
||||
.fontWeight(.semibold)
|
||||
} else {
|
||||
Text("더보기")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled((isVisible && atMin) || (!isVisible && atMax))
|
||||
.opacity((isVisible && atMin) || (!isVisible && atMax) ? 0.5 : 1)
|
||||
}
|
||||
|
||||
private func toggleTab(_ tab: AppTab) {
|
||||
var current = visibleTabs
|
||||
if let index = current.firstIndex(of: tab) {
|
||||
guard current.count > TabBarConfig.minVisible else { return }
|
||||
current.remove(at: index)
|
||||
} else {
|
||||
guard current.count < TabBarConfig.maxVisible else { return }
|
||||
current.append(tab)
|
||||
}
|
||||
// 스펙 탭 순서(메인→행동→꼬리표→목표→기록→설정) 유지
|
||||
let ordered = AppTab.allCases.filter { current.contains($0) }
|
||||
visibleTabsRaw = ordered.map(\.rawValue).joined(separator: ",")
|
||||
}
|
||||
}
|
||||
|
||||
@ -152,6 +213,7 @@ struct PremiumView: View {
|
||||
.background(AppTheme.background)
|
||||
.navigationTitle("프리미엄")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
}
|
||||
|
||||
private func featureRow(_ symbol: String, _ title: String, _ detail: String) -> some View {
|
||||
|
||||
@ -10,82 +10,92 @@ import SwiftData
|
||||
|
||||
struct TagListView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Query(sort: \Tag.createdAt) private var tags: [Tag]
|
||||
@Query(sort: [SortDescriptor(\Tag.sortOrder), SortDescriptor(\Tag.createdAt)]) private var tags: [Tag]
|
||||
|
||||
@State private var editingTag: Tag?
|
||||
@State private var showingAdd = false
|
||||
@State private var deletingTag: Tag?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(tags) { tag in
|
||||
Button {
|
||||
editingTag = tag
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(tag.color)
|
||||
.frame(width: 22, height: 22)
|
||||
Text(tag.name)
|
||||
.font(.body.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Text("행동 \(tag.actions.count)개")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.swipeActions {
|
||||
Button("삭제", role: .destructive) {
|
||||
deletingTag = tag
|
||||
}
|
||||
List {
|
||||
ForEach(tags) { tag in
|
||||
Button {
|
||||
editingTag = tag
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(tag.color)
|
||||
.frame(width: 22, height: 22)
|
||||
Text(tag.name)
|
||||
.font(.body.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Text("행동 \(tag.actions.count)개")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
if tags.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"꼬리표가 없어요",
|
||||
systemImage: "tag",
|
||||
description: Text("오른쪽 위 + 버튼으로 꼬리표를 추가하세요.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(AppTheme.background)
|
||||
.navigationTitle("꼬리표")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showingAdd = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.buttonStyle(.plain)
|
||||
.swipeActions {
|
||||
Button("삭제", role: .destructive) {
|
||||
deletingTag = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingTag) { tag in
|
||||
TagEditorView(tag: tag)
|
||||
}
|
||||
.sheet(isPresented: $showingAdd) {
|
||||
TagEditorView(tag: nil)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"‘\(deletingTag?.name ?? "")’ 꼬리표를 삭제할까요? 행동은 삭제되지 않아요.",
|
||||
isPresented: Binding(
|
||||
get: { deletingTag != nil },
|
||||
set: { if !$0 { deletingTag = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("삭제", role: .destructive) {
|
||||
if let tag = deletingTag {
|
||||
context.delete(tag)
|
||||
}
|
||||
deletingTag = nil
|
||||
.onMove { source, destination in
|
||||
var ordered = tags
|
||||
ordered.move(fromOffsets: source, toOffset: destination)
|
||||
for (index, tag) in ordered.enumerated() {
|
||||
tag.sortOrder = index
|
||||
}
|
||||
Button("취소", role: .cancel) { deletingTag = nil }
|
||||
}
|
||||
if tags.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"꼬리표가 없어요",
|
||||
systemImage: "tag",
|
||||
description: Text("오른쪽 위 + 버튼으로 꼬리표를 추가하세요.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(AppTheme.background)
|
||||
.navigationTitle("꼬리표")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if !tags.isEmpty {
|
||||
EditButton()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showingAdd = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingTag) { tag in
|
||||
TagEditorView(tag: tag)
|
||||
}
|
||||
.sheet(isPresented: $showingAdd) {
|
||||
TagEditorView(tag: nil)
|
||||
}
|
||||
.alert(
|
||||
"꼬리표 삭제",
|
||||
isPresented: Binding(
|
||||
get: { deletingTag != nil },
|
||||
set: { if !$0 { deletingTag = nil } }
|
||||
),
|
||||
presenting: deletingTag
|
||||
) { tag in
|
||||
Button("삭제", role: .destructive) {
|
||||
context.delete(tag)
|
||||
deletingTag = nil
|
||||
}
|
||||
Button("취소", role: .cancel) { deletingTag = nil }
|
||||
} message: { tag in
|
||||
Text("‘\(tag.name)’ 꼬리표를 삭제할까요? 행동은 삭제되지 않아요.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -95,6 +105,7 @@ struct TagListView: View {
|
||||
struct TagEditorView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Query private var allTags: [Tag]
|
||||
|
||||
/// nil이면 새 꼬리표 추가
|
||||
let tag: Tag?
|
||||
@ -167,7 +178,9 @@ struct TagEditorView: View {
|
||||
tag.name = trimmed
|
||||
tag.colorHex = color.hexString
|
||||
} else {
|
||||
context.insert(Tag(name: trimmed, colorHex: color.hexString))
|
||||
let newTag = Tag(name: trimmed, colorHex: color.hexString)
|
||||
newTag.sortOrder = (allTags.map(\.sortOrder).max() ?? -1) + 1
|
||||
context.insert(newTag)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user