commit 99e7e793473bdb13179d061a599cc9038bd4c166 Author: jared Date: Mon Jan 19 22:10:34 2026 -0500 Initial commit: EzTimer iOS app with widget extension Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e2aef0 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/EzTimer.xcodeproj/project.pbxproj b/EzTimer.xcodeproj/project.pbxproj new file mode 100644 index 0000000..430a167 --- /dev/null +++ b/EzTimer.xcodeproj/project.pbxproj @@ -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 = ""; + }; + 7FD67F0A2EF383EB007DDD4E /* EzTimerWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 7FD67F1E2EF383EC007DDD4E /* Exceptions for "EzTimerWidget" folder in "EzTimerWidgetExtension" target */, + ); + path = EzTimerWidget; + sourceTree = ""; + }; +/* 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 = ""; + }; + 7F02699F2EF236FB0063309D /* Products */ = { + isa = PBXGroup; + children = ( + 7F02699E2EF236FB0063309D /* EzTimer.app */, + 7FD67F042EF383EB007DDD4E /* EzTimerWidgetExtension.appex */, + ); + name = Products; + sourceTree = ""; + }; + 7FD67F052EF383EB007DDD4E /* Frameworks */ = { + isa = PBXGroup; + children = ( + 7FD67F062EF383EB007DDD4E /* WidgetKit.framework */, + 7FD67F082EF383EB007DDD4E /* SwiftUI.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/EzTimer.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/EzTimer.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/EzTimer.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/EzTimer.xcodeproj/xcshareddata/xcschemes/EzTimer.xcscheme b/EzTimer.xcodeproj/xcshareddata/xcschemes/EzTimer.xcscheme new file mode 100644 index 0000000..607dfb8 --- /dev/null +++ b/EzTimer.xcodeproj/xcshareddata/xcschemes/EzTimer.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EzTimer.xcodeproj/xcshareddata/xcschemes/EzTimerWidgetExtension.xcscheme b/EzTimer.xcodeproj/xcshareddata/xcschemes/EzTimerWidgetExtension.xcscheme new file mode 100644 index 0000000..45f7138 --- /dev/null +++ b/EzTimer.xcodeproj/xcshareddata/xcschemes/EzTimerWidgetExtension.xcscheme @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EzTimer/Assets.xcassets/AccentColor.colorset/Contents.json b/EzTimer/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/EzTimer/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/100.png b/EzTimer/Assets.xcassets/AppIconV2.appiconset/100.png new file mode 100644 index 0000000..b8d0038 Binary files /dev/null and b/EzTimer/Assets.xcassets/AppIconV2.appiconset/100.png differ diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/1024.png b/EzTimer/Assets.xcassets/AppIconV2.appiconset/1024.png new file mode 100644 index 0000000..c553b3e Binary files /dev/null and b/EzTimer/Assets.xcassets/AppIconV2.appiconset/1024.png differ diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/114.png b/EzTimer/Assets.xcassets/AppIconV2.appiconset/114.png new file mode 100644 index 0000000..2cb4d0d Binary files /dev/null and b/EzTimer/Assets.xcassets/AppIconV2.appiconset/114.png differ diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/120.png b/EzTimer/Assets.xcassets/AppIconV2.appiconset/120.png new file mode 100644 index 0000000..bf30449 Binary files /dev/null and b/EzTimer/Assets.xcassets/AppIconV2.appiconset/120.png differ diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/144.png b/EzTimer/Assets.xcassets/AppIconV2.appiconset/144.png new file mode 100644 index 0000000..66c5f29 Binary files /dev/null and b/EzTimer/Assets.xcassets/AppIconV2.appiconset/144.png differ diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/152.png b/EzTimer/Assets.xcassets/AppIconV2.appiconset/152.png new file mode 100644 index 0000000..2c27d19 Binary files /dev/null and b/EzTimer/Assets.xcassets/AppIconV2.appiconset/152.png differ diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/167.png b/EzTimer/Assets.xcassets/AppIconV2.appiconset/167.png new file mode 100644 index 0000000..a6f7ac2 Binary files /dev/null and b/EzTimer/Assets.xcassets/AppIconV2.appiconset/167.png differ diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/180.png b/EzTimer/Assets.xcassets/AppIconV2.appiconset/180.png new file mode 100644 index 0000000..677652c Binary files /dev/null and b/EzTimer/Assets.xcassets/AppIconV2.appiconset/180.png differ diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/20.png b/EzTimer/Assets.xcassets/AppIconV2.appiconset/20.png new file mode 100644 index 0000000..b16e701 Binary files /dev/null and b/EzTimer/Assets.xcassets/AppIconV2.appiconset/20.png differ diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/29.png b/EzTimer/Assets.xcassets/AppIconV2.appiconset/29.png new file mode 100644 index 0000000..f01a0eb Binary files /dev/null and b/EzTimer/Assets.xcassets/AppIconV2.appiconset/29.png differ diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/40.png b/EzTimer/Assets.xcassets/AppIconV2.appiconset/40.png new file mode 100644 index 0000000..1a3c785 Binary files /dev/null and b/EzTimer/Assets.xcassets/AppIconV2.appiconset/40.png differ diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/50.png b/EzTimer/Assets.xcassets/AppIconV2.appiconset/50.png new file mode 100644 index 0000000..7ea6719 Binary files /dev/null and b/EzTimer/Assets.xcassets/AppIconV2.appiconset/50.png differ diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/57.png b/EzTimer/Assets.xcassets/AppIconV2.appiconset/57.png new file mode 100644 index 0000000..a8a3b08 Binary files /dev/null and b/EzTimer/Assets.xcassets/AppIconV2.appiconset/57.png differ diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/58.png b/EzTimer/Assets.xcassets/AppIconV2.appiconset/58.png new file mode 100644 index 0000000..61b9f98 Binary files /dev/null and b/EzTimer/Assets.xcassets/AppIconV2.appiconset/58.png differ diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/60.png b/EzTimer/Assets.xcassets/AppIconV2.appiconset/60.png new file mode 100644 index 0000000..a9ea356 Binary files /dev/null and b/EzTimer/Assets.xcassets/AppIconV2.appiconset/60.png differ diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/72.png b/EzTimer/Assets.xcassets/AppIconV2.appiconset/72.png new file mode 100644 index 0000000..a6c1965 Binary files /dev/null and b/EzTimer/Assets.xcassets/AppIconV2.appiconset/72.png differ diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/76.png b/EzTimer/Assets.xcassets/AppIconV2.appiconset/76.png new file mode 100644 index 0000000..8809b49 Binary files /dev/null and b/EzTimer/Assets.xcassets/AppIconV2.appiconset/76.png differ diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/80.png b/EzTimer/Assets.xcassets/AppIconV2.appiconset/80.png new file mode 100644 index 0000000..e8a43ee Binary files /dev/null and b/EzTimer/Assets.xcassets/AppIconV2.appiconset/80.png differ diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/87.png b/EzTimer/Assets.xcassets/AppIconV2.appiconset/87.png new file mode 100644 index 0000000..4ec5bd6 Binary files /dev/null and b/EzTimer/Assets.xcassets/AppIconV2.appiconset/87.png differ diff --git a/EzTimer/Assets.xcassets/AppIconV2.appiconset/Contents.json b/EzTimer/Assets.xcassets/AppIconV2.appiconset/Contents.json new file mode 100644 index 0000000..5923096 --- /dev/null +++ b/EzTimer/Assets.xcassets/AppIconV2.appiconset/Contents.json @@ -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"}]} \ No newline at end of file diff --git a/EzTimer/Assets.xcassets/Contents.json b/EzTimer/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/EzTimer/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/EzTimer/ContentView.swift b/EzTimer/ContentView.swift new file mode 100644 index 0000000..e840ab1 --- /dev/null +++ b/EzTimer/ContentView.swift @@ -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() +} diff --git a/EzTimer/DesignSystem.swift b/EzTimer/DesignSystem.swift new file mode 100644 index 0000000..408ec18 --- /dev/null +++ b/EzTimer/DesignSystem.swift @@ -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) + } +} diff --git a/EzTimer/EzTimerApp.swift b/EzTimer/EzTimerApp.swift new file mode 100644 index 0000000..c198c0c --- /dev/null +++ b/EzTimer/EzTimerApp.swift @@ -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() + } + } +} diff --git a/EzTimer/Info.plist b/EzTimer/Info.plist new file mode 100644 index 0000000..dace807 --- /dev/null +++ b/EzTimer/Info.plist @@ -0,0 +1,10 @@ + + + + + NSSupportsLiveActivities + + NSCameraUsageDescription + We use the camera flash to alert you when the timer finishes. + + diff --git a/EzTimer/LiveActivityManager.swift b/EzTimer/LiveActivityManager.swift new file mode 100644 index 0000000..5d63ff7 --- /dev/null +++ b/EzTimer/LiveActivityManager.swift @@ -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? + + 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 + } + } +} diff --git a/EzTimer/TimerAttributes.swift b/EzTimer/TimerAttributes.swift new file mode 100644 index 0000000..6c7783b --- /dev/null +++ b/EzTimer/TimerAttributes.swift @@ -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 +} diff --git a/EzTimer/TimerView.swift b/EzTimer/TimerView.swift new file mode 100644 index 0000000..0a5f952 --- /dev/null +++ b/EzTimer/TimerView.swift @@ -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 = [] + @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()) + } +} diff --git a/EzTimer/TimerViewModel.swift b/EzTimer/TimerViewModel.swift new file mode 100644 index 0000000..84e7930 --- /dev/null +++ b/EzTimer/TimerViewModel.swift @@ -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() + 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) + } +} diff --git a/EzTimer/UIComponents.swift b/EzTimer/UIComponents.swift new file mode 100644 index 0000000..4e6632e --- /dev/null +++ b/EzTimer/UIComponents.swift @@ -0,0 +1,121 @@ +// +// UIComponents.swift +// EzTimer +// +// Created by Jared Evans on 12/17/25. +// + +import SwiftUI + +// MARK: - Glass Card +struct GlassCard: 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) + ) + } + } +} diff --git a/EzTimerWidget/AppIntent.swift b/EzTimerWidget/AppIntent.swift new file mode 100644 index 0000000..f9ddf9b --- /dev/null +++ b/EzTimerWidget/AppIntent.swift @@ -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 +} diff --git a/EzTimerWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/EzTimerWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/EzTimerWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/100.png b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/100.png new file mode 100644 index 0000000..b8d0038 Binary files /dev/null and b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/100.png differ diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/1024.png b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/1024.png new file mode 100644 index 0000000..c553b3e Binary files /dev/null and b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/1024.png differ diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/114.png b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/114.png new file mode 100644 index 0000000..2cb4d0d Binary files /dev/null and b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/114.png differ diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/120.png b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/120.png new file mode 100644 index 0000000..bf30449 Binary files /dev/null and b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/120.png differ diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/144.png b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/144.png new file mode 100644 index 0000000..66c5f29 Binary files /dev/null and b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/144.png differ diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/152.png b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/152.png new file mode 100644 index 0000000..2c27d19 Binary files /dev/null and b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/152.png differ diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/167.png b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/167.png new file mode 100644 index 0000000..a6f7ac2 Binary files /dev/null and b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/167.png differ diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/180.png b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/180.png new file mode 100644 index 0000000..677652c Binary files /dev/null and b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/180.png differ diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/20.png b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/20.png new file mode 100644 index 0000000..b16e701 Binary files /dev/null and b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/20.png differ diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/29.png b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/29.png new file mode 100644 index 0000000..f01a0eb Binary files /dev/null and b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/29.png differ diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/40.png b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/40.png new file mode 100644 index 0000000..1a3c785 Binary files /dev/null and b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/40.png differ diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/50.png b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/50.png new file mode 100644 index 0000000..7ea6719 Binary files /dev/null and b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/50.png differ diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/57.png b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/57.png new file mode 100644 index 0000000..a8a3b08 Binary files /dev/null and b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/57.png differ diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/58.png b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/58.png new file mode 100644 index 0000000..61b9f98 Binary files /dev/null and b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/58.png differ diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/60.png b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/60.png new file mode 100644 index 0000000..a9ea356 Binary files /dev/null and b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/60.png differ diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/72.png b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/72.png new file mode 100644 index 0000000..a6c1965 Binary files /dev/null and b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/72.png differ diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/76.png b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/76.png new file mode 100644 index 0000000..8809b49 Binary files /dev/null and b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/76.png differ diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/80.png b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/80.png new file mode 100644 index 0000000..e8a43ee Binary files /dev/null and b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/80.png differ diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/87.png b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/87.png new file mode 100644 index 0000000..4ec5bd6 Binary files /dev/null and b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/87.png differ diff --git a/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/Contents.json b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/Contents.json new file mode 100644 index 0000000..5923096 --- /dev/null +++ b/EzTimerWidget/Assets.xcassets/AppIconV2.appiconset/Contents.json @@ -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"}]} \ No newline at end of file diff --git a/EzTimerWidget/Assets.xcassets/Contents.json b/EzTimerWidget/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/EzTimerWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/EzTimerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/EzTimerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/EzTimerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/EzTimerWidget/EzTimerWidget.swift b/EzTimerWidget/EzTimerWidget.swift new file mode 100644 index 0000000..66bf469 --- /dev/null +++ b/EzTimerWidget/EzTimerWidget.swift @@ -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 { + 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 { +// // 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) +} diff --git a/EzTimerWidget/EzTimerWidgetBundle.swift b/EzTimerWidget/EzTimerWidgetBundle.swift new file mode 100644 index 0000000..395c216 --- /dev/null +++ b/EzTimerWidget/EzTimerWidgetBundle.swift @@ -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() + } +} diff --git a/EzTimerWidget/EzTimerWidgetControl.swift b/EzTimerWidget/EzTimerWidgetControl.swift new file mode 100644 index 0000000..29fa488 --- /dev/null +++ b/EzTimerWidget/EzTimerWidgetControl.swift @@ -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() + } +} diff --git a/EzTimerWidget/EzTimerWidgetLiveActivity.swift b/EzTimerWidget/EzTimerWidgetLiveActivity.swift new file mode 100644 index 0000000..87b84e8 --- /dev/null +++ b/EzTimerWidget/EzTimerWidgetLiveActivity.swift @@ -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) + } + } +} diff --git a/EzTimerWidget/Info.plist b/EzTimerWidget/Info.plist new file mode 100644 index 0000000..19e368a --- /dev/null +++ b/EzTimerWidget/Info.plist @@ -0,0 +1,15 @@ + + + + + CFBundleVersion + 2 + CFBundleShortVersionString + 1.0 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c8cb2e --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/Screenshots/iPad/1.PNG b/Screenshots/iPad/1.PNG new file mode 100644 index 0000000..6a9e3c3 Binary files /dev/null and b/Screenshots/iPad/1.PNG differ diff --git a/Screenshots/iPad/3.PNG b/Screenshots/iPad/3.PNG new file mode 100644 index 0000000..3855c43 Binary files /dev/null and b/Screenshots/iPad/3.PNG differ diff --git a/Screenshots/iPhone/1.PNG b/Screenshots/iPhone/1.PNG new file mode 100644 index 0000000..fde5bb8 Binary files /dev/null and b/Screenshots/iPhone/1.PNG differ diff --git a/Screenshots/iPhone/3.PNG b/Screenshots/iPhone/3.PNG new file mode 100644 index 0000000..b0a2252 Binary files /dev/null and b/Screenshots/iPhone/3.PNG differ diff --git a/appstore.png b/appstore.png new file mode 100644 index 0000000..c553b3e Binary files /dev/null and b/appstore.png differ diff --git a/check_build.sh b/check_build.sh new file mode 100755 index 0000000..6ee5fc4 --- /dev/null +++ b/check_build.sh @@ -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 diff --git a/playstore.png b/playstore.png new file mode 100644 index 0000000..4cafbc7 Binary files /dev/null and b/playstore.png differ