diff --git a/myApp/DeepWorkTimer b/myApp/DeepWorkTimer deleted file mode 160000 index 64afec6..0000000 --- a/myApp/DeepWorkTimer +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 64afec696ed11456cd193859a3d2cc07abfc0097 diff --git a/myApp/DeepWorkTimer/CLAUDE.md b/myApp/DeepWorkTimer/CLAUDE.md new file mode 100644 index 0000000..8e1b8e7 --- /dev/null +++ b/myApp/DeepWorkTimer/CLAUDE.md @@ -0,0 +1,9 @@ +# Project: DeepWork Timer (iOS) +- Frameworks: SwiftUI, SwiftData, Swift Charts, ActivityKit +- Architecture: MVVM (Model-View-ViewModel) + +# Guidelines +- Only use native Apple frameworks. Do not use external APIs, Cocoapods, or third-party libraries. +- Never write the entire project at once. Follow the user's specific file/milestone targets. +- Explain iOS-specific concepts (e.g., @State, @Environment, MainActor) briefly when introducing them. +- Always implement safe SwiftData migrations and error handling. diff --git a/myApp/DeepWorkTimer/DeepWorkTimer.xcodeproj/project.pbxproj b/myApp/DeepWorkTimer/DeepWorkTimer.xcodeproj/project.pbxproj new file mode 100644 index 0000000..37f6bdc --- /dev/null +++ b/myApp/DeepWorkTimer/DeepWorkTimer.xcodeproj/project.pbxproj @@ -0,0 +1,526 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 72106C082FDC71B400CDE600 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 72106C072FDC71B400CDE600 /* WidgetKit.framework */; }; + 72106C0A2FDC71B400CDE600 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 72106C092FDC71B400CDE600 /* SwiftUI.framework */; }; + 72106C172FDC71B600CDE600 /* TimeWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 72106C052FDC71B400CDE600 /* TimeWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 72106C152FDC71B600CDE600 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 72106BE22FDC6B3000CDE600 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 72106C042FDC71B400CDE600; + remoteInfo = TimeWidgetExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 72106C1C2FDC71B600CDE600 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 72106C172FDC71B600CDE600 /* TimeWidgetExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 72106BEA2FDC6B3000CDE600 /* DeepWorkTimer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DeepWorkTimer.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 72106C052FDC71B400CDE600 /* TimeWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimeWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 72106C072FDC71B400CDE600 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 72106C092FDC71B400CDE600 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 72106C182FDC71B600CDE600 /* Exceptions for "TimeWidget" folder in "TimeWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 72106C042FDC71B400CDE600 /* TimeWidgetExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 72106BEC2FDC6B3000CDE600 /* DeepWorkTimer */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = DeepWorkTimer; + sourceTree = ""; + }; + 72106C0B2FDC71B400CDE600 /* TimeWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 72106C182FDC71B600CDE600 /* Exceptions for "TimeWidget" folder in "TimeWidgetExtension" target */, + ); + path = TimeWidget; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 72106BE72FDC6B3000CDE600 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 72106C022FDC71B400CDE600 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 72106C0A2FDC71B400CDE600 /* SwiftUI.framework in Frameworks */, + 72106C082FDC71B400CDE600 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 72106BE12FDC6B3000CDE600 = { + isa = PBXGroup; + children = ( + 72106BEC2FDC6B3000CDE600 /* DeepWorkTimer */, + 72106C0B2FDC71B400CDE600 /* TimeWidget */, + 72106C062FDC71B400CDE600 /* Frameworks */, + 72106BEB2FDC6B3000CDE600 /* Products */, + ); + sourceTree = ""; + }; + 72106BEB2FDC6B3000CDE600 /* Products */ = { + isa = PBXGroup; + children = ( + 72106BEA2FDC6B3000CDE600 /* DeepWorkTimer.app */, + 72106C052FDC71B400CDE600 /* TimeWidgetExtension.appex */, + ); + name = Products; + sourceTree = ""; + }; + 72106C062FDC71B400CDE600 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 72106C072FDC71B400CDE600 /* WidgetKit.framework */, + 72106C092FDC71B400CDE600 /* SwiftUI.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 72106BE92FDC6B3000CDE600 /* DeepWorkTimer */ = { + isa = PBXNativeTarget; + buildConfigurationList = 72106BF52FDC6B3100CDE600 /* Build configuration list for PBXNativeTarget "DeepWorkTimer" */; + buildPhases = ( + 72106BE62FDC6B3000CDE600 /* Sources */, + 72106BE72FDC6B3000CDE600 /* Frameworks */, + 72106BE82FDC6B3000CDE600 /* Resources */, + 72106C1C2FDC71B600CDE600 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 72106C162FDC71B600CDE600 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 72106BEC2FDC6B3000CDE600 /* DeepWorkTimer */, + ); + name = DeepWorkTimer; + packageProductDependencies = ( + ); + productName = DeepWorkTimer; + productReference = 72106BEA2FDC6B3000CDE600 /* DeepWorkTimer.app */; + productType = "com.apple.product-type.application"; + }; + 72106C042FDC71B400CDE600 /* TimeWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 72106C192FDC71B600CDE600 /* Build configuration list for PBXNativeTarget "TimeWidgetExtension" */; + buildPhases = ( + 72106C012FDC71B400CDE600 /* Sources */, + 72106C022FDC71B400CDE600 /* Frameworks */, + 72106C032FDC71B400CDE600 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 72106C0B2FDC71B400CDE600 /* TimeWidget */, + ); + name = TimeWidgetExtension; + packageProductDependencies = ( + ); + productName = TimeWidgetExtension; + productReference = 72106C052FDC71B400CDE600 /* TimeWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 72106BE22FDC6B3000CDE600 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2650; + LastUpgradeCheck = 2650; + TargetAttributes = { + 72106BE92FDC6B3000CDE600 = { + CreatedOnToolsVersion = 26.5; + }; + 72106C042FDC71B400CDE600 = { + CreatedOnToolsVersion = 26.5; + }; + }; + }; + buildConfigurationList = 72106BE52FDC6B3000CDE600 /* Build configuration list for PBXProject "DeepWorkTimer" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 72106BE12FDC6B3000CDE600; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 72106BEB2FDC6B3000CDE600 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 72106BE92FDC6B3000CDE600 /* DeepWorkTimer */, + 72106C042FDC71B400CDE600 /* TimeWidgetExtension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 72106BE82FDC6B3000CDE600 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 72106C032FDC71B400CDE600 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 72106BE62FDC6B3000CDE600 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 72106C012FDC71B400CDE600 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 72106C162FDC71B600CDE600 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 72106C042FDC71B400CDE600 /* TimeWidgetExtension */; + targetProxy = 72106C152FDC71B600CDE600 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 72106BF32FDC6B3100CDE600 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 72106BF42FDC6B3100CDE600 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 72106BF62FDC6B3100CDE600 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "DeepWorkTimer/DeepWorkTimer.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + 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; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yechan.DeepWorkTimer; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 72106BF72FDC6B3100CDE600 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "DeepWorkTimer/DeepWorkTimer.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + 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; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yechan.DeepWorkTimer; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 72106C1A2FDC71B600CDE600 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TimeWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = TimeWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yechan.DeepWorkTimer.TimeWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 72106C1B2FDC71B600CDE600 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TimeWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = TimeWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yechan.DeepWorkTimer.TimeWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 72106BE52FDC6B3000CDE600 /* Build configuration list for PBXProject "DeepWorkTimer" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 72106BF32FDC6B3100CDE600 /* Debug */, + 72106BF42FDC6B3100CDE600 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 72106BF52FDC6B3100CDE600 /* Build configuration list for PBXNativeTarget "DeepWorkTimer" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 72106BF62FDC6B3100CDE600 /* Debug */, + 72106BF72FDC6B3100CDE600 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 72106C192FDC71B600CDE600 /* Build configuration list for PBXNativeTarget "TimeWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 72106C1A2FDC71B600CDE600 /* Debug */, + 72106C1B2FDC71B600CDE600 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 72106BE22FDC6B3000CDE600 /* Project object */; +} diff --git a/myApp/DeepWorkTimer/DeepWorkTimer.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/myApp/DeepWorkTimer/DeepWorkTimer.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/myApp/DeepWorkTimer/DeepWorkTimer.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/myApp/DeepWorkTimer/DeepWorkTimer.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate b/myApp/DeepWorkTimer/DeepWorkTimer.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..8553b76 Binary files /dev/null and b/myApp/DeepWorkTimer/DeepWorkTimer.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/myApp/DeepWorkTimer/DeepWorkTimer.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist b/myApp/DeepWorkTimer/DeepWorkTimer.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..9055e13 --- /dev/null +++ b/myApp/DeepWorkTimer/DeepWorkTimer.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,19 @@ + + + + + SchemeUserState + + DeepWorkTimer.xcscheme_^#shared#^_ + + orderHint + 1 + + TimeWidgetExtension.xcscheme_^#shared#^_ + + orderHint + 2 + + + + diff --git a/myApp/DeepWorkTimer/DeepWorkTimer/Assets.xcassets/AccentColor.colorset/Contents.json b/myApp/DeepWorkTimer/DeepWorkTimer/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/myApp/DeepWorkTimer/DeepWorkTimer/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/DeepWorkTimer/DeepWorkTimer/Assets.xcassets/AppIcon.appiconset/Contents.json b/myApp/DeepWorkTimer/DeepWorkTimer/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/myApp/DeepWorkTimer/DeepWorkTimer/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/DeepWorkTimer/DeepWorkTimer/Assets.xcassets/Contents.json b/myApp/DeepWorkTimer/DeepWorkTimer/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/myApp/DeepWorkTimer/DeepWorkTimer/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/DeepWorkTimer/DeepWorkTimer/ContentView.swift b/myApp/DeepWorkTimer/DeepWorkTimer/ContentView.swift new file mode 100644 index 0000000..25ccb2e --- /dev/null +++ b/myApp/DeepWorkTimer/DeepWorkTimer/ContentView.swift @@ -0,0 +1,163 @@ +// +// ContentView.swift +// DeepWorkTimer +// +// Created by 송예찬 on 6/13/26. +// + +import SwiftUI +import SwiftData + +struct ContentView: View { + // @State owns the ViewModel; @Observable tracks only the properties this view reads. + @State private var viewModel = TimerViewModel() + @Query(sort: \FocusSession.startTime, order: .reverse) private var sessions: [FocusSession] + @Environment(\.modelContext) private var modelContext + // scenePhase tells us when the app moves to/from the background. + @Environment(\.scenePhase) private var scenePhase + + var body: some View { + TabView { + timerTab + .tabItem { + Label("Timer", systemImage: "timer") + } + + DashboardView() + .tabItem { + Label("Dashboard", systemImage: "chart.bar.fill") + } + } + .onChange(of: scenePhase) { _, newPhase in + switch newPhase { + case .background: + viewModel.appWentBackground() + case .active: + viewModel.appReturnedForeground() + default: + break + } + } + } + + private var timerTab: some View { + NavigationStack { + ScrollView { + VStack(spacing: 40) { + timerRing + controlButtons + if !sessions.isEmpty { + sessionList + } + } + .padding(.vertical, 24) + } + .navigationTitle("Deep Work Timer") + } + } + + // MARK: - Timer Ring + + private var timerRing: some View { + ZStack { + Circle() + .stroke(Color(.systemGray5), lineWidth: 18) + Circle() + .trim(from: 0, to: viewModel.progress) + .stroke( + Color.accentColor, + style: StrokeStyle(lineWidth: 18, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(.linear(duration: 1), value: viewModel.progress) + VStack(spacing: 6) { + Text(formattedTime) + .font(.system(size: 52, weight: .thin, design: .monospaced)) + Text(statusLabel) + .font(.caption) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(2) + } + } + .frame(width: 270, height: 270) + .padding() + } + + private var formattedTime: String { + String(format: "%02d:%02d", viewModel.timeRemaining / 60, viewModel.timeRemaining % 60) + } + + private var statusLabel: String { + if viewModel.isRunning { return "Focus" } + if viewModel.sessionStartTime != nil { return "Paused" } + return "Ready" + } + + // MARK: - Control Buttons + + private var controlButtons: some View { + HStack(spacing: 20) { + Button { + viewModel.isRunning ? viewModel.pause() : viewModel.start() + } label: { + Label( + viewModel.isRunning ? "Pause" : "Start", + systemImage: viewModel.isRunning ? "pause.fill" : "play.fill" + ) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + + Button(role: .destructive) { + viewModel.stop(context: modelContext) + } label: { + Label("Stop", systemImage: "stop.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.large) + .disabled(viewModel.sessionStartTime == nil) + } + .padding(.horizontal) + } + + // MARK: - Session List + + private var sessionList: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Completed Sessions") + .font(.headline) + .padding(.horizontal) + .padding(.bottom, 12) + + ForEach(sessions) { session in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(session.category) + .font(.subheadline) + .fontWeight(.medium) + Text(session.startTime.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text("\(session.durationInMinutes) min") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding() + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal) + .padding(.vertical, 4) + } + } + } +} + +#Preview { + ContentView() + .modelContainer(for: FocusSession.self, inMemory: true) +} diff --git a/myApp/DeepWorkTimer/DeepWorkTimer/DeepWorkTimer.entitlements b/myApp/DeepWorkTimer/DeepWorkTimer/DeepWorkTimer.entitlements new file mode 100644 index 0000000..0ba9c39 --- /dev/null +++ b/myApp/DeepWorkTimer/DeepWorkTimer/DeepWorkTimer.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.live-activity + + + diff --git a/myApp/DeepWorkTimer/DeepWorkTimer/DeepWorkTimerApp.swift b/myApp/DeepWorkTimer/DeepWorkTimer/DeepWorkTimerApp.swift new file mode 100644 index 0000000..266cdf6 --- /dev/null +++ b/myApp/DeepWorkTimer/DeepWorkTimer/DeepWorkTimerApp.swift @@ -0,0 +1,21 @@ +// +// DeepWorkTimerApp.swift +// DeepWorkTimer +// +// Created by 송예찬 on 6/13/26. +// + +import SwiftUI +import SwiftData + +@main +struct DeepWorkTimerApp: App { + // modelContainer registers FocusSession with SwiftData and injects + // the store into the SwiftUI environment for all child views. + var body: some Scene { + WindowGroup { + ContentView() + } + .modelContainer(for: FocusSession.self) + } +} diff --git a/myApp/DeepWorkTimer/DeepWorkTimer/Models/FocusSession.swift b/myApp/DeepWorkTimer/DeepWorkTimer/Models/FocusSession.swift new file mode 100644 index 0000000..efc80c3 --- /dev/null +++ b/myApp/DeepWorkTimer/DeepWorkTimer/Models/FocusSession.swift @@ -0,0 +1,25 @@ +// +// FocusSession.swift +// DeepWorkTimer +// +// Created by 송예찬 on 6/13/26. +// + +import Foundation +import SwiftData + +// @Model turns this plain Swift class into a SwiftData-managed, persistable entity. +@Model +final class FocusSession { + var id: UUID + var startTime: Date + var durationInMinutes: Int + var category: String + + init(id: UUID = UUID(), startTime: Date = .now, durationInMinutes: Int, category: String) { + self.id = id + self.startTime = startTime + self.durationInMinutes = durationInMinutes + self.category = category + } +} diff --git a/myApp/DeepWorkTimer/DeepWorkTimer/Models/TimerAttributes.swift b/myApp/DeepWorkTimer/DeepWorkTimer/Models/TimerAttributes.swift new file mode 100644 index 0000000..dad149f --- /dev/null +++ b/myApp/DeepWorkTimer/DeepWorkTimer/Models/TimerAttributes.swift @@ -0,0 +1,19 @@ +// Shared ActivityAttributes definition. +// NOTE: This file must belong to BOTH the DeepWorkTimer and TimeWidgetExtension targets. +// Because this project uses Xcode 16 file system synchronized groups, a physical copy +// lives in DeepWorkTimer/Models/ (for the app) and in TimeWidget/ (for the extension). +// Keep both files identical. + +import ActivityKit +import Foundation + +struct TimerAttributes: ActivityAttributes { + // ContentState holds data the system can update while the activity is live. + struct ContentState: Codable, Hashable { + // The exact moment the timer expires — drives Text(timerInterval:) real-time countdown. + var endDate: Date + } + + // Fixed for the lifetime of this activity. + var sessionName: String +} diff --git a/myApp/DeepWorkTimer/DeepWorkTimer/ViewModels/TimerViewModel.swift b/myApp/DeepWorkTimer/DeepWorkTimer/ViewModels/TimerViewModel.swift new file mode 100644 index 0000000..15c8d28 --- /dev/null +++ b/myApp/DeepWorkTimer/DeepWorkTimer/ViewModels/TimerViewModel.swift @@ -0,0 +1,133 @@ +// +// TimerViewModel.swift +// DeepWorkTimer +// +// Created by 송예찬 on 6/13/26. +// + +import Foundation +import Observation +import SwiftData +import ActivityKit + +// @Observable replaces ObservableObject + @Published. +// SwiftUI views automatically track only the specific properties they read. +@Observable +final class TimerViewModel { + private(set) var timeRemaining: Int + private(set) var isRunning = false + private(set) var sessionStartTime: Date? + + let totalDuration: Int + + private var timerTask: Task? + // Stores the moment the app moved to the background so elapsed time + // can be calculated accurately when the app returns. + private var backgroundEntryDate: Date? + private var currentActivity: Activity? + + var progress: Double { + totalDuration > 0 ? Double(timeRemaining) / Double(totalDuration) : 1.0 + } + + init(duration: Int = 25 * 60) { + totalDuration = duration + timeRemaining = duration + } + + func start() { + guard !isRunning else { return } + if sessionStartTime == nil { + sessionStartTime = .now + } + isRunning = true + scheduleTick() + startLiveActivity() + } + + func pause() { + isRunning = false + cancelTick() + endLiveActivity() + } + + func stop(context: ModelContext) { + let start = sessionStartTime ?? .now + let elapsed = totalDuration - timeRemaining + let minutes = max(1, elapsed / 60) + context.insert(FocusSession(startTime: start, durationInMinutes: minutes, category: "Deep Work")) + endLiveActivity() + reset() + } + + // Call when scenePhase → .background + func appWentBackground() { + guard isRunning else { return } + backgroundEntryDate = .now + cancelTick() + // isRunning stays true so appReturnedForeground knows to resume. + } + + // Call when scenePhase → .active + func appReturnedForeground() { + guard isRunning, let entry = backgroundEntryDate else { return } + let elapsed = Int(Date.now.timeIntervalSince(entry)) + timeRemaining = max(0, timeRemaining - elapsed) + backgroundEntryDate = nil + if timeRemaining > 0 { + scheduleTick() + } else { + isRunning = false + endLiveActivity() + } + } + + private func startLiveActivity() { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + let endDate = Date.now.addingTimeInterval(TimeInterval(timeRemaining)) + let state = TimerAttributes.ContentState(endDate: endDate) + let content = ActivityContent(state: state, staleDate: endDate) + let attributes = TimerAttributes(sessionName: "Deep Work") + do { + currentActivity = try Activity.request(attributes: attributes, content: content) + } catch { + // Live Activities may fail if the device doesn't support them or the limit is reached. + } + } + + private func endLiveActivity() { + guard let activity = currentActivity else { return } + currentActivity = nil + Task { + let finalState = TimerAttributes.ContentState(endDate: .now) + let content = ActivityContent(state: finalState, staleDate: nil) + await activity.end(content, dismissalPolicy: .immediate) + } + } + + private func scheduleTick() { + timerTask = Task { + while !Task.isCancelled && timeRemaining > 0 { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { break } + timeRemaining = max(0, timeRemaining - 1) + } + if timeRemaining == 0 { + isRunning = false + endLiveActivity() + } + } + } + + private func cancelTick() { + timerTask?.cancel() + timerTask = nil + } + + private func reset() { + cancelTick() + isRunning = false + sessionStartTime = nil + timeRemaining = totalDuration + } +} diff --git a/myApp/DeepWorkTimer/DeepWorkTimer/Views/DashboardView.swift b/myApp/DeepWorkTimer/DeepWorkTimer/Views/DashboardView.swift new file mode 100644 index 0000000..5261534 --- /dev/null +++ b/myApp/DeepWorkTimer/DeepWorkTimer/Views/DashboardView.swift @@ -0,0 +1,169 @@ +// +// DashboardView.swift +// DeepWorkTimer +// +// Created by 송예찬 on 6/13/26. +// + +import SwiftUI +import SwiftData +import Charts + +// Lightweight struct to hold one day's aggregated focus data for the chart. +struct DayFocusData: Identifiable { + var id: Date { date } + let date: Date + let minutes: Int +} + +struct DashboardView: View { + // @Query fetches every persisted FocusSession from SwiftData automatically. + @Query(sort: \FocusSession.startTime, order: .forward) private var sessions: [FocusSession] + + // MARK: - Computed stats + + private var last7Days: [DayFocusData] { + let calendar = Calendar.current + let today = calendar.startOfDay(for: .now) + + var totals: [Date: Int] = [:] + for session in sessions { + let day = calendar.startOfDay(for: session.startTime) + totals[day, default: 0] += session.durationInMinutes + } + + return (0..<7).reversed().compactMap { offset -> DayFocusData? in + guard let day = calendar.date(byAdding: .day, value: -offset, to: today) else { return nil } + return DayFocusData(date: day, minutes: totals[day, default: 0]) + } + } + + private var totalFocusMinutes: Int { + sessions.reduce(0) { $0 + $1.durationInMinutes } + } + + private var averageDailyMinutes: Int { + let activeDays = last7Days.filter { $0.minutes > 0 } + guard !activeDays.isEmpty else { return 0 } + return activeDays.reduce(0) { $0 + $1.minutes } / activeDays.count + } + + // MARK: - Body + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + statsCards + chartSection + } + .padding(20) + } + .navigationTitle("Dashboard") + .background(Color(.systemGroupedBackground)) + } + } + + // MARK: - Stats Cards + + private var statsCards: some View { + HStack(spacing: 12) { + statCard( + title: "Total Focus Time", + value: formatted(totalFocusMinutes), + systemImage: "clock.fill", + tint: .blue + ) + statCard( + title: "Avg Daily Focus", + value: formatted(averageDailyMinutes), + systemImage: "chart.line.uptrend.xyaxis", + tint: .purple + ) + } + } + + private func statCard(title: String, value: String, systemImage: String, tint: Color) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: systemImage) + .foregroundStyle(tint) + Spacer() + } + Text(value) + .font(.system(size: 32, weight: .bold, design: .rounded)) + .foregroundStyle(.primary) + .minimumScaleFactor(0.7) + .lineLimit(1) + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + // MARK: - Chart Section + + private var chartSection: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Last 7 Days") + .font(.headline) + .padding(.horizontal, 4) + + Chart(last7Days) { day in + BarMark( + x: .value("Day", day.date, unit: .day), + y: .value("Minutes", day.minutes) + ) + .foregroundStyle( + LinearGradient( + colors: [Color.blue.opacity(0.5), Color.purple], + startPoint: .bottom, + endPoint: .top + ) + ) + .cornerRadius(8) + } + .chartXAxis { + AxisMarks(values: .stride(by: .day)) { _ in + AxisValueLabel(format: .dateTime.weekday(.abbreviated)) + .font(.caption2) + } + } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [4])) + .foregroundStyle(Color(.systemGray4)) + AxisValueLabel { + if let minutes = value.as(Int.self) { + Text("\(minutes)m") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + .frame(height: 220) + .padding(16) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + } + + // MARK: - Helpers + + private func formatted(_ totalMinutes: Int) -> String { + let hours = totalMinutes / 60 + let mins = totalMinutes % 60 + if hours > 0 { return "\(hours)h \(mins)m" } + return "\(mins)m" + } +} + +#Preview { + DashboardView() + .modelContainer(for: FocusSession.self, inMemory: true) +} diff --git a/myApp/DeepWorkTimer/TimeWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/myApp/DeepWorkTimer/TimeWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/myApp/DeepWorkTimer/TimeWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/DeepWorkTimer/TimeWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/myApp/DeepWorkTimer/TimeWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/myApp/DeepWorkTimer/TimeWidget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/DeepWorkTimer/TimeWidget/Assets.xcassets/Contents.json b/myApp/DeepWorkTimer/TimeWidget/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/myApp/DeepWorkTimer/TimeWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/DeepWorkTimer/TimeWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/myApp/DeepWorkTimer/TimeWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/myApp/DeepWorkTimer/TimeWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/DeepWorkTimer/TimeWidget/Info.plist b/myApp/DeepWorkTimer/TimeWidget/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/myApp/DeepWorkTimer/TimeWidget/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/myApp/DeepWorkTimer/TimeWidget/TimeWidget.swift b/myApp/DeepWorkTimer/TimeWidget/TimeWidget.swift new file mode 100644 index 0000000..2b2ced8 --- /dev/null +++ b/myApp/DeepWorkTimer/TimeWidget/TimeWidget.swift @@ -0,0 +1,84 @@ +// +// TimeWidget.swift +// TimeWidget +// +// Created by 송예찬 on 6/13/26. +// + +import WidgetKit +import SwiftUI + +struct Provider: TimelineProvider { + func placeholder(in context: Context) -> SimpleEntry { + SimpleEntry(date: Date(), emoji: "😀") + } + + func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { + let entry = SimpleEntry(date: Date(), emoji: "😀") + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + var entries: [SimpleEntry] = [] + + // Generate a timeline consisting of five entries an hour apart, starting from the current date. + let currentDate = Date() + for hourOffset in 0 ..< 5 { + let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! + let entry = SimpleEntry(date: entryDate, emoji: "😀") + entries.append(entry) + } + + let timeline = Timeline(entries: entries, policy: .atEnd) + completion(timeline) + } + +// func relevances() async -> WidgetRelevances { +// // Generate a list containing the contexts this widget is relevant in. +// } +} + +struct SimpleEntry: TimelineEntry { + let date: Date + let emoji: String +} + +struct TimeWidgetEntryView : View { + var entry: Provider.Entry + + var body: some View { + VStack { + Text("Time:") + Text(entry.date, style: .time) + + Text("Emoji:") + Text(entry.emoji) + } + } +} + +struct TimeWidget: Widget { + let kind: String = "TimeWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: Provider()) { entry in + if #available(iOS 17.0, *) { + TimeWidgetEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } else { + TimeWidgetEntryView(entry: entry) + .padding() + .background() + } + } + .configurationDisplayName("My Widget") + .description("This is an example widget.") + } +} + +#Preview(as: .systemSmall) { + TimeWidget() +} timeline: { + SimpleEntry(date: .now, emoji: "😀") + SimpleEntry(date: .now, emoji: "🤩") +} diff --git a/myApp/DeepWorkTimer/TimeWidget/TimeWidgetBundle.swift b/myApp/DeepWorkTimer/TimeWidget/TimeWidgetBundle.swift new file mode 100644 index 0000000..3303ea7 --- /dev/null +++ b/myApp/DeepWorkTimer/TimeWidget/TimeWidgetBundle.swift @@ -0,0 +1,17 @@ +// +// TimeWidgetBundle.swift +// TimeWidget +// +// Created by 송예찬 on 6/13/26. +// + +import WidgetKit +import SwiftUI + +@main +struct TimeWidgetBundle: WidgetBundle { + var body: some Widget { + TimeWidget() + TimeWidgetLiveActivity() + } +} diff --git a/myApp/DeepWorkTimer/TimeWidget/TimeWidgetLiveActivity.swift b/myApp/DeepWorkTimer/TimeWidget/TimeWidgetLiveActivity.swift new file mode 100644 index 0000000..12e0f61 --- /dev/null +++ b/myApp/DeepWorkTimer/TimeWidget/TimeWidgetLiveActivity.swift @@ -0,0 +1,104 @@ +// +// TimeWidgetLiveActivity.swift +// TimeWidget +// +// Created by 송예찬 on 6/13/26. +// + +import ActivityKit +import WidgetKit +import SwiftUI + +struct TimeWidgetLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: TimerAttributes.self) { context in + // Lock Screen / Notification Banner + // Text(timerInterval:) is a system primitive that counts down in real time + // without waking the app — no Timer or polling needed. + HStack(spacing: 14) { + Image(systemName: "timer") + .font(.title2.weight(.semibold)) + .foregroundStyle(.white) + + VStack(alignment: .leading, spacing: 2) { + Text(context.attributes.sessionName) + .font(.caption) + .foregroundStyle(.white.opacity(0.75)) + + let safeEnd = max(Date.now, context.state.endDate) + Text(timerInterval: Date.now...safeEnd, countsDown: true) + .font(.title.monospacedDigit().bold()) + .foregroundStyle(.white) + } + + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + .activityBackgroundTint(Color(red: 0.07, green: 0.07, blue: 0.18)) + .activitySystemActionForegroundColor(.white) + + } dynamicIsland: { context in + DynamicIsland { + // Expanded — shown when the user long-presses the Dynamic Island. + DynamicIslandExpandedRegion(.leading) { + Label(context.attributes.sessionName, systemImage: "timer") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + DynamicIslandExpandedRegion(.trailing) { + EmptyView() + } + DynamicIslandExpandedRegion(.center) { + let safeEnd = max(Date.now, context.state.endDate) + Text(timerInterval: Date.now...safeEnd, countsDown: true) + .font(.title2.monospacedDigit().bold()) + .foregroundStyle(.primary) + } + DynamicIslandExpandedRegion(.bottom) { + Text("Stay focused") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } compactLeading: { + Image(systemName: "timer") + .foregroundStyle(.indigo) + } compactTrailing: { + let safeEnd = max(Date.now, context.state.endDate) + Text(timerInterval: Date.now...safeEnd, countsDown: true) + .font(.caption.monospacedDigit().bold()) + .foregroundStyle(.primary) + .frame(width: 52) + } minimal: { + Image(systemName: "timer") + .foregroundStyle(.indigo) + } + .keylineTint(.indigo) + } + } +} + +// MARK: - Previews + +extension TimerAttributes { + fileprivate static var preview: TimerAttributes { + TimerAttributes(sessionName: "Deep Work") + } +} + +extension TimerAttributes.ContentState { + fileprivate static var active: TimerAttributes.ContentState { + TimerAttributes.ContentState(endDate: Date.now.addingTimeInterval(22 * 60)) + } + fileprivate static var nearEnd: TimerAttributes.ContentState { + TimerAttributes.ContentState(endDate: Date.now.addingTimeInterval(2 * 60)) + } +} + +#Preview("Notification", as: .content, using: TimerAttributes.preview) { + TimeWidgetLiveActivity() +} contentStates: { + TimerAttributes.ContentState.active + TimerAttributes.ContentState.nearEnd +} diff --git a/myApp/DeepWorkTimer/TimeWidget/TimerAttributes.swift b/myApp/DeepWorkTimer/TimeWidget/TimerAttributes.swift new file mode 100644 index 0000000..dad149f --- /dev/null +++ b/myApp/DeepWorkTimer/TimeWidget/TimerAttributes.swift @@ -0,0 +1,19 @@ +// Shared ActivityAttributes definition. +// NOTE: This file must belong to BOTH the DeepWorkTimer and TimeWidgetExtension targets. +// Because this project uses Xcode 16 file system synchronized groups, a physical copy +// lives in DeepWorkTimer/Models/ (for the app) and in TimeWidget/ (for the extension). +// Keep both files identical. + +import ActivityKit +import Foundation + +struct TimerAttributes: ActivityAttributes { + // ContentState holds data the system can update while the activity is live. + struct ContentState: Codable, Hashable { + // The exact moment the timer expires — drives Text(timerInterval:) real-time countdown. + var endDate: Date + } + + // Fixed for the lifetime of this activity. + var sessionName: String +} diff --git a/myApp/LemonLimeTracker b/myApp/LemonLimeTracker deleted file mode 160000 index c66ea52..0000000 --- a/myApp/LemonLimeTracker +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c66ea52abf01a4199a6c0037c7211d13983f2c80 diff --git a/myApp/LemonLimeTracker/CLAUDE.md b/myApp/LemonLimeTracker/CLAUDE.md new file mode 100644 index 0000000..00a18fe --- /dev/null +++ b/myApp/LemonLimeTracker/CLAUDE.md @@ -0,0 +1,21 @@ +# LemonLimeTracker Project Guidelines + +## Core Rules & Constraints +- **NO Paid Entitlements**: NEVER add CloudKit, iCloud sync, App Groups, or Remote Notifications to Xcode Signing & Capabilities. Avoid build errors. +- **Persistence**: Use strictly local SwiftData. Do NOT use `NSPersistentCloudKitContainer`. + +## Architecture & Future Scope +- **Pattern**: Clean Architecture + MVVM. +- **Repository Pattern**: Strict separation of Data processing (SwiftData) and UI Views to allow easy future integration of CloudKit, Apple Watch, and Widgets. +- **Folder Structure**: + - `IOS/Models`: SwiftData Models (Task, Category, Goal, Log). + - `IOS/Repositories`: Data access layer (Protocols + Implementations). + - `IOS/ViewModels`: Business logic (`@Observable`). + - `IOS/Views`: SwiftUI Views. + - `IOS/Core`: App entry point, Constants, ActivityKit. + - `IOS/Resources`: Assets, Info.plist, Localizable.strings. + +## UI / Design System +- **Light Mode**: Green, Yellow, White. +- **Dark Mode**: Green, Yellow, Black. +- **Localization**: Default is Korean. Support English. diff --git a/myApp/LemonLimeTracker/IOS/Assets.xcassets/AccentColor.colorset/Contents.json b/myApp/LemonLimeTracker/IOS/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/LemonLimeTracker/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/myApp/LemonLimeTracker/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/LemonLimeTracker/IOS/Assets.xcassets/Contents.json b/myApp/LemonLimeTracker/IOS/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/LemonLimeTracker/IOS/ContentView.swift b/myApp/LemonLimeTracker/IOS/ContentView.swift new file mode 100644 index 0000000..e245bef --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/ContentView.swift @@ -0,0 +1,24 @@ +// +// ContentView.swift +// LemonLimeTracker +// +// Created by 송예찬 on 6/18/26. +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/myApp/LemonLimeTracker/IOS/Core/Theme.swift b/myApp/LemonLimeTracker/IOS/Core/Theme.swift new file mode 100644 index 0000000..a62070a --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Core/Theme.swift @@ -0,0 +1,21 @@ +import SwiftUI +import UIKit + +enum Theme { + static let green = Color(red: 0.18, green: 0.72, blue: 0.32) + static let yellow = Color(red: 0.96, green: 0.86, blue: 0.12) + + static let background = Color(UIColor { traits in + traits.userInterfaceStyle == .dark ? .black : .white + }) + + static let surface = Color(UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(red: 0.10, green: 0.10, blue: 0.10, alpha: 1) + : UIColor(red: 0.97, green: 0.97, blue: 0.97, alpha: 1) + }) + + static let primaryText = Color(UIColor { traits in + traits.userInterfaceStyle == .dark ? .white : .black + }) +} diff --git a/myApp/LemonLimeTracker/IOS/Core/TimerActivityAttributes.swift b/myApp/LemonLimeTracker/IOS/Core/TimerActivityAttributes.swift new file mode 100644 index 0000000..4b3f522 --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Core/TimerActivityAttributes.swift @@ -0,0 +1,14 @@ +#if os(iOS) +import ActivityKit +import Foundation + +struct TimerActivityAttributes: ActivityAttributes { + struct ContentState: Codable, Hashable { + var startDate: Date + var elapsedSeconds: Int + } + + var taskName: String + var taskIcon: String +} +#endif diff --git a/myApp/LemonLimeTracker/IOS/LemonLimeTrackerApp.swift b/myApp/LemonLimeTracker/IOS/LemonLimeTrackerApp.swift new file mode 100644 index 0000000..434cfec --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/LemonLimeTrackerApp.swift @@ -0,0 +1,19 @@ +// +// LemonLimeTrackerApp.swift +// LemonLimeTracker +// +// Created by 송예찬 on 6/18/26. +// + +import SwiftUI +import SwiftData + +@main +struct LemonLimeTrackerApp: App { + var body: some Scene { + WindowGroup { + RootView() + } + .modelContainer(SwiftDataService.shared.container) + } +} diff --git a/myApp/LemonLimeTracker/IOS/Models/Category.swift b/myApp/LemonLimeTracker/IOS/Models/Category.swift new file mode 100644 index 0000000..79339cb --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Models/Category.swift @@ -0,0 +1,18 @@ +import Foundation +import SwiftData + +@Model +final class Category { + @Attribute(.unique) var id: UUID + var name: String + var colorHex: String + + @Relationship(deleteRule: .cascade, inverse: \TaskItem.category) + var tasks: [TaskItem] = [] + + init(id: UUID = UUID(), name: String, colorHex: String) { + self.id = id + self.name = name + self.colorHex = colorHex + } +} diff --git a/myApp/LemonLimeTracker/IOS/Models/Goal.swift b/myApp/LemonLimeTracker/IOS/Models/Goal.swift new file mode 100644 index 0000000..959e939 --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Models/Goal.swift @@ -0,0 +1,35 @@ +import Foundation +import SwiftData + +enum GoalFrequency: String, Codable { + case daily + case weekly + case monthly +} + +@Model +final class Goal { + @Attribute(.unique) var id: UUID + var targetType: String // "count" or "duration" + var conditions: Double // threshold value to meet the goal + var frequency: GoalFrequency + + var task: TaskItem? + var category: Category? + + init( + id: UUID = UUID(), + targetType: String, + conditions: Double, + frequency: GoalFrequency, + task: TaskItem? = nil, + category: Category? = nil + ) { + self.id = id + self.targetType = targetType + self.conditions = conditions + self.frequency = frequency + self.task = task + self.category = category + } +} diff --git a/myApp/LemonLimeTracker/IOS/Models/TaskItem.swift b/myApp/LemonLimeTracker/IOS/Models/TaskItem.swift new file mode 100644 index 0000000..bbe039e --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Models/TaskItem.swift @@ -0,0 +1,28 @@ +import Foundation +import SwiftData + +enum TaskType: String, Codable { + case count + case time +} + +@Model +final class TaskItem { + @Attribute(.unique) var id: UUID + var name: String + var icon: String + var type: TaskType + + var category: Category? + + @Relationship(deleteRule: .cascade, inverse: \TaskLog.task) + var logs: [TaskLog] = [] + + init(id: UUID = UUID(), name: String, icon: String, type: TaskType, category: Category? = nil) { + self.id = id + self.name = name + self.icon = icon + self.type = type + self.category = category + } +} diff --git a/myApp/LemonLimeTracker/IOS/Models/TaskLog.swift b/myApp/LemonLimeTracker/IOS/Models/TaskLog.swift new file mode 100644 index 0000000..ccd5273 --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Models/TaskLog.swift @@ -0,0 +1,19 @@ +import Foundation +import SwiftData + +@Model +final class TaskLog { + @Attribute(.unique) var id: UUID + var date: Date + var duration: TimeInterval + var count: Int + + var task: TaskItem? + + init(id: UUID = UUID(), date: Date = .now, duration: TimeInterval = 0, count: Int = 0) { + self.id = id + self.date = date + self.duration = duration + self.count = count + } +} diff --git a/myApp/LemonLimeTracker/IOS/Repositories/SwiftDataService.swift b/myApp/LemonLimeTracker/IOS/Repositories/SwiftDataService.swift new file mode 100644 index 0000000..b056f1e --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Repositories/SwiftDataService.swift @@ -0,0 +1,24 @@ +import Foundation +import SwiftData + +@MainActor +final class SwiftDataService { + static let shared = SwiftDataService() + + let container: ModelContainer + + private init() { + let schema = Schema([ + Category.self, + TaskItem.self, + TaskLog.self, + Goal.self + ]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + do { + container = try ModelContainer(for: schema, configurations: config) + } catch { + fatalError("SwiftData container failed to initialize: \(error)") + } + } +} diff --git a/myApp/LemonLimeTracker/IOS/Resources/en.lproj/Localizable.strings b/myApp/LemonLimeTracker/IOS/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..0d88802 --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Resources/en.lproj/Localizable.strings @@ -0,0 +1,72 @@ +/* Tab Bar */ +"tab.dashboard" = "Dashboard"; +"tab.category" = "Category"; +"tab.tasks" = "Tasks"; +"tab.goals" = "Goals"; +"tab.settings" = "Settings"; + +/* Task Tracker */ +"task.empty" = "No tasks added yet"; +"task.add" = "Add Task"; +"task.name" = "Name"; +"task.icon" = "Icon"; +"task.type" = "Type"; +"task.type.count" = "Count"; +"task.type.time" = "Time"; +"task.add.count" = "Add Count"; +"task.delete" = "Delete"; +"task.category" = "Category"; +"task.category.none" = "None"; + +/* Buttons */ +"button.cancel" = "Cancel"; +"button.add" = "Add"; + +/* Category */ +"category.empty" = "No categories yet"; +"category.add" = "Add Category"; +"category.name" = "Name"; +"category.color" = "Color"; +"category.delete" = "Delete"; +"category.task.count" = "tasks"; + +/* Goals */ +"goal.empty" = "No goals yet"; +"goal.add" = "Add Goal"; +"goal.chart.title" = "Goal Progress"; +"goal.progress" = "Progress"; +"goal.target" = "Target"; +"goal.target.task" = "Task"; +"goal.target.category" = "Category"; +"goal.no.target" = "None"; +"goal.condition" = "Condition"; +"goal.condition.count" = "Count"; +"goal.condition.time" = "Time"; +"goal.frequency" = "Frequency"; +"goal.frequency.daily" = "Daily"; +"goal.frequency.weekly" = "Weekly"; +"goal.frequency.monthly" = "Monthly"; +"goal.threshold" = "Target Value"; +"goal.delete" = "Delete"; + +/* Dashboard */ +"dashboard.progress.title" = "Today's Progress"; +"dashboard.goals.title" = "Top Goals"; +"dashboard.tasks.title" = "Remaining Tasks"; +"dashboard.tasks.empty" = "All tasks done for today!"; + +/* Settings */ +"settings.section.general" = "Preferences"; +"settings.language" = "Language"; +"settings.language.system" = "System"; +"settings.firstWeekday" = "Start of Week"; +"settings.firstWeekday.sunday" = "Sunday"; +"settings.firstWeekday.monday" = "Monday"; +"settings.dayStart" = "Start of Day"; +"settings.section.premium" = "Premium Features"; +"settings.premium.footnote" = "Premium Feature — coming soon"; +"settings.premium.cloudSync" = "Cloud Data Sync"; +"settings.premium.appleWatch" = "Apple Watch Support"; +"settings.premium.ipadMacSync" = "iPad & Mac Sync"; +"settings.premium.widgets" = "Home/Lock Screen Widgets"; +"settings.premium.siriShortcuts" = "Siri & Shortcuts"; diff --git a/myApp/LemonLimeTracker/IOS/Resources/ko.lproj/Localizable.strings b/myApp/LemonLimeTracker/IOS/Resources/ko.lproj/Localizable.strings new file mode 100644 index 0000000..6e2b31d --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Resources/ko.lproj/Localizable.strings @@ -0,0 +1,72 @@ +/* Tab Bar */ +"tab.dashboard" = "대시보드"; +"tab.category" = "카테고리"; +"tab.tasks" = "작업 추적"; +"tab.goals" = "목표"; +"tab.settings" = "설정"; + +/* Task Tracker */ +"task.empty" = "등록된 작업이 없습니다"; +"task.add" = "작업 추가"; +"task.name" = "이름"; +"task.icon" = "아이콘"; +"task.type" = "유형"; +"task.type.count" = "횟수"; +"task.type.time" = "시간"; +"task.add.count" = "횟수 추가"; +"task.delete" = "삭제"; +"task.category" = "카테고리"; +"task.category.none" = "없음"; + +/* Buttons */ +"button.cancel" = "취소"; +"button.add" = "추가"; + +/* Category */ +"category.empty" = "카테고리가 없습니다"; +"category.add" = "카테고리 추가"; +"category.name" = "이름"; +"category.color" = "색상"; +"category.delete" = "삭제"; +"category.task.count" = "작업"; + +/* Goals */ +"goal.empty" = "등록된 목표가 없습니다"; +"goal.add" = "목표 추가"; +"goal.chart.title" = "목표 진행률"; +"goal.progress" = "진행률"; +"goal.target" = "대상"; +"goal.target.task" = "작업"; +"goal.target.category" = "카테고리"; +"goal.no.target" = "없음"; +"goal.condition" = "조건"; +"goal.condition.count" = "횟수"; +"goal.condition.time" = "시간"; +"goal.frequency" = "주기"; +"goal.frequency.daily" = "매일"; +"goal.frequency.weekly" = "매주"; +"goal.frequency.monthly" = "매월"; +"goal.threshold" = "목표값"; +"goal.delete" = "삭제"; + +/* Dashboard */ +"dashboard.progress.title" = "오늘의 진행률"; +"dashboard.goals.title" = "주요 목표"; +"dashboard.tasks.title" = "남은 작업"; +"dashboard.tasks.empty" = "오늘의 작업을 모두 완료했습니다!"; + +/* Settings */ +"settings.section.general" = "환경설정"; +"settings.language" = "언어"; +"settings.language.system" = "시스템"; +"settings.firstWeekday" = "주 시작일"; +"settings.firstWeekday.sunday" = "일요일"; +"settings.firstWeekday.monday" = "월요일"; +"settings.dayStart" = "하루 시작 시간"; +"settings.section.premium" = "프리미엄 기능"; +"settings.premium.footnote" = "프리미엄 기능 — 출시 예정"; +"settings.premium.cloudSync" = "클라우드 데이터 동기화"; +"settings.premium.appleWatch" = "Apple Watch 지원"; +"settings.premium.ipadMacSync" = "iPad 및 Mac 동기화"; +"settings.premium.widgets" = "홈/잠금 화면 위젯"; +"settings.premium.siriShortcuts" = "Siri 및 단축어"; diff --git a/myApp/LemonLimeTracker/IOS/ViewModels/CategoryViewModel.swift b/myApp/LemonLimeTracker/IOS/ViewModels/CategoryViewModel.swift new file mode 100644 index 0000000..a7a858b --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/ViewModels/CategoryViewModel.swift @@ -0,0 +1,45 @@ +import Foundation +import Observation +import SwiftData + +@MainActor +@Observable +final class CategoryViewModel { + private(set) var categories: [Category] = [] + private var context: ModelContext? + + static let presetColors: [String] = [ + "#2EB852", "#F5DC1F", "#4A90D9", "#F5A623", + "#9B59B6", "#E74C3C", "#1ABC9C", "#F39C12" + ] + + func setup(context: ModelContext) { + self.context = context + fetchCategories() + } + + func fetchCategories() { + guard let context else { return } + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.name)]) + categories = (try? context.fetch(descriptor)) ?? [] + } + + func addCategory(name: String, colorHex: String) { + guard let context else { return } + let category = Category(name: name, colorHex: colorHex) + context.insert(category) + try? context.save() + fetchCategories() + } + + func deleteCategory(_ category: Category) { + guard let context else { return } + context.delete(category) + try? context.save() + fetchCategories() + } + + func taskCount(for category: Category) -> Int { + category.tasks.count + } +} diff --git a/myApp/LemonLimeTracker/IOS/ViewModels/DashboardViewModel.swift b/myApp/LemonLimeTracker/IOS/ViewModels/DashboardViewModel.swift new file mode 100644 index 0000000..85a12a5 --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/ViewModels/DashboardViewModel.swift @@ -0,0 +1,84 @@ +import Foundation +import Observation +import SwiftData + +@MainActor +@Observable +final class DashboardViewModel { + private(set) var dailyGoalProgress: [GoalProgress] = [] + private(set) var remainingTasks: [TaskItem] = [] + private(set) var completedTaskCount: Int = 0 + private(set) var totalTaskCount: Int = 0 + + private var context: ModelContext? + + var overallProgress: Double { + if !dailyGoalProgress.isEmpty { + let sum = dailyGoalProgress.reduce(0.0) { $0 + $1.ratio } + return sum / Double(dailyGoalProgress.count) + } + guard totalTaskCount > 0 else { return 0 } + return Double(completedTaskCount) / Double(totalTaskCount) + } + + var topGoalProgress: [GoalProgress] { + Array(dailyGoalProgress.sorted { $0.ratio < $1.ratio }.prefix(3)) + } + + func setup(context: ModelContext) { + self.context = context + refresh() + } + + func refresh() { + guard let context else { return } + + let goalDescriptor = FetchDescriptor() + let goals = (try? context.fetch(goalDescriptor)) ?? [] + let dailyGoals = goals.filter { $0.frequency == .daily } + dailyGoalProgress = dailyGoals.map { goal in + let current = todayValue(for: goal) + return GoalProgress(goal: goal, current: current, target: goal.conditions) + } + + let taskDescriptor = FetchDescriptor(sortBy: [SortDescriptor(\.name)]) + let tasks = (try? context.fetch(taskDescriptor)) ?? [] + totalTaskCount = tasks.count + remainingTasks = tasks.filter { !isCompletedToday($0) } + completedTaskCount = totalTaskCount - remainingTasks.count + } + + func addCount(to task: TaskItem) { + guard let context else { return } + let log = TaskLog(date: .now, duration: 0, count: 1) + log.task = task + context.insert(log) + try? context.save() + refresh() + } + + private func isCompletedToday(_ task: TaskItem) -> Bool { + let todayLogs = task.logs.filter { Calendar.current.isDateInToday($0.date) } + switch task.type { + case .count: + return todayLogs.reduce(0) { $0 + $1.count } > 0 + case .time: + return !todayLogs.isEmpty + } + } + + private func todayValue(for goal: Goal) -> Double { + let logs: [TaskLog] + if let task = goal.task { + logs = task.logs + } else if let category = goal.category { + logs = category.tasks.flatMap { $0.logs } + } else { + logs = [] + } + let todayLogs = logs.filter { Calendar.current.isDateInToday($0.date) } + return goal.targetType == "count" + ? Double(todayLogs.reduce(0) { $0 + $1.count }) + : todayLogs.reduce(0.0) { $0 + $1.duration } + } +} diff --git a/myApp/LemonLimeTracker/IOS/ViewModels/GoalViewModel.swift b/myApp/LemonLimeTracker/IOS/ViewModels/GoalViewModel.swift new file mode 100644 index 0000000..196b392 --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/ViewModels/GoalViewModel.swift @@ -0,0 +1,93 @@ +import Foundation +import Observation +import SwiftData + +enum GoalTargetType: String, CaseIterable { + case task + case category +} + +struct GoalProgress: Identifiable { + var id: UUID { goal.id } + let goal: Goal + let current: Double + let target: Double + + var ratio: Double { target > 0 ? min(current / target, 1.0) : 0.0 } + var isCompleted: Bool { current >= target } + + var displayName: String { + if let task = goal.task { return "\(task.icon) \(task.name)" } + if let category = goal.category { return category.name } + return "—" + } + + var percentText: String { "\(Int(ratio * 100))%" } +} + +@MainActor +@Observable +final class GoalViewModel { + private(set) var goals: [Goal] = [] + private(set) var goalProgress: [GoalProgress] = [] + private var context: ModelContext? + + func setup(context: ModelContext) { + self.context = context + fetchGoals() + } + + func fetchGoals() { + guard let context else { return } + let descriptor = FetchDescriptor() + goals = (try? context.fetch(descriptor)) ?? [] + computeProgress() + } + + func addGoal(targetType: String, conditions: Double, frequency: GoalFrequency, task: TaskItem?, category: Category?) { + guard let context else { return } + let goal = Goal(targetType: targetType, conditions: conditions, frequency: frequency, task: task, category: category) + context.insert(goal) + try? context.save() + fetchGoals() + } + + func deleteGoal(_ goal: Goal) { + guard let context else { return } + context.delete(goal) + try? context.save() + fetchGoals() + } + + private func computeProgress() { + let now = Date.now + let cal = Calendar.current + goalProgress = goals.map { goal in + let logs = relevantLogs(for: goal, reference: now, calendar: cal) + let current: Double = goal.targetType == "count" + ? Double(logs.reduce(0) { $0 + $1.count }) + : logs.reduce(0.0) { $0 + $1.duration } + return GoalProgress(goal: goal, current: current, target: goal.conditions) + } + } + + private func relevantLogs(for goal: Goal, reference: Date, calendar: Calendar) -> [TaskLog] { + let allLogs: [TaskLog] + if let task = goal.task { + allLogs = task.logs + } else if let category = goal.category { + allLogs = category.tasks.flatMap { $0.logs } + } else { + return [] + } + return allLogs.filter { inPeriod($0.date, frequency: goal.frequency, reference: reference, calendar: calendar) } + } + + private func inPeriod(_ date: Date, frequency: GoalFrequency, reference: Date, calendar: Calendar) -> Bool { + switch frequency { + case .daily: return calendar.isDate(date, inSameDayAs: reference) + case .weekly: return calendar.isDate(date, equalTo: reference, toGranularity: .weekOfYear) + case .monthly: return calendar.isDate(date, equalTo: reference, toGranularity: .month) + } + } +} diff --git a/myApp/LemonLimeTracker/IOS/ViewModels/SettingsViewModel.swift b/myApp/LemonLimeTracker/IOS/ViewModels/SettingsViewModel.swift new file mode 100644 index 0000000..cb5ec61 --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/ViewModels/SettingsViewModel.swift @@ -0,0 +1,76 @@ +import Foundation +import Observation + +enum AppLanguage: String, CaseIterable, Identifiable { + case system + case english + case korean + + var id: String { rawValue } + + var displayName: String { + switch self { + case .system: return String(localized: "settings.language.system") + case .english: return "English" + case .korean: return "한국어" + } + } +} + +enum FirstWeekday: Int, CaseIterable, Identifiable { + case sunday = 1 + case monday = 2 + + var id: Int { rawValue } + + var displayName: String { + switch self { + case .sunday: return String(localized: "settings.firstWeekday.sunday") + case .monday: return String(localized: "settings.firstWeekday.monday") + } + } +} + +@MainActor +@Observable +final class SettingsViewModel { + private static let languageKey = "settings.language" + private static let firstWeekdayKey = "settings.firstWeekday" + private static let dayStartMinutesKey = "settings.dayStartMinutes" + + static let defaultDayStartMinutes = 4 * 60 // 04:00 AM + + var language: AppLanguage { + didSet { UserDefaults.standard.set(language.rawValue, forKey: Self.languageKey) } + } + + var firstWeekday: FirstWeekday { + didSet { UserDefaults.standard.set(firstWeekday.rawValue, forKey: Self.firstWeekdayKey) } + } + + /// Minutes since midnight marking when a "day" starts for goal/log purposes. + var dayStartMinutes: Int { + didSet { UserDefaults.standard.set(dayStartMinutes, forKey: Self.dayStartMinutesKey) } + } + + var dayStartDate: Date { + get { + Calendar.current.date(bySettingHour: dayStartMinutes / 60, minute: dayStartMinutes % 60, second: 0, of: .now) ?? .now + } + set { + let comps = Calendar.current.dateComponents([.hour, .minute], from: newValue) + dayStartMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0) + } + } + + init() { + let defaults = UserDefaults.standard + language = AppLanguage(rawValue: defaults.string(forKey: Self.languageKey) ?? "") ?? .system + firstWeekday = FirstWeekday(rawValue: defaults.integer(forKey: Self.firstWeekdayKey)) ?? .sunday + if let storedMinutes = defaults.object(forKey: Self.dayStartMinutesKey) as? Int { + dayStartMinutes = storedMinutes + } else { + dayStartMinutes = Self.defaultDayStartMinutes + } + } +} diff --git a/myApp/LemonLimeTracker/IOS/ViewModels/TaskViewModel.swift b/myApp/LemonLimeTracker/IOS/ViewModels/TaskViewModel.swift new file mode 100644 index 0000000..0923425 --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/ViewModels/TaskViewModel.swift @@ -0,0 +1,157 @@ +import Foundation +import Observation +import SwiftData +#if os(iOS) +import ActivityKit +#endif + +@MainActor +@Observable +final class TaskViewModel { + private(set) var tasks: [TaskItem] = [] + private(set) var activeTimerStartDates: [UUID: Date] = [:] + private(set) var elapsedSeconds: [UUID: Int] = [:] + + private var context: ModelContext? + private var timerTasks: [UUID: Task] = [:] +#if os(iOS) + private var liveActivities: [UUID: Activity] = [:] +#endif + + func setup(context: ModelContext) { + self.context = context + fetchTasks() + } + + func fetchTasks() { + guard let context else { return } + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.name)]) + tasks = (try? context.fetch(descriptor)) ?? [] + } + + func addTask(name: String, icon: String, type: TaskType, category: Category? = nil) { + guard let context else { return } + let item = TaskItem(name: name, icon: icon, type: type, category: category) + context.insert(item) + try? context.save() + fetchTasks() + } + + func deleteTask(_ task: TaskItem) { + stopTimer(for: task) + guard let context else { return } + context.delete(task) + try? context.save() + fetchTasks() + } + + func addCount(to task: TaskItem) { + guard let context else { return } + let log = TaskLog(date: .now, duration: 0, count: 1) + log.task = task + context.insert(log) + try? context.save() + } + + func toggleTimer(for task: TaskItem) { + if activeTimerStartDates[task.id] != nil { + stopTimer(for: task) + } else { + startTimer(for: task) + } + } + + func isTimerRunning(for taskID: UUID) -> Bool { + activeTimerStartDates[taskID] != nil + } + + func formattedElapsed(for taskID: UUID) -> String { + let s = elapsedSeconds[taskID] ?? 0 + let h = s / 3600 + let m = (s % 3600) / 60 + let sec = s % 60 + return h > 0 + ? String(format: "%d:%02d:%02d", h, m, sec) + : String(format: "%02d:%02d", m, sec) + } + + func todayCount(for task: TaskItem) -> Int { + task.logs + .filter { Calendar.current.isDateInToday($0.date) } + .reduce(0) { $0 + $1.count } + } + + // MARK: - Timer + + private func startTimer(for task: TaskItem) { + let startDate = Date.now + activeTimerStartDates[task.id] = startDate + elapsedSeconds[task.id] = 0 + startLiveActivity(for: task, startDate: startDate) + + let taskID = task.id + timerTasks[taskID] = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled, let self else { break } + let elapsed = Int(Date.now.timeIntervalSince(startDate)) + self.elapsedSeconds[taskID] = elapsed + await self.updateLiveActivity(taskID: taskID, elapsed: elapsed, startDate: startDate) + } + } + } + + private func stopTimer(for task: TaskItem) { + guard let startDate = activeTimerStartDates[task.id] else { return } + + timerTasks[task.id]?.cancel() + timerTasks.removeValue(forKey: task.id) + + let duration = Date.now.timeIntervalSince(startDate) + activeTimerStartDates.removeValue(forKey: task.id) + elapsedSeconds.removeValue(forKey: task.id) + + endLiveActivity(for: task) + + guard let context else { return } + let log = TaskLog(date: .now, duration: duration, count: 0) + log.task = task + context.insert(log) + try? context.save() + } + + // MARK: - ActivityKit + + private func startLiveActivity(for task: TaskItem, startDate: Date) { +#if os(iOS) + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + let attrs = TimerActivityAttributes(taskName: task.name, taskIcon: task.icon) + let state = TimerActivityAttributes.ContentState(startDate: startDate, elapsedSeconds: 0) + let content = ActivityContent(state: state, staleDate: nil) + do { + let activity = try Activity.request(attributes: attrs, content: content) + liveActivities[task.id] = activity + } catch { + // Live Activity unavailable (simulator, denied, or OS < 16.2) + } +#endif + } + + private func updateLiveActivity(taskID: UUID, elapsed: Int, startDate: Date) async { +#if os(iOS) + guard let activity = liveActivities[taskID] else { return } + let state = TimerActivityAttributes.ContentState(startDate: startDate, elapsedSeconds: elapsed) + await activity.update(ActivityContent(state: state, staleDate: nil)) +#endif + } + + private func endLiveActivity(for task: TaskItem) { +#if os(iOS) + guard let activity = liveActivities[task.id] else { return } + liveActivities.removeValue(forKey: task.id) + Task { + await activity.end(nil, dismissalPolicy: .immediate) + } +#endif + } +} diff --git a/myApp/LemonLimeTracker/IOS/Views/CategoryView.swift b/myApp/LemonLimeTracker/IOS/Views/CategoryView.swift new file mode 100644 index 0000000..2b6369f --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Views/CategoryView.swift @@ -0,0 +1,177 @@ +import SwiftUI +import SwiftData + +struct CategoryView: View { + @Environment(\.modelContext) private var modelContext + @State private var viewModel = CategoryViewModel() + @State private var showAddSheet = false + + var body: some View { + ZStack { + Theme.background.ignoresSafeArea() + Group { + if viewModel.categories.isEmpty { + emptyState + } else { + categoryList + } + } + } + .navigationTitle(String(localized: "tab.category")) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { showAddSheet = true } label: { + Image(systemName: "plus") + .foregroundStyle(Theme.green) + } + } + } + .sheet(isPresented: $showAddSheet) { + AddCategorySheet { name, colorHex in + viewModel.addCategory(name: name, colorHex: colorHex) + } + } + .onAppear { viewModel.setup(context: modelContext) } + } + + private var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "folder.badge.plus") + .font(.system(size: 56)) + .foregroundStyle(Theme.green.opacity(0.6)) + Text(String(localized: "category.empty")) + .foregroundStyle(Theme.primaryText.opacity(0.6)) + } + } + + private var categoryList: some View { + List { + ForEach(viewModel.categories) { category in + CategoryRow(category: category, taskCount: viewModel.taskCount(for: category)) + .listRowBackground(Theme.surface) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + viewModel.deleteCategory(category) + } label: { + Label(String(localized: "category.delete"), systemImage: "trash") + } + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } +} + +// MARK: - Category Row + +private struct CategoryRow: View { + let category: Category + let taskCount: Int + + var body: some View { + HStack(spacing: 12) { + Circle() + .fill(Color(hex: category.colorHex)) + .frame(width: 14, height: 14) + + Text(category.name) + .font(.body) + .fontWeight(.medium) + .foregroundStyle(Theme.primaryText) + + Spacer() + + Text("\(taskCount) \(String(localized: "category.task.count"))") + .font(.caption) + .foregroundStyle(Theme.primaryText.opacity(0.5)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Theme.green.opacity(0.12), in: Capsule()) + } + .padding(.vertical, 6) + } +} + +// MARK: - Add Category Sheet + +private struct AddCategorySheet: View { + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var selectedColor = CategoryViewModel.presetColors[0] + + let onAdd: (String, String) -> Void + + var body: some View { + NavigationStack { + Form { + Section { + TextField(String(localized: "category.name"), text: $name) + } + + Section(String(localized: "category.color")) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: 16) { + ForEach(CategoryViewModel.presetColors, id: \.self) { colorHex in + Circle() + .fill(Color(hex: colorHex)) + .frame(width: 42, height: 42) + .overlay(alignment: .center) { + if selectedColor == colorHex { + Image(systemName: "checkmark") + .font(.caption) + .fontWeight(.bold) + .foregroundStyle(.white) + } + } + .overlay( + Circle() + .strokeBorder( + selectedColor == colorHex ? Theme.primaryText.opacity(0.4) : Color.clear, + lineWidth: 2 + ) + ) + .onTapGesture { selectedColor = colorHex } + } + } + .padding(.vertical, 8) + } + } + .navigationTitle(String(localized: "category.add")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "button.cancel")) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "button.add")) { + let trimmed = name.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + onAdd(trimmed, selectedColor) + dismiss() + } + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + } +} + +// MARK: - Color Hex Extension + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let r = Double((int >> 16) & 0xFF) / 255 + let g = Double((int >> 8) & 0xFF) / 255 + let b = Double(int & 0xFF) / 255 + self.init(red: r, green: g, blue: b) + } +} + +#Preview { + NavigationStack { + CategoryView() + } +} diff --git a/myApp/LemonLimeTracker/IOS/Views/DashboardView.swift b/myApp/LemonLimeTracker/IOS/Views/DashboardView.swift new file mode 100644 index 0000000..d1c8d18 --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Views/DashboardView.swift @@ -0,0 +1,160 @@ +import SwiftUI +import SwiftData + +struct DashboardView: View { + @Environment(\.modelContext) private var modelContext + @State private var viewModel = DashboardViewModel() + + var body: some View { + ZStack { + Theme.background.ignoresSafeArea() + ScrollView { + VStack(spacing: 20) { + progressRing + if !viewModel.topGoalProgress.isEmpty { + topGoals + } + remainingTasksSection + } + .padding() + } + } + .navigationTitle(String(localized: "tab.dashboard")) + .onAppear { viewModel.setup(context: modelContext) } + } + + // MARK: - Progress Ring + + private var progressRing: some View { + VStack(spacing: 12) { + ZStack { + Circle() + .stroke(Theme.surface, lineWidth: 16) + Circle() + .trim(from: 0, to: viewModel.overallProgress) + .stroke(Theme.green, style: StrokeStyle(lineWidth: 16, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.4), value: viewModel.overallProgress) + VStack(spacing: 4) { + Text("\(Int(viewModel.overallProgress * 100))%") + .font(.system(size: 34, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.primaryText) + Text(String(localized: "dashboard.progress.title")) + .font(.caption) + .foregroundStyle(Theme.primaryText.opacity(0.6)) + } + } + .frame(width: 180, height: 180) + .padding(.top, 8) + } + } + + // MARK: - Top Goals + + private var topGoals: some View { + VStack(alignment: .leading, spacing: 10) { + Text(String(localized: "dashboard.goals.title")) + .font(.headline) + .foregroundStyle(Theme.primaryText) + + VStack(spacing: 10) { + ForEach(viewModel.topGoalProgress) { progress in + TopGoalCard(progress: progress) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - Remaining Tasks + + private var remainingTasksSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text(String(localized: "dashboard.tasks.title")) + .font(.headline) + .foregroundStyle(Theme.primaryText) + + if viewModel.remainingTasks.isEmpty { + Text(String(localized: "dashboard.tasks.empty")) + .font(.subheadline) + .foregroundStyle(Theme.primaryText.opacity(0.5)) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 24) + } else { + VStack(spacing: 8) { + ForEach(viewModel.remainingTasks) { task in + RemainingTaskRow(task: task) { + viewModel.addCount(to: task) + } + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Top Goal Card + +private struct TopGoalCard: View { + let progress: GoalProgress + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(progress.displayName) + .font(.body) + .fontWeight(.semibold) + .foregroundStyle(Theme.primaryText) + ProgressView(value: progress.ratio) + .tint(progress.isCompleted ? Theme.green : Theme.yellow) + } + Text(progress.percentText) + .font(.caption) + .fontWeight(.bold) + .foregroundStyle(progress.isCompleted ? Theme.green : Theme.yellow) + } + .padding() + .background(Theme.surface, in: RoundedRectangle(cornerRadius: 12)) + } +} + +// MARK: - Remaining Task Row + +private struct RemainingTaskRow: View { + let task: TaskItem + let onAddCount: () -> Void + + var body: some View { + HStack(spacing: 12) { + Text(task.icon) + .font(.title3) + .frame(width: 30) + + Text(task.name) + .font(.body) + .foregroundStyle(Theme.primaryText) + + Spacer() + + if task.type == .count { + Button(action: onAddCount) { + Image(systemName: "plus.circle.fill") + .foregroundStyle(Theme.green) + } + .buttonStyle(.plain) + } else { + Image(systemName: "clock") + .foregroundStyle(Theme.primaryText.opacity(0.4)) + } + } + .padding(12) + .background(Theme.surface, in: RoundedRectangle(cornerRadius: 10)) + } +} + +#Preview { + NavigationStack { + DashboardView() + } +} diff --git a/myApp/LemonLimeTracker/IOS/Views/GoalView.swift b/myApp/LemonLimeTracker/IOS/Views/GoalView.swift new file mode 100644 index 0000000..8d60d24 --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Views/GoalView.swift @@ -0,0 +1,306 @@ +import SwiftUI +import SwiftData +import Charts + +struct GoalView: View { + @Environment(\.modelContext) private var modelContext + @State private var viewModel = GoalViewModel() + @State private var showAddSheet = false + + var body: some View { + ZStack { + Theme.background.ignoresSafeArea() + Group { + if viewModel.goals.isEmpty { + emptyState + } else { + goalContent + } + } + } + .navigationTitle(String(localized: "tab.goals")) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { showAddSheet = true } label: { + Image(systemName: "plus") + .foregroundStyle(Theme.green) + } + } + } + .sheet(isPresented: $showAddSheet) { + AddGoalSheet { targetType, conditions, frequency, task, category in + viewModel.addGoal(targetType: targetType, conditions: conditions, frequency: frequency, task: task, category: category) + } + } + .onAppear { viewModel.setup(context: modelContext) } + } + + private var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "flag.badge.ellipsis") + .font(.system(size: 56)) + .foregroundStyle(Theme.green.opacity(0.6)) + Text(String(localized: "goal.empty")) + .foregroundStyle(Theme.primaryText.opacity(0.6)) + } + } + + private var goalContent: some View { + ScrollView { + VStack(spacing: 16) { + progressChart + .padding(.horizontal) + goalCards + .padding(.horizontal) + } + .padding(.vertical) + } + } + + // MARK: - Chart + + private var progressChart: some View { + VStack(alignment: .leading, spacing: 10) { + Text(String(localized: "goal.chart.title")) + .font(.headline) + .foregroundStyle(Theme.primaryText) + + Chart(viewModel.goalProgress) { progress in + BarMark( + x: .value(String(localized: "goal.progress"), progress.ratio * 100), + y: .value("", progress.displayName) + ) + .foregroundStyle(progress.isCompleted ? Theme.green : Theme.yellow) + .cornerRadius(4) + } + .chartXScale(domain: 0.0...100.0) + .chartXAxis { + AxisMarks(values: [0.0, 25.0, 50.0, 75.0, 100.0]) { value in + AxisGridLine() + AxisValueLabel { + if let v = value.as(Double.self) { + Text("\(Int(v))%") + .font(.caption2) + .foregroundStyle(Theme.primaryText.opacity(0.5)) + } + } + } + } + .chartYAxis { + AxisMarks { _ in + AxisValueLabel() + } + } + .frame(height: CGFloat(viewModel.goalProgress.count) * 48 + 20) + } + .padding() + .background(Theme.surface, in: RoundedRectangle(cornerRadius: 12)) + } + + // MARK: - Goal Cards + + private var goalCards: some View { + VStack(spacing: 12) { + ForEach(viewModel.goalProgress) { progress in + GoalCard(progress: progress) { + viewModel.deleteGoal(progress.goal) + } + } + } + } +} + +// MARK: - Goal Card + +private struct GoalCard: View { + let progress: GoalProgress + let onDelete: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Text(progress.displayName) + .font(.body) + .fontWeight(.semibold) + .foregroundStyle(Theme.primaryText) + Text(subtitleText) + .font(.caption) + .foregroundStyle(Theme.primaryText.opacity(0.5)) + } + Spacer() + Button(role: .destructive, action: onDelete) { + Image(systemName: "trash") + .font(.caption) + .foregroundStyle(.red.opacity(0.7)) + } + .buttonStyle(.plain) + } + + ProgressView(value: progress.ratio) + .tint(progress.isCompleted ? Theme.green : Theme.yellow) + + HStack { + Text(currentText) + .font(.caption) + .foregroundStyle(Theme.primaryText.opacity(0.6)) + Spacer() + Text(progress.percentText) + .font(.caption) + .fontWeight(.bold) + .foregroundStyle(progress.isCompleted ? Theme.green : Theme.yellow) + } + } + .padding() + .background(Theme.surface, in: RoundedRectangle(cornerRadius: 12)) + } + + private var subtitleText: String { + let cond = progress.goal.targetType == "count" + ? String(localized: "goal.condition.count") + : String(localized: "goal.condition.time") + let freq: String + switch progress.goal.frequency { + case .daily: freq = String(localized: "goal.frequency.daily") + case .weekly: freq = String(localized: "goal.frequency.weekly") + case .monthly: freq = String(localized: "goal.frequency.monthly") + } + return "\(cond) · \(freq)" + } + + private var currentText: String { + if progress.goal.targetType == "count" { + return "\(Int(progress.current)) / \(Int(progress.target)) \(String(localized: "goal.condition.count"))" + } else { + let currentMins = Int(progress.current) / 60 + let targetMins = Int(progress.target) / 60 + return "\(currentMins)m / \(targetMins)m" + } + } +} + +// MARK: - Add Goal Sheet + +private struct AddGoalSheet: View { + @Environment(\.dismiss) private var dismiss + @Query(sort: \TaskItem.name) private var tasks: [TaskItem] + @Query(sort: \Category.name) private var categories: [Category] + + @State private var targetType: GoalTargetType = .task + @State private var selectedTask: TaskItem? + @State private var selectedCategory: Category? + @State private var conditionType: String = "count" + @State private var threshold: Double = 5 + @State private var frequency: GoalFrequency = .daily + + let onAdd: (String, Double, GoalFrequency, TaskItem?, Category?) -> Void + + var body: some View { + NavigationStack { + Form { + Section(header: Text(String(localized: "goal.target"))) { + Picker(String(localized: "goal.target"), selection: $targetType) { + Text(String(localized: "goal.target.task")).tag(GoalTargetType.task) + Text(String(localized: "goal.target.category")).tag(GoalTargetType.category) + } + .pickerStyle(.segmented) + .onChange(of: targetType) { _, _ in + selectedTask = nil + selectedCategory = nil + } + + if targetType == .task { + Picker(String(localized: "goal.target.task"), selection: $selectedTask) { + Text(String(localized: "goal.no.target")).tag(Optional.none) + ForEach(tasks) { task in + Text("\(task.icon) \(task.name)").tag(Optional(task)) + } + } + } else { + Picker(String(localized: "goal.target.category"), selection: $selectedCategory) { + Text(String(localized: "goal.no.target")).tag(Optional.none) + ForEach(categories) { cat in + Text(cat.name).tag(Optional(cat)) + } + } + } + } + + Section(header: Text(String(localized: "goal.condition"))) { + Picker(String(localized: "goal.condition"), selection: $conditionType) { + Text(String(localized: "goal.condition.count")).tag("count") + Text(String(localized: "goal.condition.time")).tag("duration") + } + .pickerStyle(.segmented) + .onChange(of: conditionType) { _, _ in threshold = 5 } + } + + Section(header: Text(String(localized: "goal.threshold"))) { + HStack { + Slider( + value: $threshold, + in: conditionType == "count" ? 1.0...100.0 : 5.0...240.0, + step: conditionType == "count" ? 1.0 : 5.0 + ) + .tint(Theme.green) + Text(thresholdText) + .font(.body) + .monospacedDigit() + .foregroundStyle(Theme.green) + .frame(width: 52, alignment: .trailing) + } + } + + Section(header: Text(String(localized: "goal.frequency"))) { + Picker(String(localized: "goal.frequency"), selection: $frequency) { + Text(String(localized: "goal.frequency.daily")).tag(GoalFrequency.daily) + Text(String(localized: "goal.frequency.weekly")).tag(GoalFrequency.weekly) + Text(String(localized: "goal.frequency.monthly")).tag(GoalFrequency.monthly) + } + .pickerStyle(.segmented) + } + } + .navigationTitle(String(localized: "goal.add")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "button.cancel")) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "button.add")) { + let storedConditions = conditionType == "duration" ? threshold * 60.0 : threshold + onAdd( + conditionType, + storedConditions, + frequency, + targetType == .task ? selectedTask : nil, + targetType == .category ? selectedCategory : nil + ) + dismiss() + } + .disabled(!canAdd) + } + } + } + } + + private var thresholdText: String { + if conditionType == "count" { + return "\(Int(threshold))" + } else { + let h = Int(threshold) / 60 + let m = Int(threshold) % 60 + return h > 0 ? "\(h)h\(m)m" : "\(Int(threshold))m" + } + } + + private var canAdd: Bool { + targetType == .task ? selectedTask != nil : selectedCategory != nil + } +} + +#Preview { + NavigationStack { + GoalView() + } +} diff --git a/myApp/LemonLimeTracker/IOS/Views/LaunchScreenView.swift b/myApp/LemonLimeTracker/IOS/Views/LaunchScreenView.swift new file mode 100644 index 0000000..7e198e0 --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Views/LaunchScreenView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct LaunchScreenView: View { + var body: some View { + ZStack { + Theme.background + .ignoresSafeArea() + VStack(spacing: 12) { + Text("🍋🍈") + .font(.system(size: 60)) + Text("LemonLimeTracker") + .font(.largeTitle) + .fontWeight(.bold) + .foregroundStyle(Theme.green) + } + } + } +} + +#Preview { + LaunchScreenView() +} diff --git a/myApp/LemonLimeTracker/IOS/Views/MainTabView.swift b/myApp/LemonLimeTracker/IOS/Views/MainTabView.swift new file mode 100644 index 0000000..323770f --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Views/MainTabView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct MainTabView: View { + var body: some View { + TabView { + NavigationStack { + DashboardView() + } + .tabItem { + Label(String(localized: "tab.dashboard"), systemImage: "chart.bar.fill") + } + + NavigationStack { + CategoryView() + } + .tabItem { + Label(String(localized: "tab.category"), systemImage: "folder.fill") + } + + NavigationStack { + TaskTrackerView() + } + .tabItem { + Label(String(localized: "tab.tasks"), systemImage: "checkmark.circle.fill") + } + + NavigationStack { + GoalView() + } + .tabItem { + Label(String(localized: "tab.goals"), systemImage: "flag.fill") + } + + NavigationStack { + SettingsView() + } + .tabItem { + Label(String(localized: "tab.settings"), systemImage: "gearshape.fill") + } + } + .tint(Theme.green) + } +} + +#Preview { + MainTabView() +} diff --git a/myApp/LemonLimeTracker/IOS/Views/RootView.swift b/myApp/LemonLimeTracker/IOS/Views/RootView.swift new file mode 100644 index 0000000..c535012 --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Views/RootView.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct RootView: View { + @State private var isLaunching = true + + var body: some View { + if isLaunching { + LaunchScreenView() + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation(.easeInOut(duration: 0.4)) { + isLaunching = false + } + } + } + } else { + MainTabView() + } + } +} + +#Preview { + RootView() +} diff --git a/myApp/LemonLimeTracker/IOS/Views/SettingsView.swift b/myApp/LemonLimeTracker/IOS/Views/SettingsView.swift new file mode 100644 index 0000000..4c81cc0 --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Views/SettingsView.swift @@ -0,0 +1,107 @@ +import SwiftUI + +struct SettingsView: View { + @State private var viewModel = SettingsViewModel() + + var body: some View { + Form { + Section(header: Text(String(localized: "settings.section.general"))) { + Picker(String(localized: "settings.language"), selection: $viewModel.language) { + ForEach(AppLanguage.allCases) { lang in + Text(lang.displayName).tag(lang) + } + } + + Picker(String(localized: "settings.firstWeekday"), selection: $viewModel.firstWeekday) { + ForEach(FirstWeekday.allCases) { day in + Text(day.displayName).tag(day) + } + } + + DatePicker( + String(localized: "settings.dayStart"), + selection: $viewModel.dayStartDate, + displayedComponents: .hourAndMinute + ) + .tint(Theme.green) + } + .listRowBackground(Theme.surface) + + Section { + ForEach(PremiumFeature.allCases) { feature in + PremiumFeatureRow(feature: feature) + } + } header: { + Text(String(localized: "settings.section.premium")) + } footer: { + Text(String(localized: "settings.premium.footnote")) + } + .listRowBackground(Theme.surface) + } + .scrollContentBackground(.hidden) + .background(Theme.background.ignoresSafeArea()) + .navigationTitle(String(localized: "tab.settings")) + } +} + +// MARK: - Premium Feature + +private enum PremiumFeature: String, CaseIterable, Identifiable { + case cloudSync + case appleWatch + case ipadMacSync + case widgets + case siriShortcuts + + var id: String { rawValue } + + var icon: String { + switch self { + case .cloudSync: return "icloud.fill" + case .appleWatch: return "applewatch" + case .ipadMacSync: return "ipad.and.macbook" + case .widgets: return "square.grid.2x2.fill" + case .siriShortcuts: return "mic.fill" + } + } + + var titleKey: String { + switch self { + case .cloudSync: return "settings.premium.cloudSync" + case .appleWatch: return "settings.premium.appleWatch" + case .ipadMacSync: return "settings.premium.ipadMacSync" + case .widgets: return "settings.premium.widgets" + case .siriShortcuts: return "settings.premium.siriShortcuts" + } + } +} + +private struct PremiumFeatureRow: View { + let feature: PremiumFeature + + var body: some View { + HStack(spacing: 12) { + Image(systemName: feature.icon) + .font(.body) + .foregroundStyle(Theme.primaryText.opacity(0.35)) + .frame(width: 24) + + Text(String(localized: String.LocalizationValue(feature.titleKey))) + .font(.body) + .foregroundStyle(Theme.primaryText.opacity(0.35)) + + Spacer() + + Image(systemName: "lock.fill") + .font(.caption) + .foregroundStyle(Theme.yellow.opacity(0.8)) + } + .opacity(0.7) + } +} + +#Preview { + NavigationStack { + SettingsView() + } +} diff --git a/myApp/LemonLimeTracker/IOS/Views/TaskTrackerView.swift b/myApp/LemonLimeTracker/IOS/Views/TaskTrackerView.swift new file mode 100644 index 0000000..2869bd5 --- /dev/null +++ b/myApp/LemonLimeTracker/IOS/Views/TaskTrackerView.swift @@ -0,0 +1,212 @@ +import SwiftUI +import SwiftData + +struct TaskTrackerView: View { + @Environment(\.modelContext) private var modelContext + @State private var viewModel = TaskViewModel() + @State private var showAddSheet = false + + var body: some View { + ZStack { + Theme.background.ignoresSafeArea() + Group { + if viewModel.tasks.isEmpty { + emptyState + } else { + taskList + } + } + } + .navigationTitle(String(localized: "tab.tasks")) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showAddSheet = true + } label: { + Image(systemName: "plus") + .foregroundStyle(Theme.green) + } + } + } + .sheet(isPresented: $showAddSheet) { + AddTaskSheet { name, icon, type, category in + viewModel.addTask(name: name, icon: icon, type: type, category: category) + } + } + .onAppear { + viewModel.setup(context: modelContext) + } + } + + private var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.badge.plus") + .font(.system(size: 56)) + .foregroundStyle(Theme.green.opacity(0.6)) + Text(String(localized: "task.empty")) + .foregroundStyle(Theme.primaryText.opacity(0.6)) + } + } + + private var taskList: some View { + List { + ForEach(viewModel.tasks) { task in + TaskRow(task: task, viewModel: viewModel) + .listRowBackground(Theme.surface) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + viewModel.deleteTask(task) + } label: { + Label(String(localized: "task.delete"), systemImage: "trash") + } + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + if task.type == .count { + Button { + viewModel.addCount(to: task) + } label: { + Label(String(localized: "task.add.count"), systemImage: "plus") + } + .tint(Theme.green) + } + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } +} + +// MARK: - Task Row + +private struct TaskRow: View { + let task: TaskItem + let viewModel: TaskViewModel + + private var isRunning: Bool { viewModel.isTimerRunning(for: task.id) } + + var body: some View { + HStack(spacing: 12) { + Text(task.icon) + .font(.title2) + .frame(width: 36) + + VStack(alignment: .leading, spacing: 2) { + Text(task.name) + .font(.body) + .fontWeight(.medium) + .foregroundStyle(Theme.primaryText) + if let category = task.category { + Text(category.name) + .font(.caption2) + .foregroundStyle(Theme.green) + } + } + + Spacer() + + if task.type == .time { + timeControls + } else { + countBadge + } + } + .padding(.vertical, 4) + } + + private var timeControls: some View { + HStack(spacing: 8) { + if isRunning { + Text(viewModel.formattedElapsed(for: task.id)) + .font(.system(.subheadline, design: .monospaced)) + .foregroundStyle(Theme.green) + .contentTransition(.numericText()) + .animation(.linear(duration: 0.3), value: viewModel.elapsedSeconds[task.id]) + } + Button { + viewModel.toggleTimer(for: task) + } label: { + Image(systemName: isRunning ? "stop.fill" : "play.fill") + .font(.title3) + .foregroundStyle(isRunning ? Theme.yellow : Theme.green) + .frame(width: 36, height: 36) + .background( + Circle() + .fill(isRunning ? Theme.yellow.opacity(0.15) : Theme.green.opacity(0.15)) + ) + } + .buttonStyle(.plain) + } + } + + private var countBadge: some View { + let count = viewModel.todayCount(for: task) + return Text("×\(count)") + .font(.headline) + .monospacedDigit() + .foregroundStyle(count > 0 ? Theme.green : Theme.primaryText.opacity(0.3)) + } +} + +// MARK: - Add Task Sheet + +private struct AddTaskSheet: View { + @Environment(\.dismiss) private var dismiss + @Query private var categories: [Category] + + @State private var name = "" + @State private var icon = "⭐" + @State private var type: TaskType = .count + @State private var selectedCategory: Category? + + let onAdd: (String, String, TaskType, Category?) -> Void + + var body: some View { + NavigationStack { + Form { + Section { + TextField(String(localized: "task.name"), text: $name) + TextField(String(localized: "task.icon"), text: $icon) + } + Section { + Picker(String(localized: "task.type"), selection: $type) { + Text(String(localized: "task.type.count")).tag(TaskType.count) + Text(String(localized: "task.type.time")).tag(TaskType.time) + } + .pickerStyle(.segmented) + } + if !categories.isEmpty { + Section { + Picker(String(localized: "task.category"), selection: $selectedCategory) { + Text(String(localized: "task.category.none")).tag(Optional.none) + ForEach(categories) { cat in + Text(cat.name).tag(Optional(cat)) + } + } + } + } + } + .navigationTitle(String(localized: "task.add")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "button.cancel")) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "button.add")) { + let trimmed = name.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + let resolvedIcon = icon.trimmingCharacters(in: .whitespaces).isEmpty ? "⭐" : icon + onAdd(trimmed, resolvedIcon, type, selectedCategory) + dismiss() + } + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + } +} + +#Preview { + TaskTrackerView() +} diff --git a/myApp/LemonLimeTracker/LemonLimeTracker-Info.plist b/myApp/LemonLimeTracker/LemonLimeTracker-Info.plist new file mode 100644 index 0000000..c41d6c0 --- /dev/null +++ b/myApp/LemonLimeTracker/LemonLimeTracker-Info.plist @@ -0,0 +1,13 @@ + + + + + CFBundleLocalizations + + ko + en + + NSSupportsLiveActivities + + + diff --git a/myApp/LemonLimeTracker/LemonLimeTracker.xcodeproj/project.pbxproj b/myApp/LemonLimeTracker/LemonLimeTracker.xcodeproj/project.pbxproj new file mode 100644 index 0000000..01f3c85 --- /dev/null +++ b/myApp/LemonLimeTracker/LemonLimeTracker.xcodeproj/project.pbxproj @@ -0,0 +1,543 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 7280EB402FE41F11006B83D9 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7280EB3F2FE41F10006B83D9 /* WidgetKit.framework */; }; + 7280EB422FE41F11006B83D9 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7280EB412FE41F11006B83D9 /* SwiftUI.framework */; }; + 7280EB4F2FE41F12006B83D9 /* LemonLimeWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7280EB3D2FE41F10006B83D9 /* LemonLimeWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 7280EB4D2FE41F12006B83D9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7280EAF02FE40D38006B83D9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7280EB3C2FE41F10006B83D9; + remoteInfo = LemonLimeWidgetExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 7280EB542FE41F12006B83D9 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 7280EB4F2FE41F12006B83D9 /* LemonLimeWidgetExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 7280EAF82FE40D38006B83D9 /* LemonLimeTracker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LemonLimeTracker.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 7280EB1E2FE41A14006B83D9 /* LemonLimeTracker-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "LemonLimeTracker-Info.plist"; sourceTree = ""; }; + 7280EB3D2FE41F10006B83D9 /* LemonLimeWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LemonLimeWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 7280EB3F2FE41F10006B83D9 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 7280EB412FE41F11006B83D9 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 7280EB502FE41F12006B83D9 /* Exceptions for "LemonLimeWidget" folder in "LemonLimeWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 7280EB3C2FE41F10006B83D9 /* LemonLimeWidgetExtension */; + }; + 7280EB5A2FE42219006B83D9 /* Exceptions for "IOS" folder in "LemonLimeWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Core/TimerActivityAttributes.swift, + ); + target = 7280EB3C2FE41F10006B83D9 /* LemonLimeWidgetExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 7280EAFA2FE40D38006B83D9 /* IOS */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 7280EB5A2FE42219006B83D9 /* Exceptions for "IOS" folder in "LemonLimeWidgetExtension" target */, + ); + path = IOS; + sourceTree = ""; + }; + 7280EB432FE41F11006B83D9 /* LemonLimeWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 7280EB502FE41F12006B83D9 /* Exceptions for "LemonLimeWidget" folder in "LemonLimeWidgetExtension" target */, + ); + path = LemonLimeWidget; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 7280EAF52FE40D38006B83D9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7280EB3A2FE41F10006B83D9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7280EB422FE41F11006B83D9 /* SwiftUI.framework in Frameworks */, + 7280EB402FE41F11006B83D9 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 7280EAEF2FE40D38006B83D9 = { + isa = PBXGroup; + children = ( + 7280EB1E2FE41A14006B83D9 /* LemonLimeTracker-Info.plist */, + 7280EAFA2FE40D38006B83D9 /* IOS */, + 7280EB432FE41F11006B83D9 /* LemonLimeWidget */, + 7280EB3E2FE41F10006B83D9 /* Frameworks */, + 7280EAF92FE40D38006B83D9 /* Products */, + ); + sourceTree = ""; + }; + 7280EAF92FE40D38006B83D9 /* Products */ = { + isa = PBXGroup; + children = ( + 7280EAF82FE40D38006B83D9 /* LemonLimeTracker.app */, + 7280EB3D2FE41F10006B83D9 /* LemonLimeWidgetExtension.appex */, + ); + name = Products; + sourceTree = ""; + }; + 7280EB3E2FE41F10006B83D9 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 7280EB3F2FE41F10006B83D9 /* WidgetKit.framework */, + 7280EB412FE41F11006B83D9 /* SwiftUI.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 7280EAF72FE40D38006B83D9 /* LemonLimeTracker */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7280EB032FE40D39006B83D9 /* Build configuration list for PBXNativeTarget "LemonLimeTracker" */; + buildPhases = ( + 7280EAF42FE40D38006B83D9 /* Sources */, + 7280EAF52FE40D38006B83D9 /* Frameworks */, + 7280EAF62FE40D38006B83D9 /* Resources */, + 7280EB542FE41F12006B83D9 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 7280EB4E2FE41F12006B83D9 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 7280EAFA2FE40D38006B83D9 /* IOS */, + ); + name = LemonLimeTracker; + packageProductDependencies = ( + ); + productName = LemonLimeTracker; + productReference = 7280EAF82FE40D38006B83D9 /* LemonLimeTracker.app */; + productType = "com.apple.product-type.application"; + }; + 7280EB3C2FE41F10006B83D9 /* LemonLimeWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7280EB512FE41F12006B83D9 /* Build configuration list for PBXNativeTarget "LemonLimeWidgetExtension" */; + buildPhases = ( + 7280EB392FE41F10006B83D9 /* Sources */, + 7280EB3A2FE41F10006B83D9 /* Frameworks */, + 7280EB3B2FE41F10006B83D9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 7280EB432FE41F11006B83D9 /* LemonLimeWidget */, + ); + name = LemonLimeWidgetExtension; + packageProductDependencies = ( + ); + productName = LemonLimeWidgetExtension; + productReference = 7280EB3D2FE41F10006B83D9 /* LemonLimeWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 7280EAF02FE40D38006B83D9 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2650; + LastUpgradeCheck = 2650; + TargetAttributes = { + 7280EAF72FE40D38006B83D9 = { + CreatedOnToolsVersion = 26.5; + }; + 7280EB3C2FE41F10006B83D9 = { + CreatedOnToolsVersion = 26.5; + }; + }; + }; + buildConfigurationList = 7280EAF32FE40D38006B83D9 /* Build configuration list for PBXProject "LemonLimeTracker" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ko, + ); + mainGroup = 7280EAEF2FE40D38006B83D9; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 7280EAF92FE40D38006B83D9 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7280EAF72FE40D38006B83D9 /* LemonLimeTracker */, + 7280EB3C2FE41F10006B83D9 /* LemonLimeWidgetExtension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 7280EAF62FE40D38006B83D9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7280EB3B2FE41F10006B83D9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 7280EAF42FE40D38006B83D9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7280EB392FE41F10006B83D9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 7280EB4E2FE41F12006B83D9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7280EB3C2FE41F10006B83D9 /* LemonLimeWidgetExtension */; + targetProxy = 7280EB4D2FE41F12006B83D9 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 7280EB012FE40D39006B83D9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 7280EB022FE40D39006B83D9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 7280EB042FE40D39006B83D9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "LemonLimeTracker-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = ""; + INFOPLIST_KEY_LSApplicationCategoryType = ""; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yechan.LemonLimeTracker; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 7280EB052FE40D39006B83D9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "LemonLimeTracker-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = ""; + INFOPLIST_KEY_LSApplicationCategoryType = ""; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yechan.LemonLimeTracker; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 7280EB522FE41F12006B83D9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LemonLimeWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LemonLimeWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yechan.LemonLimeTracker.LemonLimeWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 7280EB532FE41F12006B83D9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LemonLimeWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LemonLimeWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yechan.LemonLimeTracker.LemonLimeWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 7280EAF32FE40D38006B83D9 /* Build configuration list for PBXProject "LemonLimeTracker" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7280EB012FE40D39006B83D9 /* Debug */, + 7280EB022FE40D39006B83D9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7280EB032FE40D39006B83D9 /* Build configuration list for PBXNativeTarget "LemonLimeTracker" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7280EB042FE40D39006B83D9 /* Debug */, + 7280EB052FE40D39006B83D9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7280EB512FE41F12006B83D9 /* Build configuration list for PBXNativeTarget "LemonLimeWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7280EB522FE41F12006B83D9 /* Debug */, + 7280EB532FE41F12006B83D9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 7280EAF02FE40D38006B83D9 /* Project object */; +} diff --git a/myApp/LemonLimeTracker/LemonLimeTracker.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/myApp/LemonLimeTracker/LemonLimeTracker.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/myApp/LemonLimeTracker/LemonLimeTracker.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/myApp/LemonLimeTracker/LemonLimeTracker.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate b/myApp/LemonLimeTracker/LemonLimeTracker.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..2853e57 Binary files /dev/null and b/myApp/LemonLimeTracker/LemonLimeTracker.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/myApp/LemonLimeTracker/LemonLimeTracker.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist b/myApp/LemonLimeTracker/LemonLimeTracker.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..cf0295c --- /dev/null +++ b/myApp/LemonLimeTracker/LemonLimeTracker.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,19 @@ + + + + + SchemeUserState + + LemonLimeTracker.xcscheme_^#shared#^_ + + orderHint + 0 + + LemonLimeWidgetExtension.xcscheme_^#shared#^_ + + orderHint + 1 + + + + diff --git a/myApp/LemonLimeTracker/LemonLimeWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/myApp/LemonLimeTracker/LemonLimeWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/myApp/LemonLimeTracker/LemonLimeWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/LemonLimeTracker/LemonLimeWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/myApp/LemonLimeTracker/LemonLimeWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/myApp/LemonLimeTracker/LemonLimeWidget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/LemonLimeTracker/LemonLimeWidget/Assets.xcassets/Contents.json b/myApp/LemonLimeTracker/LemonLimeWidget/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/myApp/LemonLimeTracker/LemonLimeWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/LemonLimeTracker/LemonLimeWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/myApp/LemonLimeTracker/LemonLimeWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/myApp/LemonLimeTracker/LemonLimeWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/LemonLimeTracker/LemonLimeWidget/Info.plist b/myApp/LemonLimeTracker/LemonLimeWidget/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/myApp/LemonLimeTracker/LemonLimeWidget/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/myApp/LemonLimeTracker/LemonLimeWidget/LemonLimeWidget.swift b/myApp/LemonLimeTracker/LemonLimeWidget/LemonLimeWidget.swift new file mode 100644 index 0000000..f5d0984 --- /dev/null +++ b/myApp/LemonLimeTracker/LemonLimeWidget/LemonLimeWidget.swift @@ -0,0 +1,84 @@ +// +// LemonLimeWidget.swift +// LemonLimeWidget +// +// Created by 송예찬 on 6/18/26. +// + +import WidgetKit +import SwiftUI + +struct Provider: TimelineProvider { + func placeholder(in context: Context) -> SimpleEntry { + SimpleEntry(date: Date(), emoji: "😀") + } + + func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { + let entry = SimpleEntry(date: Date(), emoji: "😀") + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + var entries: [SimpleEntry] = [] + + // Generate a timeline consisting of five entries an hour apart, starting from the current date. + let currentDate = Date() + for hourOffset in 0 ..< 5 { + let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! + let entry = SimpleEntry(date: entryDate, emoji: "😀") + entries.append(entry) + } + + let timeline = Timeline(entries: entries, policy: .atEnd) + completion(timeline) + } + +// func relevances() async -> WidgetRelevances { +// // Generate a list containing the contexts this widget is relevant in. +// } +} + +struct SimpleEntry: TimelineEntry { + let date: Date + let emoji: String +} + +struct LemonLimeWidgetEntryView : View { + var entry: Provider.Entry + + var body: some View { + VStack { + Text("Time:") + Text(entry.date, style: .time) + + Text("Emoji:") + Text(entry.emoji) + } + } +} + +struct LemonLimeWidget: Widget { + let kind: String = "LemonLimeWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: Provider()) { entry in + if #available(iOS 17.0, *) { + LemonLimeWidgetEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } else { + LemonLimeWidgetEntryView(entry: entry) + .padding() + .background() + } + } + .configurationDisplayName("My Widget") + .description("This is an example widget.") + } +} + +#Preview(as: .systemSmall) { + LemonLimeWidget() +} timeline: { + SimpleEntry(date: .now, emoji: "😀") + SimpleEntry(date: .now, emoji: "🤩") +} diff --git a/myApp/LemonLimeTracker/LemonLimeWidget/LemonLimeWidgetBundle.swift b/myApp/LemonLimeTracker/LemonLimeWidget/LemonLimeWidgetBundle.swift new file mode 100644 index 0000000..8b40784 --- /dev/null +++ b/myApp/LemonLimeTracker/LemonLimeWidget/LemonLimeWidgetBundle.swift @@ -0,0 +1,17 @@ +// +// LemonLimeWidgetBundle.swift +// LemonLimeWidget +// +// Created by 송예찬 on 6/18/26. +// + +import WidgetKit +import SwiftUI + +@main +struct LemonLimeWidgetBundle: WidgetBundle { + var body: some Widget { + LemonLimeWidget() + LemonLimeWidgetLiveActivity() + } +} diff --git a/myApp/LemonLimeTracker/LemonLimeWidget/LemonLimeWidgetLiveActivity.swift b/myApp/LemonLimeTracker/LemonLimeWidget/LemonLimeWidgetLiveActivity.swift new file mode 100644 index 0000000..594a7e8 --- /dev/null +++ b/myApp/LemonLimeTracker/LemonLimeWidget/LemonLimeWidgetLiveActivity.swift @@ -0,0 +1,125 @@ +// +// LemonLimeWidgetLiveActivity.swift +// LemonLimeWidget +// +// Created by 송예찬 on 6/18/26. +// + +import ActivityKit +import WidgetKit +import SwiftUI + +private let timerEndDate = Date.distantFuture + +struct LemonLimeWidgetLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: TimerActivityAttributes.self) { context in + // MARK: Lock Screen / Banner + HStack(spacing: 14) { + Image(systemName: "timer") + .font(.title2.weight(.semibold)) + .foregroundStyle(.yellow) + + VStack(alignment: .leading, spacing: 3) { + Text(context.attributes.taskName) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .lineLimit(1) + + Text(timerInterval: context.state.startDate...timerEndDate, countsDown: false) + .font(.caption.monospacedDigit()) + .foregroundStyle(.yellow) + } + + Spacer() + + Text(context.attributes.taskIcon) + .font(.largeTitle) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .activityBackgroundTint(Color(red: 0.08, green: 0.30, blue: 0.08)) + .activitySystemActionForegroundColor(.yellow) + + } dynamicIsland: { context in + DynamicIsland { + // MARK: Expanded – top row + DynamicIslandExpandedRegion(.leading) { + Label { + Text(context.attributes.taskName) + .font(.caption.weight(.semibold)) + .lineLimit(1) + } icon: { + Text(context.attributes.taskIcon) + .font(.caption) + } + .foregroundStyle(.green) + } + + DynamicIslandExpandedRegion(.trailing) { + Image(systemName: "circle.fill") + .font(.caption) + .foregroundStyle(.green) + .padding(.trailing, 4) + } + + // MARK: Expanded – center (large timer) + DynamicIslandExpandedRegion(.bottom) { + Text(timerInterval: context.state.startDate...timerEndDate, countsDown: false) + .font(.system(size: 44, weight: .bold, design: .monospaced)) + .foregroundStyle(.yellow) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 4) + } + + } compactLeading: { + Image(systemName: "timer") + .foregroundStyle(.green) + .font(.caption.weight(.semibold)) + + } compactTrailing: { + Text(timerInterval: context.state.startDate...timerEndDate, countsDown: false) + .monospacedDigit() + .font(.caption2.weight(.medium)) + .foregroundStyle(.yellow) + .frame(maxWidth: 52) + + } minimal: { + ZStack { + Circle() + .fill(LinearGradient( + colors: [.green, Color(red: 0.8, green: 0.9, blue: 0.0)], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + Text(context.attributes.taskIcon) + .font(.system(size: 11)) + } + } + .keylineTint(.green) + } + } +} + +// MARK: - Previews + +extension TimerActivityAttributes { + fileprivate static var preview: TimerActivityAttributes { + TimerActivityAttributes(taskName: "운동하기", taskIcon: "🏃") + } +} + +extension TimerActivityAttributes.ContentState { + fileprivate static var running: TimerActivityAttributes.ContentState { + TimerActivityAttributes.ContentState( + startDate: Date().addingTimeInterval(-125), + elapsedSeconds: 125 + ) + } +} + +#Preview("Notification", as: .content, using: TimerActivityAttributes.preview) { + LemonLimeWidgetLiveActivity() +} contentStates: { + TimerActivityAttributes.ContentState.running +}