restore project and add
This commit is contained in:
parent
4e4016533e
commit
479b8bab8a
@ -1 +0,0 @@
|
|||||||
Subproject commit 64afec696ed11456cd193859a3d2cc07abfc0097
|
|
||||||
9
myApp/DeepWorkTimer/CLAUDE.md
Normal file
9
myApp/DeepWorkTimer/CLAUDE.md
Normal file
@ -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.
|
||||||
526
myApp/DeepWorkTimer/DeepWorkTimer.xcodeproj/project.pbxproj
Normal file
526
myApp/DeepWorkTimer/DeepWorkTimer.xcodeproj/project.pbxproj
Normal file
@ -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 = "<group>";
|
||||||
|
};
|
||||||
|
72106C0B2FDC71B400CDE600 /* TimeWidget */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
72106C182FDC71B600CDE600 /* Exceptions for "TimeWidget" folder in "TimeWidgetExtension" target */,
|
||||||
|
);
|
||||||
|
path = TimeWidget;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 = "<group>";
|
||||||
|
};
|
||||||
|
72106BEB2FDC6B3000CDE600 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
72106BEA2FDC6B3000CDE600 /* DeepWorkTimer.app */,
|
||||||
|
72106C052FDC71B400CDE600 /* TimeWidgetExtension.appex */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
72106C062FDC71B400CDE600 /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
72106C072FDC71B400CDE600 /* WidgetKit.framework */,
|
||||||
|
72106C092FDC71B400CDE600 /* SwiftUI.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 */;
|
||||||
|
}
|
||||||
7
myApp/DeepWorkTimer/DeepWorkTimer.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
myApp/DeepWorkTimer/DeepWorkTimer.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
Binary file not shown.
@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>SchemeUserState</key>
|
||||||
|
<dict>
|
||||||
|
<key>DeepWorkTimer.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
<key>TimeWidgetExtension.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>2</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
163
myApp/DeepWorkTimer/DeepWorkTimer/ContentView.swift
Normal file
163
myApp/DeepWorkTimer/DeepWorkTimer/ContentView.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.live-activity</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
21
myApp/DeepWorkTimer/DeepWorkTimer/DeepWorkTimerApp.swift
Normal file
21
myApp/DeepWorkTimer/DeepWorkTimer/DeepWorkTimerApp.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
25
myApp/DeepWorkTimer/DeepWorkTimer/Models/FocusSession.swift
Normal file
25
myApp/DeepWorkTimer/DeepWorkTimer/Models/FocusSession.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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<Void, Never>?
|
||||||
|
// 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<TimerAttributes>?
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
169
myApp/DeepWorkTimer/DeepWorkTimer/Views/DashboardView.swift
Normal file
169
myApp/DeepWorkTimer/DeepWorkTimer/Views/DashboardView.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
11
myApp/DeepWorkTimer/TimeWidget/Info.plist
Normal file
11
myApp/DeepWorkTimer/TimeWidget/Info.plist
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.widgetkit-extension</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
84
myApp/DeepWorkTimer/TimeWidget/TimeWidget.swift
Normal file
84
myApp/DeepWorkTimer/TimeWidget/TimeWidget.swift
Normal file
@ -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<Entry>) -> ()) {
|
||||||
|
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<Void> {
|
||||||
|
// // 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: "🤩")
|
||||||
|
}
|
||||||
17
myApp/DeepWorkTimer/TimeWidget/TimeWidgetBundle.swift
Normal file
17
myApp/DeepWorkTimer/TimeWidget/TimeWidgetBundle.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
104
myApp/DeepWorkTimer/TimeWidget/TimeWidgetLiveActivity.swift
Normal file
104
myApp/DeepWorkTimer/TimeWidget/TimeWidgetLiveActivity.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
19
myApp/DeepWorkTimer/TimeWidget/TimerAttributes.swift
Normal file
19
myApp/DeepWorkTimer/TimeWidget/TimerAttributes.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
Subproject commit c66ea52abf01a4199a6c0037c7211d13983f2c80
|
|
||||||
21
myApp/LemonLimeTracker/CLAUDE.md
Normal file
21
myApp/LemonLimeTracker/CLAUDE.md
Normal file
@ -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.
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
6
myApp/LemonLimeTracker/IOS/Assets.xcassets/Contents.json
Normal file
6
myApp/LemonLimeTracker/IOS/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
24
myApp/LemonLimeTracker/IOS/ContentView.swift
Normal file
24
myApp/LemonLimeTracker/IOS/ContentView.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
21
myApp/LemonLimeTracker/IOS/Core/Theme.swift
Normal file
21
myApp/LemonLimeTracker/IOS/Core/Theme.swift
Normal file
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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
|
||||||
19
myApp/LemonLimeTracker/IOS/LemonLimeTrackerApp.swift
Normal file
19
myApp/LemonLimeTracker/IOS/LemonLimeTrackerApp.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
myApp/LemonLimeTracker/IOS/Models/Category.swift
Normal file
18
myApp/LemonLimeTracker/IOS/Models/Category.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
35
myApp/LemonLimeTracker/IOS/Models/Goal.swift
Normal file
35
myApp/LemonLimeTracker/IOS/Models/Goal.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
28
myApp/LemonLimeTracker/IOS/Models/TaskItem.swift
Normal file
28
myApp/LemonLimeTracker/IOS/Models/TaskItem.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
19
myApp/LemonLimeTracker/IOS/Models/TaskLog.swift
Normal file
19
myApp/LemonLimeTracker/IOS/Models/TaskLog.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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";
|
||||||
@ -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 및 단축어";
|
||||||
@ -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<Category>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<Goal>()
|
||||||
|
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<TaskItem>(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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
93
myApp/LemonLimeTracker/IOS/ViewModels/GoalViewModel.swift
Normal file
93
myApp/LemonLimeTracker/IOS/ViewModels/GoalViewModel.swift
Normal file
@ -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<Goal>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
157
myApp/LemonLimeTracker/IOS/ViewModels/TaskViewModel.swift
Normal file
157
myApp/LemonLimeTracker/IOS/ViewModels/TaskViewModel.swift
Normal file
@ -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<Void, Never>] = [:]
|
||||||
|
#if os(iOS)
|
||||||
|
private var liveActivities: [UUID: Activity<TimerActivityAttributes>] = [:]
|
||||||
|
#endif
|
||||||
|
|
||||||
|
func setup(context: ModelContext) {
|
||||||
|
self.context = context
|
||||||
|
fetchTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchTasks() {
|
||||||
|
guard let context else { return }
|
||||||
|
let descriptor = FetchDescriptor<TaskItem>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
177
myApp/LemonLimeTracker/IOS/Views/CategoryView.swift
Normal file
177
myApp/LemonLimeTracker/IOS/Views/CategoryView.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
160
myApp/LemonLimeTracker/IOS/Views/DashboardView.swift
Normal file
160
myApp/LemonLimeTracker/IOS/Views/DashboardView.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
306
myApp/LemonLimeTracker/IOS/Views/GoalView.swift
Normal file
306
myApp/LemonLimeTracker/IOS/Views/GoalView.swift
Normal file
@ -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<TaskItem>.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<Category>.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
22
myApp/LemonLimeTracker/IOS/Views/LaunchScreenView.swift
Normal file
22
myApp/LemonLimeTracker/IOS/Views/LaunchScreenView.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
47
myApp/LemonLimeTracker/IOS/Views/MainTabView.swift
Normal file
47
myApp/LemonLimeTracker/IOS/Views/MainTabView.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
24
myApp/LemonLimeTracker/IOS/Views/RootView.swift
Normal file
24
myApp/LemonLimeTracker/IOS/Views/RootView.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
107
myApp/LemonLimeTracker/IOS/Views/SettingsView.swift
Normal file
107
myApp/LemonLimeTracker/IOS/Views/SettingsView.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
212
myApp/LemonLimeTracker/IOS/Views/TaskTrackerView.swift
Normal file
212
myApp/LemonLimeTracker/IOS/Views/TaskTrackerView.swift
Normal file
@ -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<Category>.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()
|
||||||
|
}
|
||||||
13
myApp/LemonLimeTracker/LemonLimeTracker-Info.plist
Normal file
13
myApp/LemonLimeTracker/LemonLimeTracker-Info.plist
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleLocalizations</key>
|
||||||
|
<array>
|
||||||
|
<string>ko</string>
|
||||||
|
<string>en</string>
|
||||||
|
</array>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@ -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 = "<group>"; };
|
||||||
|
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 = "<group>";
|
||||||
|
};
|
||||||
|
7280EB432FE41F11006B83D9 /* LemonLimeWidget */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
7280EB502FE41F12006B83D9 /* Exceptions for "LemonLimeWidget" folder in "LemonLimeWidgetExtension" target */,
|
||||||
|
);
|
||||||
|
path = LemonLimeWidget;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 = "<group>";
|
||||||
|
};
|
||||||
|
7280EAF92FE40D38006B83D9 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7280EAF82FE40D38006B83D9 /* LemonLimeTracker.app */,
|
||||||
|
7280EB3D2FE41F10006B83D9 /* LemonLimeWidgetExtension.appex */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
7280EB3E2FE41F10006B83D9 /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7280EB3F2FE41F10006B83D9 /* WidgetKit.framework */,
|
||||||
|
7280EB412FE41F11006B83D9 /* SwiftUI.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 */;
|
||||||
|
}
|
||||||
7
myApp/LemonLimeTracker/LemonLimeTracker.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
myApp/LemonLimeTracker/LemonLimeTracker.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
Binary file not shown.
@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>SchemeUserState</key>
|
||||||
|
<dict>
|
||||||
|
<key>LemonLimeTracker.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
</dict>
|
||||||
|
<key>LemonLimeWidgetExtension.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
11
myApp/LemonLimeTracker/LemonLimeWidget/Info.plist
Normal file
11
myApp/LemonLimeTracker/LemonLimeWidget/Info.plist
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.widgetkit-extension</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
84
myApp/LemonLimeTracker/LemonLimeWidget/LemonLimeWidget.swift
Normal file
84
myApp/LemonLimeTracker/LemonLimeWidget/LemonLimeWidget.swift
Normal file
@ -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<Entry>) -> ()) {
|
||||||
|
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<Void> {
|
||||||
|
// // 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: "🤩")
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user