Initial commit: EzTimer iOS app with widget extension

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 22:10:34 -05:00
commit 99e7e79347
73 changed files with 2447 additions and 0 deletions

68
.gitignore vendored Normal file
View 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

View 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 */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View 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>

View File

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

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

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

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

132
EzTimer/ContentView.swift Normal file
View 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
View 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
View 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
View 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>

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

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

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

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

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

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

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

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

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

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

View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 MiB

BIN
Screenshots/iPad/3.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

BIN
Screenshots/iPhone/1.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 MiB

BIN
Screenshots/iPhone/3.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

BIN
appstore.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

27
check_build.sh Executable file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB