Complete free features and prototype; testing to follow

This commit is contained in:
songyc macbook 2026-07-08 02:32:13 +09:00
parent c854c8f265
commit 3000ce6713
18 changed files with 636 additions and 57 deletions

View File

@ -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 = "<group>";
};
AA0000000000000000000001 /* Widgets */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
AA000000000000000000000F /* Exceptions for "Widgets" folder in "Haru_DanimWidgets" target */,
);
path = Widgets;
sourceTree = "<group>";
};
AA0000000000000000000002 /* Shared */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Shared;
sourceTree = "<group>";
};
/* 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 = "<group>";
@ -41,6 +102,7 @@
isa = PBXGroup;
children = (
725A9E842FFCC65000846FFB /* Haru_Danim.app */,
AA0000000000000000000004 /* Haru_DanimWidgets.appex */,
);
name = Products;
sourceTree = "<group>";
@ -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 */;

View File

@ -9,6 +9,11 @@
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>Haru_DanimWidgets.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

View File

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

View File

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

View File

@ -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<Action>(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<Action>())) ?? 0

View File

@ -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<TimeSession>(
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<TrackingActivityAttributes>.activities
if let activity = activities.first {
await activity.update(content)
//
for extra in activities.dropFirst() {
await extra.end(nil, dismissalPolicy: .immediate)
}
} else {
_ = try? Activity<TrackingActivityAttributes>.request(
attributes: TrackingActivityAttributes(),
content: content
)
}
}
}
static func endAll() {
Task {
for activity in Activity<TrackingActivityAttributes>.activities {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
}
}

View File

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

View File

@ -15,8 +15,16 @@ struct MainView: View {
@Query(filter: #Predicate<TimeSession> { $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)
}
}

View File

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

View File

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

View File

@ -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<Date> {
startedAt...startedAt.addingTimeInterval(60 * 60 * 24 * 30)
}
}

View File

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

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

BIN
myApp/Haru_Danim/icon.heic Normal file

Binary file not shown.