From f6e2963c88f319e40907bca84f07b3ad58e8ed14 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Wed, 8 Jul 2026 16:18:33 +0900 Subject: [PATCH] feat: major app overhaul with new features, UI fixes, and branding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [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 --- .../Haru_Danim.xcodeproj/project.pbxproj | 6 +- .../UserInterfaceState.xcuserstate | Bin 21203 -> 21210 bytes myApp/Haru_Danim/IOS/ContentView.swift | 159 +++-- myApp/Haru_Danim/IOS/Core/DebugSeed.swift | 7 +- myApp/Haru_Danim/IOS/Core/Models.swift | 15 +- myApp/Haru_Danim/IOS/Core/Settings.swift | 9 + myApp/Haru_Danim/IOS/InfoPlist.xcstrings | 41 ++ myApp/Haru_Danim/IOS/Localizable.xcstrings | 643 ++++++++++++++++++ myApp/Haru_Danim/IOS/Views/ActionViews.swift | 215 ++++-- myApp/Haru_Danim/IOS/Views/GoalViews.swift | 161 +++-- myApp/Haru_Danim/IOS/Views/HistoryView.swift | 139 ++-- myApp/Haru_Danim/IOS/Views/MainView.swift | 153 +++-- .../IOS/Views/QuestEditorView.swift | 1 + .../Haru_Danim/IOS/Views/RecordEditors.swift | 22 +- myApp/Haru_Danim/IOS/Views/SettingsView.swift | 64 +- myApp/Haru_Danim/IOS/Views/TagViews.swift | 141 ++-- 16 files changed, 1426 insertions(+), 350 deletions(-) create mode 100644 myApp/Haru_Danim/IOS/InfoPlist.xcstrings create mode 100644 myApp/Haru_Danim/IOS/Localizable.xcstrings diff --git a/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.pbxproj b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.pbxproj index da12963..9519ce9 100644 --- a/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.pbxproj +++ b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.pbxproj @@ -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; diff --git a/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate index 8319d3cd07ce93ea29926305d95415d69a494f93..f6bd72e42647229e402e87443f7c87875e41e04b 100644 GIT binary patch delta 3569 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 delta 3661 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 Grv4jn#>^D} diff --git a/myApp/Haru_Danim/IOS/ContentView.swift b/myApp/Haru_Danim/IOS/ContentView.swift index 2aeabdd..ddfb51a 100644 --- a/myApp/Haru_Danim/IOS/ContentView.swift +++ b/myApp/Haru_Danim/IOS/ContentView.swift @@ -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("더보기") + } } } diff --git a/myApp/Haru_Danim/IOS/Core/DebugSeed.swift b/myApp/Haru_Danim/IOS/Core/DebugSeed.swift index 8c449be..a2108fc 100644 --- a/myApp/Haru_Danim/IOS/Core/DebugSeed.swift +++ b/myApp/Haru_Danim/IOS/Core/DebugSeed.swift @@ -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))) diff --git a/myApp/Haru_Danim/IOS/Core/Models.swift b/myApp/Haru_Danim/IOS/Core/Models.swift index 05f3e6f..ebc74ad 100644 --- a/myApp/Haru_Danim/IOS/Core/Models.swift +++ b/myApp/Haru_Danim/IOS/Core/Models.swift @@ -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) { diff --git a/myApp/Haru_Danim/IOS/Core/Settings.swift b/myApp/Haru_Danim/IOS/Core/Settings.swift index b51e72e..5eb1ad2 100644 --- a/myApp/Haru_Danim/IOS/Core/Settings.swift +++ b/myApp/Haru_Danim/IOS/Core/Settings.swift @@ -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 } /// 짧은 기록 무시 옵션 선택지 (초) diff --git a/myApp/Haru_Danim/IOS/InfoPlist.xcstrings b/myApp/Haru_Danim/IOS/InfoPlist.xcstrings new file mode 100644 index 0000000..e63daff --- /dev/null +++ b/myApp/Haru_Danim/IOS/InfoPlist.xcstrings @@ -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" +} \ No newline at end of file diff --git a/myApp/Haru_Danim/IOS/Localizable.xcstrings b/myApp/Haru_Danim/IOS/Localizable.xcstrings new file mode 100644 index 0000000..4cb570d --- /dev/null +++ b/myApp/Haru_Danim/IOS/Localizable.xcstrings @@ -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" +} \ No newline at end of file diff --git a/myApp/Haru_Danim/IOS/Views/ActionViews.swift b/myApp/Haru_Danim/IOS/Views/ActionViews.swift index cfa22ee..d770734 100644 --- a/myApp/Haru_Danim/IOS/Views/ActionViews.swift +++ b/myApp/Haru_Danim/IOS/Views/ActionViews.swift @@ -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 { diff --git a/myApp/Haru_Danim/IOS/Views/GoalViews.swift b/myApp/Haru_Danim/IOS/Views/GoalViews.swift index 11f3345..d31ff2a 100644 --- a/myApp/Haru_Danim/IOS/Views/GoalViews.swift +++ b/myApp/Haru_Danim/IOS/Views/GoalViews.swift @@ -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) diff --git a/myApp/Haru_Danim/IOS/Views/HistoryView.swift b/myApp/Haru_Danim/IOS/Views/HistoryView.swift index 0900f8e..2138e4f 100644 --- a/myApp/Haru_Danim/IOS/Views/HistoryView.swift +++ b/myApp/Haru_Danim/IOS/Views/HistoryView.swift @@ -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 { 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 { + 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)) diff --git a/myApp/Haru_Danim/IOS/Views/MainView.swift b/myApp/Haru_Danim/IOS/Views/MainView.swift index b638800..23caaa0 100644 --- a/myApp/Haru_Danim/IOS/Views/MainView.swift +++ b/myApp/Haru_Danim/IOS/Views/MainView.swift @@ -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 { diff --git a/myApp/Haru_Danim/IOS/Views/QuestEditorView.swift b/myApp/Haru_Danim/IOS/Views/QuestEditorView.swift index 1ab43a5..d270864 100644 --- a/myApp/Haru_Danim/IOS/Views/QuestEditorView.swift +++ b/myApp/Haru_Danim/IOS/Views/QuestEditorView.swift @@ -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() diff --git a/myApp/Haru_Danim/IOS/Views/RecordEditors.swift b/myApp/Haru_Danim/IOS/Views/RecordEditors.swift index 90b3a99..8f3edb7 100644 --- a/myApp/Haru_Danim/IOS/Views/RecordEditors.swift +++ b/myApp/Haru_Danim/IOS/Views/RecordEditors.swift @@ -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() } } diff --git a/myApp/Haru_Danim/IOS/Views/SettingsView.swift b/myApp/Haru_Danim/IOS/Views/SettingsView.swift index 973e041..0206637 100644 --- a/myApp/Haru_Danim/IOS/Views/SettingsView.swift +++ b/myApp/Haru_Danim/IOS/Views/SettingsView.swift @@ -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 { @@ -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 { diff --git a/myApp/Haru_Danim/IOS/Views/TagViews.swift b/myApp/Haru_Danim/IOS/Views/TagViews.swift index f94ac41..fc9098c 100644 --- a/myApp/Haru_Danim/IOS/Views/TagViews.swift +++ b/myApp/Haru_Danim/IOS/Views/TagViews.swift @@ -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() }