Initial commit: EzTimer iOS app with widget extension
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
68
.gitignore
vendored
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Xcode
|
||||||
|
build/
|
||||||
|
DerivedData/
|
||||||
|
*.xcodeproj/xcuserdata/
|
||||||
|
*.xcworkspace/xcuserdata/
|
||||||
|
*.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
|
||||||
|
*.pbxuser
|
||||||
|
!default.pbxuser
|
||||||
|
*.mode1v3
|
||||||
|
!default.mode1v3
|
||||||
|
*.mode2v3
|
||||||
|
!default.mode2v3
|
||||||
|
*.perspectivev3
|
||||||
|
!default.perspectivev3
|
||||||
|
xcuserdata/
|
||||||
|
|
||||||
|
# CocoaPods
|
||||||
|
Pods/
|
||||||
|
Podfile.lock
|
||||||
|
|
||||||
|
# Carthage
|
||||||
|
Carthage/Build/
|
||||||
|
Carthage/Checkouts/
|
||||||
|
|
||||||
|
# Swift Package Manager
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
Package.resolved
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Archives
|
||||||
|
*.xcarchive
|
||||||
|
|
||||||
|
# Playgrounds
|
||||||
|
timeline.xctimeline
|
||||||
|
playground.xcworkspace
|
||||||
|
|
||||||
|
# Node (if applicable)
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
*.ipa
|
||||||
|
*.dSYM.zip
|
||||||
|
*.dSYM
|
||||||
|
|
||||||
|
# Fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots/**/*.png
|
||||||
|
fastlane/test_output
|
||||||
|
|
||||||
|
# Code Injection
|
||||||
|
iOSInjectionProject/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
574
EzTimer.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
7FD67F072EF383EB007DDD4E /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7FD67F062EF383EB007DDD4E /* WidgetKit.framework */; };
|
||||||
|
7FD67F092EF383EB007DDD4E /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7FD67F082EF383EB007DDD4E /* SwiftUI.framework */; };
|
||||||
|
7FD67F1A2EF383EC007DDD4E /* EzTimerWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7FD67F042EF383EB007DDD4E /* EzTimerWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
7FD67F182EF383EC007DDD4E /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 7F0269962EF236FB0063309D /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 7FD67F032EF383EB007DDD4E;
|
||||||
|
remoteInfo = EzTimerWidgetExtension;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
7FD67F1B2EF383EC007DDD4E /* Embed Foundation Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
7FD67F1A2EF383EC007DDD4E /* EzTimerWidgetExtension.appex in Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed Foundation Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
7F02699E2EF236FB0063309D /* EzTimer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EzTimer.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
7FD67F042EF383EB007DDD4E /* EzTimerWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = EzTimerWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
7FD67F062EF383EB007DDD4E /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||||
|
7FD67F082EF383EB007DDD4E /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
7FD67F1E2EF383EC007DDD4E /* Exceptions for "EzTimerWidget" folder in "EzTimerWidgetExtension" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = 7FD67F032EF383EB007DDD4E /* EzTimerWidgetExtension */;
|
||||||
|
};
|
||||||
|
7FD67F212EF38416007DDD4E /* Exceptions for "EzTimer" folder in "EzTimer" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = 7F02699D2EF236FB0063309D /* EzTimer */;
|
||||||
|
};
|
||||||
|
7FD67F232EF3848A007DDD4E /* Exceptions for "EzTimer" folder in "EzTimerWidgetExtension" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
TimerAttributes.swift,
|
||||||
|
);
|
||||||
|
target = 7FD67F032EF383EB007DDD4E /* EzTimerWidgetExtension */;
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
7F0269A02EF236FB0063309D /* EzTimer */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
7FD67F212EF38416007DDD4E /* Exceptions for "EzTimer" folder in "EzTimer" target */,
|
||||||
|
7FD67F232EF3848A007DDD4E /* Exceptions for "EzTimer" folder in "EzTimerWidgetExtension" target */,
|
||||||
|
);
|
||||||
|
path = EzTimer;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
7FD67F0A2EF383EB007DDD4E /* EzTimerWidget */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
7FD67F1E2EF383EC007DDD4E /* Exceptions for "EzTimerWidget" folder in "EzTimerWidgetExtension" target */,
|
||||||
|
);
|
||||||
|
path = EzTimerWidget;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
7F02699B2EF236FB0063309D /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
7FD67F012EF383EB007DDD4E /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
7FD67F092EF383EB007DDD4E /* SwiftUI.framework in Frameworks */,
|
||||||
|
7FD67F072EF383EB007DDD4E /* WidgetKit.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
7F0269952EF236FB0063309D = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7F0269A02EF236FB0063309D /* EzTimer */,
|
||||||
|
7FD67F0A2EF383EB007DDD4E /* EzTimerWidget */,
|
||||||
|
7FD67F052EF383EB007DDD4E /* Frameworks */,
|
||||||
|
7F02699F2EF236FB0063309D /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
7F02699F2EF236FB0063309D /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7F02699E2EF236FB0063309D /* EzTimer.app */,
|
||||||
|
7FD67F042EF383EB007DDD4E /* EzTimerWidgetExtension.appex */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
7FD67F052EF383EB007DDD4E /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7FD67F062EF383EB007DDD4E /* WidgetKit.framework */,
|
||||||
|
7FD67F082EF383EB007DDD4E /* SwiftUI.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
7F02699D2EF236FB0063309D /* EzTimer */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 7F0269A92EF236FC0063309D /* Build configuration list for PBXNativeTarget "EzTimer" */;
|
||||||
|
buildPhases = (
|
||||||
|
7F02699A2EF236FB0063309D /* Sources */,
|
||||||
|
7F02699B2EF236FB0063309D /* Frameworks */,
|
||||||
|
7F02699C2EF236FB0063309D /* Resources */,
|
||||||
|
7FD67F1B2EF383EC007DDD4E /* Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
7FD67F192EF383EC007DDD4E /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
7F0269A02EF236FB0063309D /* EzTimer */,
|
||||||
|
);
|
||||||
|
name = EzTimer;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = EzTimer;
|
||||||
|
productReference = 7F02699E2EF236FB0063309D /* EzTimer.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
7FD67F032EF383EB007DDD4E /* EzTimerWidgetExtension */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 7FD67F1F2EF383EC007DDD4E /* Build configuration list for PBXNativeTarget "EzTimerWidgetExtension" */;
|
||||||
|
buildPhases = (
|
||||||
|
7FD67F002EF383EB007DDD4E /* Sources */,
|
||||||
|
7FD67F012EF383EB007DDD4E /* Frameworks */,
|
||||||
|
7FD67F022EF383EB007DDD4E /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
7FD67F0A2EF383EB007DDD4E /* EzTimerWidget */,
|
||||||
|
);
|
||||||
|
name = EzTimerWidgetExtension;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = EzTimerWidgetExtension;
|
||||||
|
productReference = 7FD67F042EF383EB007DDD4E /* EzTimerWidgetExtension.appex */;
|
||||||
|
productType = "com.apple.product-type.app-extension";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
7F0269962EF236FB0063309D /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 2620;
|
||||||
|
LastUpgradeCheck = 2620;
|
||||||
|
TargetAttributes = {
|
||||||
|
7F02699D2EF236FB0063309D = {
|
||||||
|
CreatedOnToolsVersion = 26.1.1;
|
||||||
|
};
|
||||||
|
7FD67F032EF383EB007DDD4E = {
|
||||||
|
CreatedOnToolsVersion = 26.2;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 7F0269992EF236FB0063309D /* Build configuration list for PBXProject "EzTimer" */;
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 7F0269952EF236FB0063309D;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
productRefGroup = 7F02699F2EF236FB0063309D /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
7F02699D2EF236FB0063309D /* EzTimer */,
|
||||||
|
7FD67F032EF383EB007DDD4E /* EzTimerWidgetExtension */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
7F02699C2EF236FB0063309D /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
7FD67F022EF383EB007DDD4E /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
7F02699A2EF236FB0063309D /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
7FD67F002EF383EB007DDD4E /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
7FD67F192EF383EC007DDD4E /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 7FD67F032EF383EB007DDD4E /* EzTimerWidgetExtension */;
|
||||||
|
targetProxy = 7FD67F182EF383EC007DDD4E /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
7F0269A72EF236FC0063309D /* 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;
|
||||||
|
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||||
|
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.1;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
7F0269A82EF236FC0063309D /* 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";
|
||||||
|
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||||
|
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.1;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
7F0269AA2EF236FC0063309D /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconV2;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = EzTimer/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = VizTimer;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
|
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";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 2.3;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.EzTimer;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
7F0269AB2EF236FC0063309D /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconV2;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = EzTimer/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = VizTimer;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
|
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";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 2.3;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.EzTimer;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
7FD67F1C2EF383EC007DDD4E /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconV2;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = EzTimerWidget/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = EzTimerWidget;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 2.3;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.EzTimer.EzTimerWidget;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
7FD67F1D2EF383EC007DDD4E /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconV2;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = EzTimerWidget/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = EzTimerWidget;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 2.3;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.EzTimer.EzTimerWidget;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
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 */
|
||||||
|
7F0269992EF236FB0063309D /* Build configuration list for PBXProject "EzTimer" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
7F0269A72EF236FC0063309D /* Debug */,
|
||||||
|
7F0269A82EF236FC0063309D /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
7F0269A92EF236FC0063309D /* Build configuration list for PBXNativeTarget "EzTimer" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
7F0269AA2EF236FC0063309D /* Debug */,
|
||||||
|
7F0269AB2EF236FC0063309D /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
7FD67F1F2EF383EC007DDD4E /* Build configuration list for PBXNativeTarget "EzTimerWidgetExtension" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
7FD67F1C2EF383EC007DDD4E /* Debug */,
|
||||||
|
7FD67F1D2EF383EC007DDD4E /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 7F0269962EF236FB0063309D /* Project object */;
|
||||||
|
}
|
||||||
7
EzTimer.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>
|
||||||
78
EzTimer.xcodeproj/xcshareddata/xcschemes/EzTimer.xcscheme
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2620"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "7F02699D2EF236FB0063309D"
|
||||||
|
BuildableName = "EzTimer.app"
|
||||||
|
BlueprintName = "EzTimer"
|
||||||
|
ReferencedContainer = "container:EzTimer.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "7F02699D2EF236FB0063309D"
|
||||||
|
BuildableName = "EzTimer.app"
|
||||||
|
BlueprintName = "EzTimer"
|
||||||
|
ReferencedContainer = "container:EzTimer.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "7F02699D2EF236FB0063309D"
|
||||||
|
BuildableName = "EzTimer.app"
|
||||||
|
BlueprintName = "EzTimer"
|
||||||
|
ReferencedContainer = "container:EzTimer.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2620"
|
||||||
|
wasCreatedForAppExtension = "YES"
|
||||||
|
version = "2.0">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "7FD67F032EF383EB007DDD4E"
|
||||||
|
BuildableName = "EzTimerWidgetExtension.appex"
|
||||||
|
BlueprintName = "EzTimerWidgetExtension"
|
||||||
|
ReferencedContainer = "container:EzTimer.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "7F02699D2EF236FB0063309D"
|
||||||
|
BuildableName = "EzTimer.app"
|
||||||
|
BlueprintName = "EzTimer"
|
||||||
|
ReferencedContainer = "container:EzTimer.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = ""
|
||||||
|
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||||
|
launchStyle = "0"
|
||||||
|
askForAppToLaunch = "Yes"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES"
|
||||||
|
launchAutomaticallySubstyle = "2">
|
||||||
|
<RemoteRunnable
|
||||||
|
runnableDebuggingMode = "2"
|
||||||
|
BundleIdentifier = "com.apple.springboard">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "7FD67F032EF383EB007DDD4E"
|
||||||
|
BuildableName = "EzTimerWidgetExtension.appex"
|
||||||
|
BlueprintName = "EzTimerWidgetExtension"
|
||||||
|
ReferencedContainer = "container:EzTimer.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</RemoteRunnable>
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "7F02699D2EF236FB0063309D"
|
||||||
|
BuildableName = "EzTimer.app"
|
||||||
|
BlueprintName = "EzTimer"
|
||||||
|
ReferencedContainer = "container:EzTimer.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "_XCWidgetKind"
|
||||||
|
value = ""
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "_XCWidgetDefaultView"
|
||||||
|
value = "timeline"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "_XCWidgetFamily"
|
||||||
|
value = "systemMedium"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
askForAppToLaunch = "Yes"
|
||||||
|
launchAutomaticallySubstyle = "2">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "7F02699D2EF236FB0063309D"
|
||||||
|
BuildableName = "EzTimer.app"
|
||||||
|
BlueprintName = "EzTimer"
|
||||||
|
ReferencedContainer = "container:EzTimer.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
11
EzTimer/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
EzTimer/Assets.xcassets/AppIconV2.appiconset/100.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
EzTimer/Assets.xcassets/AppIconV2.appiconset/1024.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
EzTimer/Assets.xcassets/AppIconV2.appiconset/114.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
EzTimer/Assets.xcassets/AppIconV2.appiconset/120.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
EzTimer/Assets.xcassets/AppIconV2.appiconset/144.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
EzTimer/Assets.xcassets/AppIconV2.appiconset/152.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
EzTimer/Assets.xcassets/AppIconV2.appiconset/167.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
EzTimer/Assets.xcassets/AppIconV2.appiconset/180.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
EzTimer/Assets.xcassets/AppIconV2.appiconset/20.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
EzTimer/Assets.xcassets/AppIconV2.appiconset/29.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
EzTimer/Assets.xcassets/AppIconV2.appiconset/40.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
EzTimer/Assets.xcassets/AppIconV2.appiconset/50.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
EzTimer/Assets.xcassets/AppIconV2.appiconset/57.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
EzTimer/Assets.xcassets/AppIconV2.appiconset/58.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
EzTimer/Assets.xcassets/AppIconV2.appiconset/60.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
EzTimer/Assets.xcassets/AppIconV2.appiconset/72.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
EzTimer/Assets.xcassets/AppIconV2.appiconset/76.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
EzTimer/Assets.xcassets/AppIconV2.appiconset/80.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
EzTimer/Assets.xcassets/AppIconV2.appiconset/87.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
|||||||
|
{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","idiom":"ipad","scale":"2x"}]}
|
||||||
6
EzTimer/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
132
EzTimer/ContentView.swift
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
//
|
||||||
|
// ContentView.swift
|
||||||
|
// EzTimer
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 12/16/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@StateObject private var viewModel = TimerViewModel()
|
||||||
|
|
||||||
|
// Navigation State
|
||||||
|
@State private var selectedTab = 0
|
||||||
|
|
||||||
|
// Renaming state (Keep here to share via binding or handle alert)
|
||||||
|
@State private var renamingIndex: Int?
|
||||||
|
@State private var renamingText = ""
|
||||||
|
@State private var isRenaming = false
|
||||||
|
|
||||||
|
// Alarm State
|
||||||
|
@State private var flashOpacity = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// 1. Background
|
||||||
|
MeshBackground()
|
||||||
|
|
||||||
|
// 2. Main Content
|
||||||
|
TimerView(viewModel: viewModel)
|
||||||
|
|
||||||
|
// 3. Visual Alarm Overlay (Absolute top priority)
|
||||||
|
if viewModel.isTimerFinished {
|
||||||
|
AlarmOverlay(viewModel: viewModel, flashOpacity: $flashOpacity)
|
||||||
|
.zIndex(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.preferredColorScheme(.dark) // Enforce dark mode for the 'neon/glass' aesthetic
|
||||||
|
.onAppear {
|
||||||
|
viewModel.requestPermissions()
|
||||||
|
UIApplication.shared.isIdleTimerDisabled = true
|
||||||
|
}
|
||||||
|
.alert("Rename Button", isPresented: $isRenaming) {
|
||||||
|
TextField("Button Label", text: $renamingText)
|
||||||
|
Button("Save") {
|
||||||
|
if let index = renamingIndex {
|
||||||
|
viewModel.updateCustomLabel(at: index, with: renamingText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Extracted Alarm Overlay
|
||||||
|
struct AlarmOverlay: View {
|
||||||
|
@ObservedObject var viewModel: TimerViewModel
|
||||||
|
@Binding var flashOpacity: Bool
|
||||||
|
|
||||||
|
// Flash timing constant for rapid strobe mode
|
||||||
|
private let flashIntervalSeconds: Double = 0.08 // 80ms strobe speed
|
||||||
|
|
||||||
|
@State private var flashTimer: Timer?
|
||||||
|
@State private var softFlashOpacity: Double = 0.0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
// Use different opacity source based on mode
|
||||||
|
Color(viewModel.selectedColor.color)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.opacity(viewModel.selectedFlashMode == .soft ? softFlashOpacity : (flashOpacity ? 1.0 : 0.0))
|
||||||
|
.onAppear {
|
||||||
|
startFlashing()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
stopFlashing()
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image(systemName: "alarm.fill")
|
||||||
|
.font(.system(size: 80))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.symbolEffect(.bounce.byLayer, options: .repeating)
|
||||||
|
|
||||||
|
Text(viewModel.notificationMessage.isEmpty ? "Timer Finished!" : viewModel.notificationMessage)
|
||||||
|
.font(.system(size: 60, weight: .heavy))
|
||||||
|
.minimumScaleFactor(0.5)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding()
|
||||||
|
.shadow(color: .black, radius: 2, x: 1, y: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
stopFlashing()
|
||||||
|
viewModel.isTimerFinished = false
|
||||||
|
// Visual alarm dismissed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Flash Timer Methods
|
||||||
|
|
||||||
|
private func startFlashing() {
|
||||||
|
switch viewModel.selectedFlashMode {
|
||||||
|
case .soft:
|
||||||
|
// Soft mode: continuous smooth fading animation
|
||||||
|
withAnimation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true)) {
|
||||||
|
softFlashOpacity = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
case .rapid:
|
||||||
|
// Rapid strobe mode: toggle flash every 80ms
|
||||||
|
flashTimer = Timer.scheduledTimer(withTimeInterval: flashIntervalSeconds, repeats: true) { _ in
|
||||||
|
flashOpacity.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopFlashing() {
|
||||||
|
flashTimer?.invalidate()
|
||||||
|
flashTimer = nil
|
||||||
|
flashOpacity = false
|
||||||
|
softFlashOpacity = 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
111
EzTimer/DesignSystem.swift
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
//
|
||||||
|
// DesignSystem.swift
|
||||||
|
// EzTimer
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 12/17/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Colors & Gradients
|
||||||
|
extension Color {
|
||||||
|
static let liquidBackground = Color(red: 0.1, green: 0.1, blue: 0.2) // Deep dark base
|
||||||
|
static let glassBorder = LinearGradient(
|
||||||
|
colors: [.white.opacity(0.5), .white.opacity(0.1)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
static let neonBlue = Color(red: 0.0, green: 0.8, blue: 1.0)
|
||||||
|
static let neonOrange = Color(red: 1.0, green: 0.6, blue: 0.2)
|
||||||
|
static let neonRed = Color(red: 1.0, green: 0.2, blue: 0.3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mesh Background
|
||||||
|
struct MeshBackground: View {
|
||||||
|
@State private var animate = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.liquidBackground.ignoresSafeArea()
|
||||||
|
|
||||||
|
// blob 1
|
||||||
|
Circle()
|
||||||
|
.fill(Color.blue.opacity(0.4))
|
||||||
|
.frame(width: 300, height: 300)
|
||||||
|
.blur(radius: 80)
|
||||||
|
.offset(x: animate ? -100 : 100, y: animate ? -100 : 50)
|
||||||
|
|
||||||
|
// blob 2
|
||||||
|
Circle()
|
||||||
|
.fill(Color.purple.opacity(0.4))
|
||||||
|
.frame(width: 300, height: 300)
|
||||||
|
.blur(radius: 80)
|
||||||
|
.offset(x: animate ? 100 : -100, y: animate ? 100 : -50)
|
||||||
|
|
||||||
|
// blob 3
|
||||||
|
Circle()
|
||||||
|
.fill(Color.cyan.opacity(0.3))
|
||||||
|
.frame(width: 250, height: 250)
|
||||||
|
.blur(radius: 70)
|
||||||
|
.offset(y: animate ? 150 : -150)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.easeInOut(duration: 10).repeatForever(autoreverses: true)) {
|
||||||
|
animate.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Liquid Glass Modifier
|
||||||
|
struct LiquidGlass: ViewModifier {
|
||||||
|
var cornerRadius: CGFloat
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
.cornerRadius(cornerRadius)
|
||||||
|
.shadow(color: Color.black.opacity(0.2), radius: 10, x: 0, y: 5)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
|
.stroke(Color.glassBorder, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func liquidGlass(cornerRadius: CGFloat = 20) -> some View {
|
||||||
|
self.modifier(LiquidGlass(cornerRadius: cornerRadius))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Glow Effect
|
||||||
|
struct GlowEffect: ViewModifier {
|
||||||
|
var color: Color
|
||||||
|
var radius: CGFloat
|
||||||
|
var isActive: Bool
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.shadow(color: isActive ? color.opacity(0.6) : .clear, radius: radius, x: 0, y: 0)
|
||||||
|
.shadow(color: isActive ? color.opacity(0.4) : .clear, radius: radius * 1.5, x: 0, y: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func glow(color: Color = .blue, radius: CGFloat = 10, isActive: Bool = true) -> some View {
|
||||||
|
self.modifier(GlowEffect(color: color, radius: radius, isActive: isActive))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Typography
|
||||||
|
extension Font {
|
||||||
|
static var timerDisplay: Font {
|
||||||
|
.system(size: 80, weight: .thin, design: .rounded)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var displayLabel: Font {
|
||||||
|
.system(size: 16, weight: .medium, design: .rounded)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
EzTimer/EzTimerApp.swift
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// EzTimerApp.swift
|
||||||
|
// EzTimer
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 12/16/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct EzTimerApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
EzTimer/Info.plist
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?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>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>We use the camera flash to alert you when the timer finishes.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
71
EzTimer/LiveActivityManager.swift
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
//
|
||||||
|
// LiveActivityManager.swift
|
||||||
|
// EzTimer
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 12/17/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ActivityKit
|
||||||
|
|
||||||
|
class LiveActivityManager {
|
||||||
|
static let shared = LiveActivityManager()
|
||||||
|
|
||||||
|
private var activity: Activity<TimerAttributes>?
|
||||||
|
|
||||||
|
func start(endTime: Date, duration: TimeInterval, message: String) {
|
||||||
|
// Ensure Live Activities are supported and enabled
|
||||||
|
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||||
|
print("Live Activities are not enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let attributes = TimerAttributes(timerName: "VizTimer")
|
||||||
|
let contentState = TimerAttributes.ContentState(endTime: endTime, duration: duration, message: message, isFinished: false)
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Note: staleDate is nil, meaning the activity content doesn't become stale at a specific time
|
||||||
|
// relevanceScore is standard default
|
||||||
|
let content = ActivityContent(state: contentState, staleDate: nil)
|
||||||
|
|
||||||
|
activity = try Activity.request(attributes: attributes, content: content)
|
||||||
|
print("Live Activity started with ID: \(activity?.id ?? "unknown")")
|
||||||
|
} catch {
|
||||||
|
print("Error starting Live Activity: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(endTime: Date, duration: TimeInterval, message: String) {
|
||||||
|
guard let activity = activity else { return }
|
||||||
|
|
||||||
|
let contentState = TimerAttributes.ContentState(endTime: endTime, duration: duration, message: message, isFinished: false)
|
||||||
|
let content = ActivityContent(state: contentState, staleDate: nil)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await activity.update(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish(message: String) {
|
||||||
|
guard let activity = activity else { return }
|
||||||
|
|
||||||
|
let contentState = TimerAttributes.ContentState(endTime: Date(), duration: 0, message: message, isFinished: true)
|
||||||
|
let content = ActivityContent(state: contentState, staleDate: nil)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
// End with content, but dismissal policy default (keeps it on lock screen)
|
||||||
|
await activity.end(content, dismissalPolicy: .default)
|
||||||
|
self.activity = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func end() {
|
||||||
|
guard let activity = activity else { return }
|
||||||
|
|
||||||
|
Task {
|
||||||
|
// End immediately
|
||||||
|
await activity.end(nil, dismissalPolicy: .immediate)
|
||||||
|
self.activity = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
EzTimer/TimerAttributes.swift
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// TimerAttributes.swift
|
||||||
|
// EzTimer
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 12/17/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import ActivityKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct TimerAttributes: ActivityAttributes {
|
||||||
|
public struct ContentState: Codable, Hashable {
|
||||||
|
// dynamic state variables
|
||||||
|
var endTime: Date
|
||||||
|
var duration: TimeInterval
|
||||||
|
var message: String
|
||||||
|
var isFinished: Bool = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixed state variables
|
||||||
|
var timerName: String
|
||||||
|
}
|
||||||
315
EzTimer/TimerView.swift
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
//
|
||||||
|
// TimerView.swift
|
||||||
|
// EzTimer
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 12/17/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TimerView: View {
|
||||||
|
@ObservedObject var viewModel: TimerViewModel
|
||||||
|
@State private var selectedPresets: Set<Int> = []
|
||||||
|
@State private var selectedLabelIndex: Int?
|
||||||
|
|
||||||
|
// For handling renaming (local state moved from ContentView)
|
||||||
|
@State private var isRenaming = false
|
||||||
|
@State private var renamingIndex: Int?
|
||||||
|
@State private var renamingText = ""
|
||||||
|
|
||||||
|
let presetTimes = [1, 2, 5, 10, 15, 20, 25, 30, 40, 45, 50, 60]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
timerControls
|
||||||
|
messageInput
|
||||||
|
presetsGrid
|
||||||
|
colorSelectionRow
|
||||||
|
customMessagesGrid
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.onAppear {
|
||||||
|
selectedPresets.removeAll()
|
||||||
|
selectedLabelIndex = nil
|
||||||
|
}
|
||||||
|
.alert("Rename Button", isPresented: $isRenaming) {
|
||||||
|
TextField("Button Label", text: $renamingText)
|
||||||
|
Button("Save") {
|
||||||
|
if let index = renamingIndex {
|
||||||
|
viewModel.updateCustomLabel(at: index, with: renamingText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subviews
|
||||||
|
|
||||||
|
private var timerControls: some View {
|
||||||
|
GlassCard {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
// Minutes Adjustment
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
AdjustmentButton(icon: "chevron.up") { viewModel.adjustTime(bySeconds: 60) }
|
||||||
|
AdjustmentButton(icon: "chevron.down") { viewModel.adjustTime(bySeconds: -60) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time Display (Compact)
|
||||||
|
Text(viewModel.timeString())
|
||||||
|
.font(.system(size: 96, weight: .regular, design: .rounded))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.contentTransition(.numericText(value: Double(Int(ceil(viewModel.timeRemaining)))))
|
||||||
|
.minimumScaleFactor(0.5)
|
||||||
|
.lineLimit(1)
|
||||||
|
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
|
||||||
|
|
||||||
|
// Seconds Adjustment
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
AdjustmentButton(icon: "chevron.up") { viewModel.adjustTime(bySeconds: 1) }
|
||||||
|
AdjustmentButton(icon: "chevron.down") { viewModel.adjustTime(bySeconds: -1) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controls Row
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
ModernButton(title: "Reset", icon: "arrow.counterclockwise", action: {
|
||||||
|
withAnimation { viewModel.resetTimer() }
|
||||||
|
selectedPresets.removeAll() // Clear selection on reset
|
||||||
|
selectedLabelIndex = nil // Clear custom message selection
|
||||||
|
viewModel.notificationMessage = "" // Clear message text
|
||||||
|
}, backgroundColor: .neonOrange)
|
||||||
|
|
||||||
|
ModernButton(title: "Start", icon: viewModel.isActive ? "pause.fill" : "play.fill", action: {
|
||||||
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||||
|
withAnimation { viewModel.startCountdown() }
|
||||||
|
}, backgroundColor: viewModel.isActive ? .gray : .neonBlue)
|
||||||
|
.disabled(viewModel.isActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.glow(color: viewModel.isActive ? .neonBlue : .clear, isActive: viewModel.isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var messageInput: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "pencil.and.scribble")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
TextField("Timer message (optional)", text: $viewModel.notificationMessage)
|
||||||
|
.font(.subheadline)
|
||||||
|
|
||||||
|
if !viewModel.notificationMessage.isEmpty {
|
||||||
|
Button(action: {
|
||||||
|
viewModel.notificationMessage = ""
|
||||||
|
selectedLabelIndex = nil
|
||||||
|
}) {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.frame(height: 20)
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
withAnimation {
|
||||||
|
viewModel.isFlashEnabled.toggle()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Image(systemName: viewModel.isFlashEnabled ? "lightbulb.fill" : "lightbulb")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundColor(viewModel.isFlashEnabled ? .yellow : .secondary)
|
||||||
|
.padding(8)
|
||||||
|
.background(viewModel.isFlashEnabled ? Color.yellow.opacity(0.2) : Color.clear)
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 0)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.liquidGlass(cornerRadius: 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var presetsGrid: some View {
|
||||||
|
GlassCard {
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 70))], spacing: 12) {
|
||||||
|
ForEach(presetTimes, id: \.self) { minutes in
|
||||||
|
presetButtonLabel(minutes: minutes)
|
||||||
|
.onTapGesture {
|
||||||
|
selectedPresets = [minutes]
|
||||||
|
withAnimation { viewModel.setTime(minutes: minutes) }
|
||||||
|
}
|
||||||
|
.onLongPressGesture(minimumDuration: 0.5) {
|
||||||
|
let generator = UIImpactFeedbackGenerator(style: .medium)
|
||||||
|
generator.impactOccurred()
|
||||||
|
selectedPresets.insert(minutes)
|
||||||
|
withAnimation { viewModel.addTime(minutes: minutes) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isActive)
|
||||||
|
.opacity(viewModel.isActive ? 0.1 : 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presetButtonLabel(minutes: Int) -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Text("\(minutes)")
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
Text("min")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 50)
|
||||||
|
.background(selectedPresets.contains(minutes) ? Color.blue.opacity(0.6) : Color.blue.opacity(0.1))
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(selectedPresets.contains(minutes) ? Color.white.opacity(0.6) : Color.white.opacity(0.2), lineWidth: 1.5)
|
||||||
|
)
|
||||||
|
.shadow(color: selectedPresets.contains(minutes) ? Color.blue.opacity(0.5) : Color.clear, radius: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Color Selection Row
|
||||||
|
private var colorSelectionRow: some View {
|
||||||
|
GlassCard {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Color circles
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(TimerViewModel.TimerColor.allCases, id: \.self) { colorOption in
|
||||||
|
Circle()
|
||||||
|
.fill(colorOption.color)
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.white, lineWidth: viewModel.selectedColor == colorOption ? 3 : 0)
|
||||||
|
)
|
||||||
|
.shadow(color: colorOption.color.opacity(0.6), radius: viewModel.selectedColor == colorOption ? 8 : 2)
|
||||||
|
.scaleEffect(viewModel.selectedColor == colorOption ? 1.1 : 1.0)
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
|
||||||
|
viewModel.selectedColor = colorOption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.frame(height: 24)
|
||||||
|
|
||||||
|
// Flash mode icons
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
// Soft fade icon
|
||||||
|
flashModeButton(mode: .soft)
|
||||||
|
|
||||||
|
// Rapid strobe icon
|
||||||
|
flashModeButton(mode: .rapid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isActive)
|
||||||
|
.opacity(viewModel.isActive ? 0.1 : 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func flashModeButton(mode: TimerViewModel.FlashMode) -> some View {
|
||||||
|
let isSelected = viewModel.selectedFlashMode == mode
|
||||||
|
return Button(action: {
|
||||||
|
viewModel.selectedFlashMode = mode
|
||||||
|
}) {
|
||||||
|
Image(systemName: mode.iconName)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(isSelected ? .white : .secondary)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(isSelected ? Color.blue.opacity(0.6) : Color.clear)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(isSelected ? Color.white : Color.white.opacity(0.2), lineWidth: isSelected ? 2 : 1)
|
||||||
|
)
|
||||||
|
.shadow(color: isSelected ? Color.blue.opacity(0.5) : Color.clear, radius: 4)
|
||||||
|
.contentTransition(.identity)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var customMessagesGrid: some View {
|
||||||
|
GlassCard {
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 12) {
|
||||||
|
ForEach(0..<4, id: \.self) { index in
|
||||||
|
let label = viewModel.customLabels[index]
|
||||||
|
customMessageButtonLabel(label: label, index: index)
|
||||||
|
.onTapGesture {
|
||||||
|
if label.isEmpty {
|
||||||
|
startRenaming(at: index)
|
||||||
|
} else {
|
||||||
|
selectedLabelIndex = index
|
||||||
|
viewModel.notificationMessage = label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onLongPressGesture(minimumDuration: 0.5) {
|
||||||
|
startRenaming(at: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isActive)
|
||||||
|
.opacity(viewModel.isActive ? 0.1 : 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func customMessageButtonLabel(label: String, index: Int) -> some View {
|
||||||
|
HStack {
|
||||||
|
if label.isEmpty {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
} else {
|
||||||
|
Text(label)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(selectedLabelIndex == index ? Color.indigo.opacity(0.6) : Color.indigo.opacity(0.2))
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
.cornerRadius(16)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.stroke(selectedLabelIndex == index ? Color.white.opacity(0.6) : Color.white.opacity(0.2), lineWidth: 1.5)
|
||||||
|
)
|
||||||
|
.shadow(color: selectedLabelIndex == index ? Color.indigo.opacity(0.5) : Color.clear, radius: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startRenaming(at index: Int) {
|
||||||
|
renamingIndex = index
|
||||||
|
renamingText = viewModel.customLabels[index]
|
||||||
|
isRenaming = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AdjustmentButton: View {
|
||||||
|
let icon: String
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: {
|
||||||
|
withAnimation { action() }
|
||||||
|
}) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.title3)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1))
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
369
EzTimer/TimerViewModel.swift
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
//
|
||||||
|
// TimerViewModel.swift
|
||||||
|
// EzTimer
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 12/16/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
import UserNotifications
|
||||||
|
import AudioToolbox
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
class TimerViewModel: ObservableObject {
|
||||||
|
@Published var timeRemaining: TimeInterval = 0
|
||||||
|
@Published var isActive = false
|
||||||
|
@Published var notificationMessage = ""
|
||||||
|
@Published var customLabels: [String] = []
|
||||||
|
|
||||||
|
@Published var isTimerFinished = false
|
||||||
|
@Published var isFlashEnabled = false
|
||||||
|
|
||||||
|
private var timerSubscription: AnyCancellable?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var endTime: Date?
|
||||||
|
private var initialDuration: TimeInterval = 0 // Track the initial set time
|
||||||
|
private var adjustmentDebounceTimer: Timer?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Load custom labels or default to 4 empty strings
|
||||||
|
if let saved = UserDefaults.standard.stringArray(forKey: "customLabels") {
|
||||||
|
if saved.count >= 4 {
|
||||||
|
customLabels = Array(saved.prefix(4))
|
||||||
|
} else {
|
||||||
|
// Fallback reset if weird count (less than 4)
|
||||||
|
customLabels = ["", "", "", ""]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
customLabels = ["", "", "", ""]
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSettings()
|
||||||
|
|
||||||
|
// Observe changes to notificationMessage and update notification if timer is active
|
||||||
|
$notificationMessage
|
||||||
|
.dropFirst() // Skip initial value
|
||||||
|
.sink { [weak self] newMessage in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if self.isActive && self.timeRemaining > 0, let endTime = self.endTime {
|
||||||
|
// Cancel existing notifications and reschedule with new message
|
||||||
|
self.cancelNotification()
|
||||||
|
self.scheduleNotification(message: newMessage)
|
||||||
|
|
||||||
|
// Update Live Activity with new message
|
||||||
|
LiveActivityManager.shared.update(endTime: endTime, duration: self.timeRemaining, message: newMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a custom label and save to persistence
|
||||||
|
func updateCustomLabel(at index: Int, with text: String) {
|
||||||
|
guard index >= 0 && index < customLabels.count else { return }
|
||||||
|
customLabels[index] = text
|
||||||
|
UserDefaults.standard.set(customLabels, forKey: "customLabels")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Color Selection
|
||||||
|
enum TimerColor: String, CaseIterable, Codable {
|
||||||
|
case red, yellow, white, cyan, orange
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
switch self {
|
||||||
|
case .red: return .red
|
||||||
|
case .yellow: return .yellow
|
||||||
|
case .white: return .white
|
||||||
|
case .cyan: return .cyan
|
||||||
|
case .orange: return .orange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var name: String {
|
||||||
|
rawValue.capitalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var selectedColor: TimerColor = .red {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(selectedColor.rawValue, forKey: "selectedTimerColor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Flash Mode Selection
|
||||||
|
enum FlashMode: String, CaseIterable, Codable {
|
||||||
|
case soft // Soft fading flash
|
||||||
|
case rapid // Rapid strobe-like flash
|
||||||
|
|
||||||
|
var iconName: String {
|
||||||
|
switch self {
|
||||||
|
case .soft: return "sparkle"
|
||||||
|
case .rapid: return "sun.max"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var selectedFlashMode: FlashMode = .soft {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(selectedFlashMode.rawValue, forKey: "selectedFlashMode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadSettings() {
|
||||||
|
if let colorString = UserDefaults.standard.string(forKey: "selectedTimerColor"),
|
||||||
|
let color = TimerColor(rawValue: colorString) {
|
||||||
|
selectedColor = color
|
||||||
|
}
|
||||||
|
if let flashModeString = UserDefaults.standard.string(forKey: "selectedFlashMode"),
|
||||||
|
let flashMode = FlashMode(rawValue: flashModeString) {
|
||||||
|
selectedFlashMode = flashMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request notification permissions
|
||||||
|
func requestPermissions() {
|
||||||
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||||
|
if granted {
|
||||||
|
print("Notification permission granted")
|
||||||
|
} else if let error = error {
|
||||||
|
print("Notification permission error: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set timer with a specific duration in minutes but do not start
|
||||||
|
func setTime(minutes: Int) {
|
||||||
|
stopTimer() // Invalidate any existing timer
|
||||||
|
isTimerFinished = false
|
||||||
|
|
||||||
|
// Just set the initial time
|
||||||
|
let seconds = TimeInterval(minutes * 60)
|
||||||
|
timeRemaining = seconds
|
||||||
|
initialDuration = seconds
|
||||||
|
isActive = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add time to current duration
|
||||||
|
func addTime(minutes: Int) {
|
||||||
|
if !isActive {
|
||||||
|
// If timer is not active, just add to the current setup
|
||||||
|
timeRemaining += Double(minutes * 60)
|
||||||
|
initialDuration += Double(minutes * 60)
|
||||||
|
} else {
|
||||||
|
// If running, extend the current timer
|
||||||
|
timeRemaining += Double(minutes * 60)
|
||||||
|
|
||||||
|
// If we extended it while running, we might need to update the endTime locally so the tick logic (which relies on endTime) works correctly?
|
||||||
|
// Wait, tick() uses: let remaining = endTime.timeIntervalSinceNow
|
||||||
|
// So if we increase timeRemaining, we MUST increase endTime.
|
||||||
|
if let currentEnd = endTime {
|
||||||
|
endTime = currentEnd.addingTimeInterval(Double(minutes * 60))
|
||||||
|
|
||||||
|
// Also update Live Activity if needed
|
||||||
|
LiveActivityManager.shared.start(endTime: endTime!, duration: timeRemaining, message: notificationMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the countdown from the current timeRemaining
|
||||||
|
func startCountdown() {
|
||||||
|
stopTimer() // Invalidate any existing timer
|
||||||
|
isTimerFinished = false
|
||||||
|
|
||||||
|
guard timeRemaining > 0 else { return }
|
||||||
|
|
||||||
|
// Calculate new end time based on current remaining time
|
||||||
|
endTime = Date().addingTimeInterval(timeRemaining)
|
||||||
|
isActive = true
|
||||||
|
|
||||||
|
scheduleNotification()
|
||||||
|
|
||||||
|
// Start Live Activity
|
||||||
|
if let targetTime = endTime {
|
||||||
|
LiveActivityManager.shared.start(endTime: targetTime, duration: timeRemaining, message: notificationMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
timerSubscription = Timer.publish(every: 0.1, on: .main, in: .common)
|
||||||
|
.autoconnect()
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.tick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the timer to 0 and stop
|
||||||
|
func resetTimer() {
|
||||||
|
stopTimer()
|
||||||
|
isTimerFinished = false
|
||||||
|
timeRemaining = 0
|
||||||
|
initialDuration = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust time manually
|
||||||
|
func adjustTime(bySeconds seconds: Int) {
|
||||||
|
let newTime = timeRemaining + TimeInterval(seconds)
|
||||||
|
timeRemaining = max(newTime, 0)
|
||||||
|
|
||||||
|
if isActive {
|
||||||
|
// Update endTime to reflect the new remaining time
|
||||||
|
endTime = Date().addingTimeInterval(timeRemaining)
|
||||||
|
|
||||||
|
// Cancel current notifications and live activity immediately
|
||||||
|
cancelNotification()
|
||||||
|
LiveActivityManager.shared.end()
|
||||||
|
|
||||||
|
// Cancel any existing debounce timer
|
||||||
|
adjustmentDebounceTimer?.invalidate()
|
||||||
|
|
||||||
|
// If time was reduced to 0, stop the timer
|
||||||
|
if timeRemaining <= 0 {
|
||||||
|
stopTimer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a new debounce timer (2 seconds)
|
||||||
|
adjustmentDebounceTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||||
|
guard let self = self, self.isActive, self.timeRemaining > 0, let endTime = self.endTime else { return }
|
||||||
|
|
||||||
|
// Restart notifications and live activity
|
||||||
|
self.scheduleNotification()
|
||||||
|
LiveActivityManager.shared.start(endTime: endTime, duration: self.timeRemaining, message: self.notificationMessage)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not running, just update initial duration for setup mode
|
||||||
|
initialDuration = timeRemaining
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the timer
|
||||||
|
private func stopTimer(cancelsNotification: Bool = true, endActivity: Bool = true) {
|
||||||
|
// Cancel any pending debounce timer
|
||||||
|
adjustmentDebounceTimer?.invalidate()
|
||||||
|
adjustmentDebounceTimer = nil
|
||||||
|
|
||||||
|
isActive = false
|
||||||
|
timerSubscription?.cancel()
|
||||||
|
timerSubscription = nil
|
||||||
|
if cancelsNotification {
|
||||||
|
cancelNotification()
|
||||||
|
isTimerFinished = false // Only clear if manually stopped/reset
|
||||||
|
}
|
||||||
|
|
||||||
|
// End Live Activity only if requested
|
||||||
|
if endActivity {
|
||||||
|
LiveActivityManager.shared.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule a local notification
|
||||||
|
private func scheduleNotification(message: String? = nil) {
|
||||||
|
// Only schedule if we have time remaining
|
||||||
|
guard timeRemaining > 0 else { return }
|
||||||
|
|
||||||
|
let targetMessage = message ?? notificationMessage
|
||||||
|
|
||||||
|
// Burst Mode: Schedule 3 notifications spaced 1 second apart
|
||||||
|
for i in 0..<3 {
|
||||||
|
let offset = TimeInterval(i * 1)
|
||||||
|
let triggerTime = timeRemaining + offset
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "VizTimer"
|
||||||
|
content.body = targetMessage.isEmpty ? "Timer Finished!" : targetMessage
|
||||||
|
content.sound = .default
|
||||||
|
content.interruptionLevel = .timeSensitive
|
||||||
|
|
||||||
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: triggerTime, repeats: false)
|
||||||
|
let request = UNNotificationRequest(identifier: "VizTimerNotification_\(i)", content: content, trigger: trigger)
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().add(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel any pending notifications
|
||||||
|
private func cancelNotification() {
|
||||||
|
let identifiers = (0..<3).map { "VizTimerNotification_\($0)" }
|
||||||
|
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: identifiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger vibration 3 times
|
||||||
|
private func triggerVibration() {
|
||||||
|
for i in 0..<3 {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i)) {
|
||||||
|
AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger Camera Flash 3 times
|
||||||
|
private func triggerFlash() {
|
||||||
|
guard let device = AVCaptureDevice.default(for: .video) else {
|
||||||
|
print("No video device found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard device.hasTorch else {
|
||||||
|
print("Device does not have a torch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..<3 {
|
||||||
|
// Flash ON
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i)) {
|
||||||
|
do {
|
||||||
|
try device.lockForConfiguration()
|
||||||
|
// Use max brightness for visibility
|
||||||
|
try device.setTorchModeOn(level: AVCaptureDevice.maxAvailableTorchLevel)
|
||||||
|
device.unlockForConfiguration()
|
||||||
|
} catch {
|
||||||
|
print("Torch could not be used: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flash OFF (short pulse - 100ms)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) + 0.1) {
|
||||||
|
do {
|
||||||
|
try device.lockForConfiguration()
|
||||||
|
device.torchMode = .off
|
||||||
|
device.unlockForConfiguration()
|
||||||
|
} catch {
|
||||||
|
print("Torch could not be used: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update logic for each tick
|
||||||
|
private func tick() {
|
||||||
|
guard let endTime = endTime else { return }
|
||||||
|
let remaining = endTime.timeIntervalSinceNow
|
||||||
|
|
||||||
|
if remaining <= 0 {
|
||||||
|
withAnimation {
|
||||||
|
timeRemaining = initialDuration // Reset to initial duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish Live Activity nicely
|
||||||
|
LiveActivityManager.shared.finish(message: notificationMessage.isEmpty ? "Timer Finished!" : notificationMessage)
|
||||||
|
|
||||||
|
triggerVibration()
|
||||||
|
if isFlashEnabled {
|
||||||
|
triggerFlash()
|
||||||
|
}
|
||||||
|
stopTimer(cancelsNotification: false, endActivity: false) // Do NOT cancel notification so it fires, and don't kill activity
|
||||||
|
isTimerFinished = true
|
||||||
|
} else {
|
||||||
|
withAnimation {
|
||||||
|
timeRemaining = remaining
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format time for display
|
||||||
|
func timeString() -> String {
|
||||||
|
let totalSeconds = Int(ceil(timeRemaining))
|
||||||
|
let minutes = totalSeconds / 60
|
||||||
|
let seconds = totalSeconds % 60
|
||||||
|
return String(format: "%02d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
121
EzTimer/UIComponents.swift
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
//
|
||||||
|
// UIComponents.swift
|
||||||
|
// EzTimer
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 12/17/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Glass Card
|
||||||
|
struct GlassCard<Content: View>: View {
|
||||||
|
let content: Content
|
||||||
|
|
||||||
|
init(@ViewBuilder content: () -> Content) {
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
content
|
||||||
|
.padding()
|
||||||
|
.liquidGlass(cornerRadius: 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Modern Button
|
||||||
|
struct ModernButton: View {
|
||||||
|
let title: String
|
||||||
|
let icon: String?
|
||||||
|
let action: () -> Void
|
||||||
|
var backgroundColor: Color = .blue
|
||||||
|
|
||||||
|
@State private var isPressed = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack {
|
||||||
|
if let icon = icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
}
|
||||||
|
Text(title)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(backgroundColor.opacity(0.8))
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
.cornerRadius(16)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.shadow(color: backgroundColor.opacity(0.4), radius: isPressed ? 10 : 0)
|
||||||
|
.scaleEffect(isPressed ? 0.98 : 1.0)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.simultaneousGesture(
|
||||||
|
DragGesture(minimumDistance: 0)
|
||||||
|
.onChanged { _ in isPressed = true }
|
||||||
|
.onEnded { _ in isPressed = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Floating Tab Bar
|
||||||
|
struct FloatingTabBar: View {
|
||||||
|
@Binding var selectedTab: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
TabBarButton(icon: "timer", title: "Timer", isSelected: selectedTab == 0) {
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||||
|
selectedTab = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TabBarButton(icon: "clock.arrow.circlepath", title: "Presets", isSelected: selectedTab == 1) {
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||||
|
selectedTab = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(6)
|
||||||
|
.background(.thinMaterial)
|
||||||
|
.cornerRadius(30)
|
||||||
|
.shadow(color: .black.opacity(0.15), radius: 10, x: 0, y: 5)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TabBarButton: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let isSelected: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 20, weight: isSelected ? .bold : .regular))
|
||||||
|
.foregroundColor(isSelected ? .primary : .secondary)
|
||||||
|
.scaleEffect(isSelected ? 1.1 : 1.0)
|
||||||
|
|
||||||
|
if isSelected {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 50)
|
||||||
|
.background(
|
||||||
|
isSelected ?
|
||||||
|
Capsule().fill(Color.primary.opacity(0.1)) :
|
||||||
|
Capsule().fill(Color.clear)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
EzTimerWidget/AppIntent.swift
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// AppIntent.swift
|
||||||
|
// EzTimerWidget
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 12/17/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import WidgetKit
|
||||||
|
import AppIntents
|
||||||
|
|
||||||
|
struct ConfigurationAppIntent: WidgetConfigurationIntent {
|
||||||
|
static var title: LocalizedStringResource { "Configuration" }
|
||||||
|
static var description: IntentDescription { "This is an example widget." }
|
||||||
|
|
||||||
|
// An example configurable parameter.
|
||||||
|
@Parameter(title: "Favorite Emoji", default: "😃")
|
||||||
|
var favoriteEmoji: String
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/100.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/1024.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/114.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/120.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/144.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/152.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/167.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/180.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/20.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/29.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/40.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/50.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/57.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/58.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/60.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/72.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/76.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/80.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/87.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
|||||||
|
{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","idiom":"ipad","scale":"2x"}]}
|
||||||
6
EzTimerWidget/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
88
EzTimerWidget/EzTimerWidget.swift
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
//
|
||||||
|
// EzTimerWidget.swift
|
||||||
|
// EzTimerWidget
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 12/17/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct Provider: AppIntentTimelineProvider {
|
||||||
|
func placeholder(in context: Context) -> SimpleEntry {
|
||||||
|
SimpleEntry(date: Date(), configuration: ConfigurationAppIntent())
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
|
||||||
|
SimpleEntry(date: Date(), configuration: configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
|
||||||
|
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, configuration: configuration)
|
||||||
|
entries.append(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Timeline(entries: entries, policy: .atEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// func relevances() async -> WidgetRelevances<ConfigurationAppIntent> {
|
||||||
|
// // Generate a list containing the contexts this widget is relevant in.
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SimpleEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let configuration: ConfigurationAppIntent
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EzTimerWidgetEntryView : View {
|
||||||
|
var entry: Provider.Entry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
Text("Time:")
|
||||||
|
Text(entry.date, style: .time)
|
||||||
|
|
||||||
|
Text("Favorite Emoji:")
|
||||||
|
Text(entry.configuration.favoriteEmoji)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EzTimerWidget: Widget {
|
||||||
|
let kind: String = "EzTimerWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
|
||||||
|
EzTimerWidgetEntryView(entry: entry)
|
||||||
|
.containerBackground(.fill.tertiary, for: .widget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ConfigurationAppIntent {
|
||||||
|
fileprivate static var smiley: ConfigurationAppIntent {
|
||||||
|
let intent = ConfigurationAppIntent()
|
||||||
|
intent.favoriteEmoji = "😀"
|
||||||
|
return intent
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate static var starEyes: ConfigurationAppIntent {
|
||||||
|
let intent = ConfigurationAppIntent()
|
||||||
|
intent.favoriteEmoji = "🤩"
|
||||||
|
return intent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview(as: .systemSmall) {
|
||||||
|
EzTimerWidget()
|
||||||
|
} timeline: {
|
||||||
|
SimpleEntry(date: .now, configuration: .smiley)
|
||||||
|
SimpleEntry(date: .now, configuration: .starEyes)
|
||||||
|
}
|
||||||
18
EzTimerWidget/EzTimerWidgetBundle.swift
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// EzTimerWidgetBundle.swift
|
||||||
|
// EzTimerWidget
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 12/17/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct EzTimerWidgetBundle: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
// EzTimerWidget()
|
||||||
|
// EzTimerWidgetControl()
|
||||||
|
EzTimerWidgetLiveActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
77
EzTimerWidget/EzTimerWidgetControl.swift
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
//
|
||||||
|
// EzTimerWidgetControl.swift
|
||||||
|
// EzTimerWidget
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 12/17/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppIntents
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
struct EzTimerWidgetControl: ControlWidget {
|
||||||
|
static let kind: String = "com.jaredlog.EzTimer.EzTimerWidget"
|
||||||
|
|
||||||
|
var body: some ControlWidgetConfiguration {
|
||||||
|
AppIntentControlConfiguration(
|
||||||
|
kind: Self.kind,
|
||||||
|
provider: Provider()
|
||||||
|
) { value in
|
||||||
|
ControlWidgetToggle(
|
||||||
|
"Start Timer",
|
||||||
|
isOn: value.isRunning,
|
||||||
|
action: StartTimerIntent(value.name)
|
||||||
|
) { isRunning in
|
||||||
|
Label(isRunning ? "On" : "Off", systemImage: "timer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.displayName("Timer")
|
||||||
|
.description("A an example control that runs a timer.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EzTimerWidgetControl {
|
||||||
|
struct Value {
|
||||||
|
var isRunning: Bool
|
||||||
|
var name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Provider: AppIntentControlValueProvider {
|
||||||
|
func previewValue(configuration: TimerConfiguration) -> Value {
|
||||||
|
EzTimerWidgetControl.Value(isRunning: false, name: configuration.timerName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentValue(configuration: TimerConfiguration) async throws -> Value {
|
||||||
|
let isRunning = true // Check if the timer is running
|
||||||
|
return EzTimerWidgetControl.Value(isRunning: isRunning, name: configuration.timerName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TimerConfiguration: ControlConfigurationIntent {
|
||||||
|
static let title: LocalizedStringResource = "Timer Name Configuration"
|
||||||
|
|
||||||
|
@Parameter(title: "Timer Name", default: "Timer")
|
||||||
|
var timerName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StartTimerIntent: SetValueIntent {
|
||||||
|
static let title: LocalizedStringResource = "Start a timer"
|
||||||
|
|
||||||
|
@Parameter(title: "Timer Name")
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
@Parameter(title: "Timer is running")
|
||||||
|
var value: Bool
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
init(_ name: String) {
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
|
||||||
|
func perform() async throws -> some IntentResult {
|
||||||
|
// Start the timer…
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
107
EzTimerWidget/EzTimerWidgetLiveActivity.swift
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
//
|
||||||
|
// EzTimerWidgetLiveActivity.swift
|
||||||
|
// EzTimerWidget
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 12/17/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import ActivityKit
|
||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct EzTimerWidgetLiveActivity: Widget {
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
ActivityConfiguration(for: TimerAttributes.self) { context in
|
||||||
|
// Lock Screen/Banner UI
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "timer")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Text("VizTimer")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
// This counts down automatically relative to the target date
|
||||||
|
if context.state.isFinished {
|
||||||
|
Text("Done at \(context.state.endTime, style: .time)")
|
||||||
|
.font(.system(size: 40, weight: .bold)) // Slightly smaller to fit time
|
||||||
|
.foregroundColor(.white)
|
||||||
|
} else {
|
||||||
|
Text(timerInterval: Date()...context.state.endTime, countsDown: true)
|
||||||
|
.font(.system(size: 48, weight: .bold))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if !context.state.message.isEmpty {
|
||||||
|
Text(context.state.message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.activityBackgroundTint(Color.black.opacity(0.8))
|
||||||
|
.activitySystemActionForegroundColor(Color.red)
|
||||||
|
|
||||||
|
} dynamicIsland: { context in
|
||||||
|
DynamicIsland {
|
||||||
|
// Expanded State
|
||||||
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
|
Image(systemName: "timer")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
|
if context.state.isFinished {
|
||||||
|
Text("Done at \(context.state.endTime, style: .time)")
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
} else {
|
||||||
|
Text(timerInterval: Date()...context.state.endTime, countsDown: true)
|
||||||
|
.monospacedDigit()
|
||||||
|
.font(.title2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
|
if !context.state.message.isEmpty {
|
||||||
|
Text(context.state.message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
.padding(.top, 4)
|
||||||
|
} else {
|
||||||
|
Text("Time Remaining")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} compactLeading: {
|
||||||
|
Image(systemName: "timer")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
} compactTrailing: {
|
||||||
|
if context.state.isFinished {
|
||||||
|
Text("End")
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.frame(minWidth: 40)
|
||||||
|
} else {
|
||||||
|
Text(timerInterval: Date()...context.state.endTime, countsDown: true)
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(minWidth: 40)
|
||||||
|
}
|
||||||
|
} minimal: {
|
||||||
|
Image(systemName: "timer")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
.widgetURL(URL(string: "viztimer://open"))
|
||||||
|
.keylineTint(Color.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
EzTimerWidget/Info.plist
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?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>CFBundleVersion</key>
|
||||||
|
<string>2</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.widgetkit-extension</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
31
README.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# VizTimer
|
||||||
|
|
||||||
|
**VizTimer is a deaf-friendly timer that uses visual alerts such as full color screen, vibrations, and rear camera flash to grab your attention when the timer reaches zero.**
|
||||||
|
|
||||||
|
Designed with a high-contrast, modern "glassmorphism" aesthetic, VizTimer ensures you never miss a notification, even in noisy environments or when you cannot hear a standard alarm.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### 🚨 Accessibility-First Alerts
|
||||||
|
Unlike standard timers that rely on sound, VizTimer activates three distinct sensory alerts when the countdown finishes:
|
||||||
|
|
||||||
|
* **Full-Screen Visual Alarm:** The entire screen pulses with your selected color to provide a massive visual cue.
|
||||||
|
* **Camera Flash Strobe:** Utilizes the rear device torch (flashlight) to flash continuously, grabbing attention from across the room.
|
||||||
|
* **Haptic Feedback:** Intense vibration patterns for tactile notification when the phone is in your pocket or on your desk.
|
||||||
|
|
||||||
|
### 🏝 Live Activities & Dynamic Island
|
||||||
|
Stay updated without unlocking your phone. VizTimer integrates with **Live Activities**, allowing you to view your countdown directly from the Lock Screen or the Dynamic Island on supported iPhones.
|
||||||
|
|
||||||
|
### ⚡️ Quick & Custom Controls
|
||||||
|
* **One-Tap Presets:** Instantly start timers for common durations (1, 2, 5, 10... up to 60 minutes).
|
||||||
|
* **Stackable Time:** Long-press preset buttons to add time to an active timer (e.g., tap 5 min, then long-press 1 min to make it 6 minutes).
|
||||||
|
* **Precision Adjustment:** Fine-tune your timer using minute and second adjustment buttons.
|
||||||
|
|
||||||
|
### 🎨 Personalization
|
||||||
|
* **Color Themes:** Choose from 5 distinct high-visibility colors (Red, Yellow, White, Cyan, Orange) for your visual alarm.
|
||||||
|
* **Custom Labels:** Save up to 4 custom named presets. Long-press a slot to rename it (e.g., "Cooking", "Laundry", "Nap").
|
||||||
|
* **Timer Messages:** Add a specific note to your timer that appears on the visual alarm and Lock Screen notification.
|
||||||
|
|
||||||
|
### 📱 Modern Interface
|
||||||
|
* **Always-On Display:** The app prevents the screen from sleeping while the timer is active, ensuring you can always see the remaining time.
|
||||||
|
* **Dark Mode Native:** Built with a "Liquid Glass" dark mode aesthetic featuring animated mesh backgrounds and neon accents.
|
||||||
BIN
Screenshots/iPad/1.PNG
Normal file
|
After Width: | Height: | Size: 7.3 MiB |
BIN
Screenshots/iPad/3.PNG
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
Screenshots/iPhone/1.PNG
Normal file
|
After Width: | Height: | Size: 9.7 MiB |
BIN
Screenshots/iPhone/3.PNG
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
appstore.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
27
check_build.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
set -o pipefail # Fail if xcodebuild fails, even with xcbeautify
|
||||||
|
|
||||||
|
# --- Configuration ---
|
||||||
|
SCHEME="EzTimer"
|
||||||
|
DEVICE_NAME="iPhone 17 Pro"
|
||||||
|
BUILD_PATH="./build"
|
||||||
|
|
||||||
|
echo "🔍 Checking compilation for $SCHEME..."
|
||||||
|
|
||||||
|
# Build Only (No Install/Launch)
|
||||||
|
# We use 'env -u' to hide Homebrew variables
|
||||||
|
# We use '-derivedDataPath' to keep it isolated
|
||||||
|
env -u CC -u CXX -u LIBCLANG_PATH xcodebuild \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-destination "platform=iOS Simulator,name=$DEVICE_NAME" \
|
||||||
|
-configuration Debug \
|
||||||
|
-derivedDataPath "$BUILD_PATH" \
|
||||||
|
build
|
||||||
|
|
||||||
|
# Check exit code of the pipeline
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✅ Build Succeeded. No errors found."
|
||||||
|
else
|
||||||
|
echo "❌ Build Failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
BIN
playstore.png
Normal file
|
After Width: | Height: | Size: 436 KiB |