diff --git a/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.pbxproj b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.pbxproj index eea9049..da12963 100644 --- a/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.pbxproj +++ b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.pbxproj @@ -6,16 +6,68 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + AA000000000000000000000E /* Haru_DanimWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000004 /* Haru_DanimWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + AA000000000000000000000B /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 725A9E7C2FFCC65000846FFB /* Project object */; + proxyType = 1; + remoteGlobalIDString = AA0000000000000000000003; + remoteInfo = Haru_DanimWidgets; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + AA000000000000000000000D /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + AA000000000000000000000E /* Haru_DanimWidgets.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 725A9E842FFCC65000846FFB /* Haru_Danim.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Haru_Danim.app; sourceTree = BUILT_PRODUCTS_DIR; }; + AA0000000000000000000004 /* Haru_DanimWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Haru_DanimWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + AA000000000000000000000F /* Exceptions for "Widgets" folder in "Haru_DanimWidgets" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = AA0000000000000000000003 /* Haru_DanimWidgets */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 725A9E862FFCC65000846FFB /* IOS */ = { isa = PBXFileSystemSynchronizedRootGroup; path = IOS; sourceTree = ""; }; + AA0000000000000000000001 /* Widgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + AA000000000000000000000F /* Exceptions for "Widgets" folder in "Haru_DanimWidgets" target */, + ); + path = Widgets; + sourceTree = ""; + }; + AA0000000000000000000002 /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -26,6 +78,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA0000000000000000000006 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -33,6 +92,8 @@ isa = PBXGroup; children = ( 725A9E862FFCC65000846FFB /* IOS */, + AA0000000000000000000002 /* Shared */, + AA0000000000000000000001 /* Widgets */, 725A9E852FFCC65000846FFB /* Products */, ); sourceTree = ""; @@ -41,6 +102,7 @@ isa = PBXGroup; children = ( 725A9E842FFCC65000846FFB /* Haru_Danim.app */, + AA0000000000000000000004 /* Haru_DanimWidgets.appex */, ); name = Products; sourceTree = ""; @@ -55,13 +117,16 @@ 725A9E802FFCC65000846FFB /* Sources */, 725A9E812FFCC65000846FFB /* Frameworks */, 725A9E822FFCC65000846FFB /* Resources */, + AA000000000000000000000D /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + AA000000000000000000000C /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 725A9E862FFCC65000846FFB /* IOS */, + AA0000000000000000000002 /* Shared */, ); name = Haru_Danim; packageProductDependencies = ( @@ -70,6 +135,29 @@ productReference = 725A9E842FFCC65000846FFB /* Haru_Danim.app */; productType = "com.apple.product-type.application"; }; + AA0000000000000000000003 /* Haru_DanimWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA000000000000000000000A /* Build configuration list for PBXNativeTarget "Haru_DanimWidgets" */; + buildPhases = ( + AA0000000000000000000005 /* Sources */, + AA0000000000000000000006 /* Frameworks */, + AA0000000000000000000007 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + AA0000000000000000000001 /* Widgets */, + AA0000000000000000000002 /* Shared */, + ); + name = Haru_DanimWidgets; + packageProductDependencies = ( + ); + productName = Haru_DanimWidgets; + productReference = AA0000000000000000000004 /* Haru_DanimWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -83,6 +171,9 @@ 725A9E832FFCC65000846FFB = { CreatedOnToolsVersion = 26.6; }; + AA0000000000000000000003 = { + CreatedOnToolsVersion = 26.6; + }; }; }; buildConfigurationList = 725A9E7F2FFCC65000846FFB /* Build configuration list for PBXProject "Haru_Danim" */; @@ -100,6 +191,7 @@ projectRoot = ""; targets = ( 725A9E832FFCC65000846FFB /* Haru_Danim */, + AA0000000000000000000003 /* Haru_DanimWidgets */, ); }; /* End PBXProject section */ @@ -112,6 +204,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA0000000000000000000007 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -122,8 +221,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA0000000000000000000005 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + AA000000000000000000000C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = AA0000000000000000000003 /* Haru_DanimWidgets */; + targetProxy = AA000000000000000000000B /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 725A9E8D2FFCC65100846FFB /* Debug */ = { isa = XCBuildConfiguration; @@ -254,6 +368,7 @@ DEVELOPMENT_TEAM = 5GNS4Z2SYT; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -286,6 +401,7 @@ DEVELOPMENT_TEAM = 5GNS4Z2SYT; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -308,6 +424,56 @@ }; name = Release; }; + AA0000000000000000000008 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5GNS4Z2SYT; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Widgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "하루 다님 위젯"; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.yechan.Haru-Danim.Widgets"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AA0000000000000000000009 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5GNS4Z2SYT; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Widgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "하루 다님 위젯"; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.yechan.Haru-Danim.Widgets"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -329,6 +495,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + AA000000000000000000000A /* Build configuration list for PBXNativeTarget "Haru_DanimWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA0000000000000000000008 /* Debug */, + AA0000000000000000000009 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 725A9E7C2FFCC65000846FFB /* Project object */; 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 783e7eb..8319d3c 100644 Binary files a/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate and b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/myApp/Haru_Danim/Haru_Danim.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist b/myApp/Haru_Danim/Haru_Danim.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist index 4babad8..2b7630d 100644 --- a/myApp/Haru_Danim/Haru_Danim.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/myApp/Haru_Danim/Haru_Danim.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,6 +9,11 @@ orderHint 0 + Haru_DanimWidgets.xcscheme_^#shared#^_ + + orderHint + 0 + diff --git a/myApp/Haru_Danim/IOS/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/myApp/Haru_Danim/IOS/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..227ab2d Binary files /dev/null and b/myApp/Haru_Danim/IOS/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/myApp/Haru_Danim/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/myApp/Haru_Danim/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..87d4015 100644 --- a/myApp/Haru_Danim/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/myApp/Haru_Danim/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -12,6 +13,7 @@ "value" : "dark" } ], + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -23,6 +25,7 @@ "value" : "tinted" } ], + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/myApp/Haru_Danim/IOS/Assets.xcassets/AppLogo.imageset/AppLogo.png b/myApp/Haru_Danim/IOS/Assets.xcassets/AppLogo.imageset/AppLogo.png new file mode 100644 index 0000000..4e935ef Binary files /dev/null and b/myApp/Haru_Danim/IOS/Assets.xcassets/AppLogo.imageset/AppLogo.png differ diff --git a/myApp/Haru_Danim/IOS/Assets.xcassets/AppLogo.imageset/Contents.json b/myApp/Haru_Danim/IOS/Assets.xcassets/AppLogo.imageset/Contents.json new file mode 100644 index 0000000..a75bb54 --- /dev/null +++ b/myApp/Haru_Danim/IOS/Assets.xcassets/AppLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "AppLogo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/Haru_Danim/IOS/ContentView.swift b/myApp/Haru_Danim/IOS/ContentView.swift index e2eefb9..2aeabdd 100644 --- a/myApp/Haru_Danim/IOS/ContentView.swift +++ b/myApp/Haru_Danim/IOS/ContentView.swift @@ -27,6 +27,7 @@ struct ContentView: View { .task { #if DEBUG DebugSeed.seedIfRequested(context: context) + DebugSeed.autoStartIfRequested(context: context) #endif try? await Task.sleep(for: .seconds(1.2)) withAnimation(.easeOut(duration: 0.4)) { @@ -36,27 +37,37 @@ struct ContentView: View { } } -// MARK: - 스플래시 (로고는 플레이스홀더, CLAUDE.md §5.1) +// MARK: - 스플래시 (앱 로고 + 앱 이름, CLAUDE.md §5.1) struct SplashView: View { + @State private var appeared = false + var body: some View { ZStack { + // 로고 배경(웜 화이트)과 이어지도록 라이트/다크 공통 배경 사용 AppTheme.background.ignoresSafeArea() - VStack(spacing: 20) { - ZStack { - RoundedRectangle(cornerRadius: 28, style: .continuous) - .fill(AppTheme.green) - .frame(width: 110, height: 110) - Image(systemName: "figure.walk") - .font(.system(size: 52, weight: .medium)) - .foregroundStyle(AppTheme.yellow) - } + VStack(spacing: 18) { + Image("AppLogo") + .resizable() + .scaledToFit() + .frame(width: 150, height: 150) + .clipShape(RoundedRectangle(cornerRadius: 34, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 14, y: 6) + .scaleEffect(appeared ? 1 : 0.85) + .opacity(appeared ? 1 : 0) Text("하루 다님") .font(.largeTitle.bold()) .foregroundStyle(AppTheme.green) + .opacity(appeared ? 1 : 0) Text("하루 습관 및 시간 추적") .font(.subheadline) .foregroundStyle(.secondary) + .opacity(appeared ? 1 : 0) + } + } + .onAppear { + withAnimation(.spring(duration: 0.5)) { + appeared = true } } } @@ -104,48 +115,55 @@ struct MainTabView: View { }() var body: some View { - VStack(spacing: 0) { - ZStack { - switch selection { - case .main: MainView() - case .action: ActionListView() - case .tag: TagListView() - case .goal: GoalListView() - case .history: HistoryView() - case .settings: SettingsView() - } + 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) - + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .safeAreaInset(edge: .bottom) { tabBar } .background(AppTheme.background) } + /// 리퀴드 글라스 스타일의 플로팅 탭바 private var tabBar: some View { - HStack { + HStack(spacing: 0) { ForEach(AppTab.allCases) { tab in Button { - selection = tab + withAnimation(.spring(duration: 0.25)) { + selection = tab + } } label: { VStack(spacing: 3) { Image(systemName: tab.symbol) - .font(.system(size: 20)) + .font(.system(size: 19, weight: .medium)) Text(tab.label) - .font(.system(size: 10)) + .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) } } - .padding(.top, 8) - .padding(.bottom, 2) - .background(.bar) - .overlay(alignment: .top) { - Divider() - } + .padding(6) + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 26, style: .continuous)) + .padding(.horizontal, 10) + .padding(.bottom, 4) } } diff --git a/myApp/Haru_Danim/IOS/Core/DebugSeed.swift b/myApp/Haru_Danim/IOS/Core/DebugSeed.swift index 91089b0..8c449be 100644 --- a/myApp/Haru_Danim/IOS/Core/DebugSeed.swift +++ b/myApp/Haru_Danim/IOS/Core/DebugSeed.swift @@ -10,6 +10,17 @@ import Foundation import SwiftData enum DebugSeed { + /// `-autoStart <행동이름>` 런치 인자가 있으면 해당 행동의 시간 측정을 시작 (Live Activity 검증용) + static func autoStartIfRequested(context: ModelContext) { + guard let name = UserDefaults.standard.string(forKey: "autoStart") else { return } + let descriptor = FetchDescriptor(predicate: #Predicate { $0.name == name }) + guard let action = try? context.fetch(descriptor).first else { return } + if action.runningSession == nil { + context.insert(TimeSession(action: action, startAt: .now)) + } + LiveActivityManager.sync(context: context) + } + static func seedIfRequested(context: ModelContext) { guard UserDefaults.standard.bool(forKey: "seedDemo") else { return } let existing = (try? context.fetchCount(FetchDescriptor())) ?? 0 diff --git a/myApp/Haru_Danim/IOS/Core/LiveActivityManager.swift b/myApp/Haru_Danim/IOS/Core/LiveActivityManager.swift new file mode 100644 index 0000000..2930e2d --- /dev/null +++ b/myApp/Haru_Danim/IOS/Core/LiveActivityManager.swift @@ -0,0 +1,67 @@ +// +// LiveActivityManager.swift +// Haru_Danim +// +// 추적 상태를 다이나믹 아일랜드/잠금화면 Live Activity에 반영 (CLAUDE.md §4.1, §4.2) +// + +import Foundation +import ActivityKit +import SwiftData + +enum LiveActivityManager { + /// 현재 진행 중인 세션들을 읽어 Live Activity를 시작/갱신/종료한다. + /// 세션 시작·종료·수정 후마다 호출. + static func sync(context: ModelContext) { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.endAt == nil }, + sortBy: [SortDescriptor(\.startAt, order: .forward)] + ) + let running = (try? context.fetch(descriptor)) ?? [] + + let mode = LiveActivityMode( + rawValue: UserDefaults.standard.string(forKey: SettingsKeys.liveActivityMode) ?? "" + ) ?? .latest + let representative = mode == .earliest ? running.first : running.last + + guard let representative, let action = representative.action else { + endAll() + return + } + + let state = TrackingActivityAttributes.ContentState( + actionName: action.name, + symbolName: action.symbolName, + colorHex: action.sortedTags.first?.colorHex ?? "#2F6B4F", + startedAt: representative.startAt, + extraCount: max(running.count - 1, 0) + ) + let content = ActivityContent(state: state, staleDate: nil) + + Task { + let activities = Activity.activities + if let activity = activities.first { + await activity.update(content) + // 혹시 중복 생성된 것이 있으면 정리 + for extra in activities.dropFirst() { + await extra.end(nil, dismissalPolicy: .immediate) + } + } else { + _ = try? Activity.request( + attributes: TrackingActivityAttributes(), + content: content + ) + } + } + } + + static func endAll() { + Task { + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + } + } +} diff --git a/myApp/Haru_Danim/IOS/Core/Settings.swift b/myApp/Haru_Danim/IOS/Core/Settings.swift index 460a739..b51e72e 100644 --- a/myApp/Haru_Danim/IOS/Core/Settings.swift +++ b/myApp/Haru_Danim/IOS/Core/Settings.swift @@ -20,6 +20,22 @@ enum SettingsKeys { static let liveActivityMode = "settings.liveActivityMode" /// 프리미엄 결제 여부 static let isPremium = "settings.isPremium" + /// 메인 탭 그리드 한 줄에 표시할 행동 버튼 개수. 기본값 3 + static let gridColumns = "settings.gridColumns" + /// 이 시간(초)보다 짧게 측정된 세션은 기록하지 않음. 0 = 사용 안 함 + static let minSessionSeconds = "settings.minSessionSeconds" +} + +/// 짧은 기록 무시 옵션 선택지 (초) +enum MinSessionOption { + static let choices: [(seconds: Int, label: String)] = [ + (0, "사용 안 함"), + (3, "3초"), + (5, "5초"), + (10, "10초"), + (30, "30초"), + (60, "1분"), + ] } enum LiveActivityMode: String, CaseIterable, Identifiable { diff --git a/myApp/Haru_Danim/IOS/Views/MainView.swift b/myApp/Haru_Danim/IOS/Views/MainView.swift index 031701f..b638800 100644 --- a/myApp/Haru_Danim/IOS/Views/MainView.swift +++ b/myApp/Haru_Danim/IOS/Views/MainView.swift @@ -15,8 +15,16 @@ struct MainView: View { @Query(filter: #Predicate { $0.endAt == nil }, sort: \TimeSession.startAt) private var runningSessions: [TimeSession] - @State private var isEditing = false - @State private var jiggle = false + @AppStorage(SettingsKeys.gridColumns) private var gridColumns = 3 + @AppStorage(SettingsKeys.minSessionSeconds) private var minSessionSeconds = 0 + + @State private var isEditing: Bool = { + #if DEBUG + return UserDefaults.standard.bool(forKey: "startEditing") + #else + return false + #endif + }() @State private var draggingAction: Action? @State private var settingsEditingAction: Action? @State private var recordsAction: Action? @@ -28,9 +36,12 @@ struct MainView: View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 16) { - if anyRunning { + if anyRunning && !isEditing { runningArea } + if isEditing { + layoutControl + } if actions.isEmpty { emptyState } else { @@ -46,7 +57,6 @@ struct MainView: View { Button(isEditing ? "완료" : "배치 편집") { withAnimation { isEditing.toggle() - jiggle = isEditing } } .fontWeight(isEditing ? .bold : .regular) @@ -69,6 +79,7 @@ struct MainView: View { Button("삭제", role: .destructive) { if let action = deletingAction { context.delete(action) + LiveActivityManager.sync(context: context) } deletingAction = nil } @@ -96,27 +107,48 @@ struct MainView: View { .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) } + // MARK: 배치 편집 컨트롤 (한 줄 개수 선택) + + private var layoutControl: some View { + VStack(alignment: .leading, spacing: 8) { + Label("한 줄에 표시할 개수", systemImage: "square.grid.3x3") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(AppTheme.green) + Picker("한 줄에 표시할 개수", selection: $gridColumns) { + ForEach(2...6, id: \.self) { n in + Text("\(n)개").tag(n) + } + } + .pickerStyle(.segmented) + Text("버튼을 길게 눌러 끌면 순서를 바꿀 수 있어요.") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(14) + .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + // MARK: 그리드 private var grid: some View { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 104), spacing: 12)], spacing: 12) { + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: gridColumns), + spacing: 12 + ) { ForEach(actions) { action in cell(for: action) } } .animation(.default, value: actions.map(\.persistentModelID)) + .animation(.default, value: gridColumns) } @ViewBuilder private func cell(for action: Action) -> some View { - let base = ActionButtonCell(action: action, isEditing: isEditing) { + let base = ActionButtonCell(action: action, isEditing: isEditing, compact: gridColumns >= 5) { handleTap(action) } - .rotationEffect(.degrees(isEditing ? (jiggle ? 1.6 : -1.6) : 0)) - .animation( - isEditing ? .easeInOut(duration: 0.13).repeatForever(autoreverses: true) : .default, - value: jiggle - ) + .modifier(JiggleEffect(active: isEditing)) if isEditing { base @@ -179,17 +211,28 @@ struct MainView: View { switch action.trackingType { case .time: if let session = action.runningSession { - session.endAt = .now + finish(session) } else { context.insert(TimeSession(action: action, startAt: .now)) + LiveActivityManager.sync(context: context) } case .count: context.insert(CountEntry(action: action, timestamp: .now)) } } + /// 세션 종료. "짧은 기록 무시" 설정보다 짧으면 기록하지 않고 삭제 (실수 방지) + private func finish(_ session: TimeSession) { + if minSessionSeconds > 0, session.duration() < Double(minSessionSeconds) { + context.delete(session) + } else { + session.endAt = .now + } + LiveActivityManager.sync(context: context) + } + private func stop(_ session: TimeSession) { - session.endAt = .now + finish(session) } private func moveDragging(to target: Action) { @@ -205,6 +248,38 @@ struct MainView: View { } } +// MARK: - 지글(흔들림) 애니메이션 + +struct JiggleEffect: ViewModifier { + let active: Bool + @State private var angle: Double = 0 + + func body(content: Content) -> some View { + content + .rotationEffect(.degrees(angle)) + .onChange(of: active) { _, on in + if on { + angle = -1.7 + withAnimation(.easeInOut(duration: 0.13).repeatForever(autoreverses: true)) { + angle = 1.7 + } + } else { + withAnimation(.easeOut(duration: 0.15)) { + angle = 0 + } + } + } + .onAppear { + if active { + angle = -1.7 + withAnimation(.easeInOut(duration: 0.13).repeatForever(autoreverses: true)) { + angle = 1.7 + } + } + } + } +} + // MARK: - 드래그 순서 변경 private struct ActionReorderDelegate: DropDelegate { @@ -268,10 +343,13 @@ struct RunningSessionRow: View { struct ActionButtonCell: View { let action: Action let isEditing: Bool + var compact: Bool = false let onTap: () -> Void @State private var bounce = false + private var cornerRadius: CGFloat { compact ? 16 : 22 } + var body: some View { Button { onTap() @@ -279,13 +357,13 @@ struct ActionButtonCell: View { bounce.toggle() } } label: { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: compact ? 3 : 6) { HStack { Image(systemName: action.symbolName) - .font(.system(size: 26, weight: .medium)) + .font(.system(size: compact ? 17 : 26, weight: .medium)) .foregroundStyle(.white) Spacer() - if action.isRunning { + if action.isRunning && !compact { Image(systemName: "record.circle") .font(.system(size: 14, weight: .bold)) .foregroundStyle(AppTheme.yellow) @@ -294,26 +372,25 @@ struct ActionButtonCell: View { } Spacer(minLength: 0) Text(action.name) - .font(.footnote.weight(.semibold)) + .font(compact ? .caption2.weight(.semibold) : .footnote.weight(.semibold)) .foregroundStyle(.white) - .lineLimit(2) + .lineLimit(compact ? 1 : 2) .multilineTextAlignment(.leading) footer } - .padding(12) - .frame(minHeight: 104, alignment: .topLeading) + .padding(compact ? 8 : 12) + .frame(minHeight: compact ? 68 : 104, alignment: .topLeading) .frame(maxWidth: .infinity) .background( - RoundedRectangle(cornerRadius: 22, style: .continuous) + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill(action.color.gradient) ) .overlay { if action.isRunning { - RoundedRectangle(cornerRadius: 22, style: .continuous) - .strokeBorder(AppTheme.yellow, lineWidth: 3) + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder(AppTheme.yellow, lineWidth: compact ? 2 : 3) } } - .scaleEffect(bounce ? 1.0 : 1.0) } .buttonStyle(PressableButtonStyle()) .sensoryFeedback(.impact(weight: .light), trigger: bounce) @@ -327,18 +404,20 @@ struct ActionButtonCell: View { if let session = action.runningSession { TimelineView(.periodic(from: .now, by: 1)) { timeline in Text(Format.timer(timeline.date.timeIntervalSince(session.startAt))) - .font(.caption.weight(.bold).monospacedDigit()) + .font((compact ? Font.caption2 : Font.caption).weight(.bold).monospacedDigit()) .foregroundStyle(AppTheme.yellow) } } else { Text(todayLabel) .font(.caption2) .foregroundStyle(.white.opacity(0.85)) + .lineLimit(1) } case .count: Text(todayLabel) .font(.caption2) .foregroundStyle(.white.opacity(0.85)) + .lineLimit(1) } } diff --git a/myApp/Haru_Danim/IOS/Views/RecordEditors.swift b/myApp/Haru_Danim/IOS/Views/RecordEditors.swift index 2011d87..90b3a99 100644 --- a/myApp/Haru_Danim/IOS/Views/RecordEditors.swift +++ b/myApp/Haru_Danim/IOS/Views/RecordEditors.swift @@ -56,6 +56,7 @@ struct ActionRecordsSheet: View { .swipeActions { Button("삭제", role: .destructive) { context.delete(session) + LiveActivityManager.sync(context: context) } } } @@ -181,6 +182,7 @@ struct SessionEditorView: View { Section { Button("기록 삭제", role: .destructive) { context.delete(session) + LiveActivityManager.sync(context: context) dismiss() } } @@ -195,6 +197,7 @@ struct SessionEditorView: View { Button("저장") { session.startAt = startAt session.endAt = isFinished ? max(endAt, startAt) : nil + LiveActivityManager.sync(context: context) dismiss() } } diff --git a/myApp/Haru_Danim/IOS/Views/SettingsView.swift b/myApp/Haru_Danim/IOS/Views/SettingsView.swift index 3086295..973e041 100644 --- a/myApp/Haru_Danim/IOS/Views/SettingsView.swift +++ b/myApp/Haru_Danim/IOS/Views/SettingsView.swift @@ -14,6 +14,7 @@ struct SettingsView: View { @AppStorage(SettingsKeys.dayStartMinutes) private var dayStartMinutes = 0 @AppStorage(SettingsKeys.liveActivityMode) private var liveActivityMode = LiveActivityMode.latest.rawValue @AppStorage(SettingsKeys.isPremium) private var isPremium = false + @AppStorage(SettingsKeys.minSessionSeconds) private var minSessionSeconds = 0 /// 하루 시작 시간을 DatePicker와 연결하기 위한 변환 private var dayStartDate: Binding { @@ -53,6 +54,17 @@ struct SettingsView: View { } footer: { Text("하루 시작 시간을 걸치는 기록은 통계에서 자동으로 날짜별로 나누어 계산돼요.") } + Section { + Picker("짧은 기록 무시", selection: $minSessionSeconds) { + ForEach(MinSessionOption.choices, id: \.seconds) { option in + Text(option.label).tag(option.seconds) + } + } + } header: { + Text("측정") + } footer: { + Text("설정한 시간보다 짧게 측정하고 종료한 기록은 저장하지 않아요. 버튼을 실수로 눌렀을 때 유용해요.") + } Section { Picker("대표 시간 기준", selection: $liveActivityMode) { ForEach(LiveActivityMode.allCases) { mode in diff --git a/myApp/Haru_Danim/Shared/TrackingActivityAttributes.swift b/myApp/Haru_Danim/Shared/TrackingActivityAttributes.swift new file mode 100644 index 0000000..5785854 --- /dev/null +++ b/myApp/Haru_Danim/Shared/TrackingActivityAttributes.swift @@ -0,0 +1,40 @@ +// +// TrackingActivityAttributes.swift +// Haru_Danim +// +// Live Activity 상태 정의. 앱 타깃과 위젯 확장 타깃 양쪽에 포함된다. +// + +import Foundation +import ActivityKit +import SwiftUI + +nonisolated struct TrackingActivityAttributes: ActivityAttributes { + nonisolated struct ContentState: Codable, Hashable { + /// 대표로 표시할 행동 (설정: 가장 먼저/나중에 시작한 행동) + var actionName: String + var symbolName: String + var colorHex: String + var startedAt: Date + /// 동시에 추적 중인 추가 행동 개수 (+1, +2 표시용) + var extraCount: Int + } +} + +extension TrackingActivityAttributes.ContentState { + var tintColor: Color { + let cleaned = colorHex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var value: UInt64 = 0 + Scanner(string: cleaned).scanHexInt64(&value) + return Color( + red: Double((value >> 16) & 0xFF) / 255, + green: Double((value >> 8) & 0xFF) / 255, + blue: Double(value & 0xFF) / 255 + ) + } + + /// 타이머 표시용 구간 (상한은 충분히 먼 미래) + var timerRange: ClosedRange { + startedAt...startedAt.addingTimeInterval(60 * 60 * 24 * 30) + } +} diff --git a/myApp/Haru_Danim/Widgets/HaruDanimWidgets.swift b/myApp/Haru_Danim/Widgets/HaruDanimWidgets.swift new file mode 100644 index 0000000..3661f72 --- /dev/null +++ b/myApp/Haru_Danim/Widgets/HaruDanimWidgets.swift @@ -0,0 +1,118 @@ +// +// HaruDanimWidgets.swift +// Haru_DanimWidgets +// +// Live Activity: 잠금화면 배너 + 다이나믹 아일랜드 (CLAUDE.md §4.1, §4.2) +// + +import WidgetKit +import SwiftUI +import ActivityKit + +@main +struct HaruDanimWidgetsBundle: WidgetBundle { + var body: some Widget { + TrackingLiveActivity() + } +} + +struct TrackingLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: TrackingActivityAttributes.self) { context in + LockScreenActivityView(state: context.state) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + HStack(spacing: 8) { + Image(systemName: context.state.symbolName) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 32, height: 32) + .background( + context.state.tintColor, + in: RoundedRectangle(cornerRadius: 9, style: .continuous) + ) + VStack(alignment: .leading, spacing: 1) { + Text(context.state.actionName) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + Text("측정 중") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + DynamicIslandExpandedRegion(.trailing) { + Text(timerInterval: context.state.timerRange, countsDown: false) + .font(.title3.weight(.bold).monospacedDigit()) + .multilineTextAlignment(.trailing) + .frame(maxWidth: 76) + } + DynamicIslandExpandedRegion(.bottom) { + if context.state.extraCount > 0 { + Text("다른 행동 \(context.state.extraCount)개도 함께 추적 중") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } compactLeading: { + Image(systemName: context.state.symbolName) + .foregroundStyle(context.state.tintColor) + } compactTrailing: { + HStack(spacing: 2) { + Text(timerInterval: context.state.timerRange, countsDown: false) + .font(.caption.weight(.semibold).monospacedDigit()) + .frame(maxWidth: 52) + .multilineTextAlignment(.trailing) + if context.state.extraCount > 0 { + Text("+\(context.state.extraCount)") + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + } + } + } minimal: { + Image(systemName: context.state.symbolName) + .foregroundStyle(context.state.tintColor) + } + } + } +} + +// MARK: - 잠금화면 배너 + +struct LockScreenActivityView: View { + let state: TrackingActivityAttributes.ContentState + + var body: some View { + HStack(spacing: 12) { + Image(systemName: state.symbolName) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 40, height: 40) + .background( + state.tintColor, + in: RoundedRectangle(cornerRadius: 11, style: .continuous) + ) + VStack(alignment: .leading, spacing: 2) { + Text(state.actionName) + .font(.headline) + .lineLimit(1) + if state.extraCount > 0 { + Text("+\(state.extraCount)개 함께 추적 중") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("측정 중") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Text(timerInterval: state.timerRange, countsDown: false) + .font(.title2.weight(.bold).monospacedDigit()) + .multilineTextAlignment(.trailing) + .frame(maxWidth: 90) + } + .padding(14) + } +} diff --git a/myApp/Haru_Danim/Widgets/Info.plist b/myApp/Haru_Danim/Widgets/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/myApp/Haru_Danim/Widgets/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/myApp/Haru_Danim/icon.heic b/myApp/Haru_Danim/icon.heic new file mode 100644 index 0000000..6efc12a Binary files /dev/null and b/myApp/Haru_Danim/icon.heic differ