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:
songyc macbook 2026-07-08 16:18:33 +09:00
parent 3000ce6713
commit f6e2963c88
16 changed files with 1426 additions and 350 deletions

View File

@ -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;

View File

@ -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("더보기")
}
}
}

View File

@ -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)))

View File

@ -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) {

View File

@ -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
}
/// ()

View 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"
}

View 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"
}

View File

@ -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 {

View File

@ -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)

View File

@ -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))

View File

@ -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 {

View File

@ -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()

View File

@ -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()
}
}

View File

@ -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 {

View File

@ -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()
}