diff --git a/.github/.keep b/.github/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..04ed6ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +*/.DS_Store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b727fb8 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +https://github.com/user-attachments/assets/2b6da6d2-30ff-4a63-84ce-6af15dae3ee7 + +# TimerPlus + +TimerPlus is a SwiftUI iOS focus timer built around a Pomodoro-inspired study and break cycle. The app uses a visual, hourglass-like countdown experience to make each session feel more tangible, with separate study and break timers, quick settings, and a simple start-pause-resume flow. + +## Overview + +TimerPlus is designed for focused work sessions such as studying, reading, or deep work. The home screen shows your configured study and break lengths, the timer screen animates the remaining session visually, and the settings screen lets you adjust the timer behavior before you begin. + +## Features + +- Study timer with configurable session length +- Break timer with configurable break length +- Hourglass-inspired visual countdown using a shrinking colored timer field +- Pause, resume, and stop controls during a session +- Manual skip from study to break +- Optional automatic transition between study and break phases +- Optional sound cue when a timer switches phases +- Clean SwiftUI navigation flow between home, timer, break, and settings screens + +## Screens + +### Home + +The main screen displays the current study and break durations and provides a single entry point to start a study session. + +### Active Timer + +The timer view runs the countdown and changes color depending on the current phase: + +- Blue for study time +- Red for break time + +Users can pause, resume, stop, or move early to the next phase. + +### Break Screen + +When a study session ends, the app can route to a break screen or jump directly into the break timer if auto-start is enabled. + +### Settings + +The settings screen allows users to configure: + +- Study duration +- Break duration +- Automatic timer start between phases +- Sound playback on timer switch + +## Tech Stack + +- Swift +- SwiftUI +- Xcode project structure for iOS +- ObservableObject state management via `TimerSettings` + +## Project Structure + +```text +timerplus/ +├── README.md +└── final/ + ├── final/ + │ ├── ContentView.swift + │ ├── TimerView.swift + │ ├── BreakView.swift + │ ├── SettingsView.swift + │ ├── TimerSettings.swift + │ ├── finalApp.swift + │ ├── Persistence.swift + │ └── Assets.xcassets/ + └── final.xcodeproj/ +``` + +## How It Works + +1. The app launches into the home screen and loads default timer values from a shared `TimerSettings` object. +2. Starting a session opens the study timer. +3. The timer counts down once per second and updates the UI in real time. +4. When the study timer completes, the app transitions to a break flow. +5. If auto-start is enabled, the break timer begins immediately. +6. If auto-start is disabled, the app first shows a break screen before the break session starts. +7. When the break timer completes, the app either returns home or restarts the study cycle depending on the current settings. + +## Running the App + +### Requirements + +- macOS with Xcode installed +- Swift 5 support in Xcode +- iOS Simulator or physical iPhone/iPad + +### Open in Xcode + +Open the project at: + +- [final/final.xcodeproj](./final/final.xcodeproj) + +### Build and Run + +1. Open the Xcode project. +2. Select the `final` scheme. +3. Choose an iOS Simulator or connected device. +4. Run the app with Xcode. + +The current Xcode project configuration targets iOS 18.5 and supports iPhone and iPad device families. + +## Current Notes + +- Timer settings are currently stored in memory for the active app session only. +- The project still includes Xcode Core Data template files, but the timer flow does not currently use persistent storage. +- The app target and scheme are still named `final`, even though the repository is named TimerPlus. + +## Future Improvements + +- Add session history and focus statistics +- Improve the hourglass visual with richer animation and transitions +- Add notifications or background timer support +- Add tests for timer state transitions and settings behavior + +## Purpose + +This project is a lightweight focus timer built to support structured study sessions with a calm, visual interface. It is best suited for users who want a straightforward Pomodoro-style workflow without extra complexity. diff --git a/timerplus.xcodeproj/project.pbxproj b/timerplus.xcodeproj/project.pbxproj new file mode 100644 index 0000000..5d51b85 --- /dev/null +++ b/timerplus.xcodeproj/project.pbxproj @@ -0,0 +1,331 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + 235D81742DF26CFC00F1F44F /* timerplus.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = timerplus.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 235D81762DF26CFC00F1F44F /* timerplus */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = timerplus; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 235D81712DF26CFC00F1F44F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 235D816B2DF26CFC00F1F44F = { + isa = PBXGroup; + children = ( + 235D81762DF26CFC00F1F44F /* timerplus */, + 235D81752DF26CFC00F1F44F /* Products */, + ); + sourceTree = ""; + }; + 235D81752DF26CFC00F1F44F /* Products */ = { + isa = PBXGroup; + children = ( + 235D81742DF26CFC00F1F44F /* timerplus.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 235D81732DF26CFC00F1F44F /* timerplus */ = { + isa = PBXNativeTarget; + buildConfigurationList = 235D81842DF26CFE00F1F44F /* Build configuration list for PBXNativeTarget "timerplus" */; + buildPhases = ( + 235D81702DF26CFC00F1F44F /* Sources */, + 235D81712DF26CFC00F1F44F /* Frameworks */, + 235D81722DF26CFC00F1F44F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 235D81762DF26CFC00F1F44F /* timerplus */, + ); + name = timerplus; + packageProductDependencies = ( + ); + productName = final; + productReference = 235D81742DF26CFC00F1F44F /* timerplus.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 235D816C2DF26CFC00F1F44F /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1640; + LastUpgradeCheck = 2640; + TargetAttributes = { + 235D81732DF26CFC00F1F44F = { + CreatedOnToolsVersion = 16.4; + }; + }; + }; + buildConfigurationList = 235D816F2DF26CFC00F1F44F /* Build configuration list for PBXProject "timerplus" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 235D816B2DF26CFC00F1F44F; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 235D81752DF26CFC00F1F44F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 235D81732DF26CFC00F1F44F /* timerplus */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 235D81722DF26CFC00F1F44F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 235D81702DF26CFC00F1F44F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 235D81822DF26CFE00F1F44F /* 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 = 5N3FA5DXR2; + 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 = 18.5; + 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; + }; + 235D81832DF26CFE00F1F44F /* 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 = 5N3FA5DXR2; + 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 = 18.5; + 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; + }; + 235D81852DF26CFE00F1F44F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = jjdubski.timerplus; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 235D81862DF26CFE00F1F44F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = jjdubski.timerplus; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 235D816F2DF26CFC00F1F44F /* Build configuration list for PBXProject "timerplus" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 235D81822DF26CFE00F1F44F /* Debug */, + 235D81832DF26CFE00F1F44F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 235D81842DF26CFE00F1F44F /* Build configuration list for PBXNativeTarget "timerplus" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 235D81852DF26CFE00F1F44F /* Debug */, + 235D81862DF26CFE00F1F44F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 235D816C2DF26CFC00F1F44F /* Project object */; +} diff --git a/timerplus.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/timerplus.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/timerplus.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/timerplus.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/timerplus.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/timerplus.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/timerplus.xcodeproj/project.xcworkspace/xcuserdata/jake.xcuserdatad/UserInterfaceState.xcuserstate b/timerplus.xcodeproj/project.xcworkspace/xcuserdata/jake.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..edd2bb1 Binary files /dev/null and b/timerplus.xcodeproj/project.xcworkspace/xcuserdata/jake.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/timerplus.xcodeproj/project.xcworkspace/xcuserdata/jake.xcuserdatad/WorkspaceSettings.xcsettings b/timerplus.xcodeproj/project.xcworkspace/xcuserdata/jake.xcuserdatad/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..bbfef02 --- /dev/null +++ b/timerplus.xcodeproj/project.xcworkspace/xcuserdata/jake.xcuserdatad/WorkspaceSettings.xcsettings @@ -0,0 +1,14 @@ + + + + + BuildLocationStyle + UseAppPreferences + CustomBuildLocationType + RelativeToDerivedData + DerivedDataLocationStyle + Default + ShowSharedSchemesAutomaticallyEnabled + + + diff --git a/timerplus.xcodeproj/xcuserdata/jake.xcuserdatad/xcschemes/xcschememanagement.plist b/timerplus.xcodeproj/xcuserdata/jake.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..f64df29 --- /dev/null +++ b/timerplus.xcodeproj/xcuserdata/jake.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,19 @@ + + + + + SchemeUserState + + final.xcscheme_^#shared#^_ + + orderHint + 0 + + timerplus.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/timerplus/Assets.xcassets/AccentColor.colorset/Contents.json b/timerplus/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/timerplus/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/timerplus/Assets.xcassets/AppIcon.appiconset/Contents.json b/timerplus/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/timerplus/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/timerplus/Assets.xcassets/Contents.json b/timerplus/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/timerplus/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/timerplus/BreakView.swift b/timerplus/BreakView.swift new file mode 100644 index 0000000..fcf5ea4 --- /dev/null +++ b/timerplus/BreakView.swift @@ -0,0 +1,55 @@ +// +// BreakScreen.swift +// final +// +// Created by Jacob Waksmanski on 6/9/25. +// + +import SwiftUI + +struct BreakView: View { + @EnvironmentObject private var timerSettings: TimerSettings + @State private var breakTime: Int = 5 * 60 // 5 minutes in seconds + @State private var timerActive = false + + var body: some View { + ZStack { + Color(.darkGray) + .ignoresSafeArea() + VStack(spacing: 40) { + VStack(spacing: 0) { + Text("Break:") + .font(.system(size: 36, weight: .bold, design: .monospaced)) + .foregroundColor(.red) + Text(timeString) + .font(.system(size: 60, weight: .bold, design: .monospaced)) + .foregroundColor(.white) + } + Button(action: { + timerActive.toggle() + }) { + NavigationLink(destination: TimerView(onBreak: true)) { + Text("Start") + .font(.system(size: 32, weight: .bold)) + .foregroundColor(.white) + .frame(width: 140, height: 70) + .background(Color(red: 1.0, green: 0.27, blue: 0.23)) + .cornerRadius(18) + } + } + .padding(.top, 40) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + }.navigationBarBackButtonHidden(true) + } + + var timeString: String { + let minutes = breakTime / 60 + let seconds = breakTime % 60 + return String(format: "%d:%02d", minutes, seconds) + } +} + +#Preview { + BreakView() +} diff --git a/timerplus/ContentView.swift b/timerplus/ContentView.swift new file mode 100644 index 0000000..78bd4fb --- /dev/null +++ b/timerplus/ContentView.swift @@ -0,0 +1,76 @@ +// +// ContentView.swift +// final +// +// Created by Jacob Waksmanski on 6/5/25. +// + +import CoreData +import SwiftUI + +struct ContentView: View { + @EnvironmentObject private var timerSettings: TimerSettings + + var body: some View { + NavigationStack { + ZStack { + Color(.darkGray) + .ignoresSafeArea() + VStack { + HStack { + Spacer() + NavigationLink(destination: SettingsView()) { + Image(systemName: "gearshape.fill") + .font(.system(size: 40)) + .foregroundColor(Color(red: 0.53, green: 0.74, blue: 1.0)) + .padding(.trailing, 40.0) + } + } + Spacer() + + VStack(spacing: 24) { + VStack(spacing: 0) { + Text("Break:") + .font(.system(size: 28, weight: .bold, design: .monospaced)) + .foregroundColor(.red) + Text(timeString(from: timerSettings.breakTime)) + .font(.system(size: 48, weight: .bold, design: .monospaced)) + .foregroundColor(.white) + } + .padding(.bottom, 10.0) + + VStack(spacing: 0) { + Text("Study:") + .font(.system(size: 36, weight: .bold, design: .monospaced)) + .foregroundColor(Color(red: 0.53, green: 0.74, blue: 1.0)) + Text(timeString(from: timerSettings.studyTime)) + .font(.system(size: 56, weight: .bold, design: .monospaced)) + .foregroundColor(.white) + } + } + .padding(.bottom, 40) + + NavigationLink(destination: TimerView(onBreak: false)) { + Text("Start") + .font(.system(size: 32, weight: .bold)) + .foregroundColor(.white) + .frame(width: 150, height: 70) + .background(Color(red: 0.53, green: 0.74, blue: 1.0)) + .cornerRadius(20) + } + Spacer() + } + } + }.navigationBarBackButtonHidden(true) + } + + // Helper function to format seconds as mm:ss + private func timeString(from minutes: Int) -> String { + return String(format: "%d:00", minutes) + } +} + +#Preview { + ContentView() + .environmentObject(TimerSettings()) +} diff --git a/timerplus/LaunchScreen.storyboard b/timerplus/LaunchScreen.storyboard new file mode 100644 index 0000000..6f4a572 --- /dev/null +++ b/timerplus/LaunchScreen.storyboard @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/timerplus/Persistence.swift b/timerplus/Persistence.swift new file mode 100644 index 0000000..b92ec59 --- /dev/null +++ b/timerplus/Persistence.swift @@ -0,0 +1,57 @@ +// +// Persistence.swift +// final +// +// Created by Jacob Waksmanski on 6/5/25. +// + +import CoreData + +struct PersistenceController { + static let shared = PersistenceController() + + @MainActor + static let preview: PersistenceController = { + let result = PersistenceController(inMemory: true) + let viewContext = result.container.viewContext + for _ in 0..<10 { + let newItem = Item(context: viewContext) + newItem.timestamp = Date() + } + do { + try viewContext.save() + } catch { + // Replace this implementation with code to handle the error appropriately. + // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. + let nsError = error as NSError + fatalError("Unresolved error \(nsError), \(nsError.userInfo)") + } + return result + }() + + let container: NSPersistentContainer + + init(inMemory: Bool = false) { + container = NSPersistentContainer(name: "final") + if inMemory { + container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") + } + container.loadPersistentStores(completionHandler: { (storeDescription, error) in + if let error = error as NSError? { + // Replace this implementation with code to handle the error appropriately. + // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. + + /* + Typical reasons for an error here include: + * The parent directory does not exist, cannot be created, or disallows writing. + * The persistent store is not accessible, due to permissions or data protection when the device is locked. + * The device is out of space. + * The store could not be migrated to the current model version. + Check the error message to determine what the actual problem was. + */ + fatalError("Unresolved error \(error), \(error.userInfo)") + } + }) + container.viewContext.automaticallyMergesChangesFromParent = true + } +} diff --git a/timerplus/SettingsView.swift b/timerplus/SettingsView.swift new file mode 100644 index 0000000..757b71f --- /dev/null +++ b/timerplus/SettingsView.swift @@ -0,0 +1,120 @@ +// +// SwiftUIView.swift +// final +// +// Created by Jacob Waksmanski on 6/5/25. +// + +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject private var timerSettings: TimerSettings + @Environment(\.dismiss) private var dismiss + + let studyOptions = [15, 20, 25, 30, 35, 40, 45, 50, 55, 60] + let breakOptions = [5, 10, 15, 20, 25, 30] + + var body: some View { + ZStack { + Color(.darkGray) + .ignoresSafeArea() + VStack(spacing: 36) { + Text("Timer Settings") + .font(.system(size: 40, weight: .bold, design: .rounded)) + .foregroundColor(Color(red: 0.44, green: 0.65, blue: 1.0)) + .italic() + .padding(.top, 30) + + VStack(alignment: .leading, spacing: 24) { + Text("Study Time") + .foregroundColor(.white) + .font(.system(size: 22, weight: .regular)) + Picker(selection: $timerSettings.studyTime) { + ForEach(studyOptions, id: \.self) { time in + Text("\(time) minutes").tag(time) + } + } label: { + Text("\(timerSettings.studyTime) minutes").foregroundColor(.black) + } + .pickerStyle(MenuPickerStyle()) + .frame(maxWidth: .infinity) + .padding() + .background(Color.white) + .cornerRadius(12) + .shadow(color: .black.opacity(0.15), radius: 4, x: 0, y: 2) + + Text("Break Time") + .foregroundColor(.white) + .font(.system(size: 22, weight: .regular)) + Picker( + selection: $timerSettings.breakTime, + ) { + ForEach(breakOptions, id: \.self) { time in + Text("\(time) minutes").tag(time) + } + } label: { + Text("\(timerSettings.breakTime) minutes").foregroundColor(.black) + } + .pickerStyle(MenuPickerStyle()) + .frame(maxWidth: .infinity) + .padding() + .background(Color.white) + .cornerRadius(12) + .shadow(color: .black.opacity(0.15), radius: 4, x: 0, y: 2) + } + .padding(.horizontal, 32) + + VStack(alignment: .leading, spacing: 36) { + HStack { + Text("Automatically start timer") + .foregroundColor(.white) + .font(.system(size: 20)) + Spacer() + Toggle("", isOn: $timerSettings.autoStart) + .labelsHidden() + } + HStack { + Text("Play sound on timer switch") + .foregroundColor(.white) + .font(.system(size: 20)) + Spacer() + Toggle("", isOn: $timerSettings.playSound) + .labelsHidden() + } + } + .padding(.horizontal, 32) + + Spacer() + + HStack(spacing: 32) { + Button(action: { + // Back action + dismiss() + }) { + Text("Back") + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.white) + .frame(width: 140, height: 56) + .background(Color.red) + .cornerRadius(14) + } + // Button(action: { + // // Save action + // }) { + // Text("Save") + // .font(.system(size: 22, weight: .bold)) + // .foregroundColor(.white) + // .frame(width: 140, height: 56) + // .background(Color(red: 0.44, green: 0.65, blue: 1.0)) + // .cornerRadius(14) + // } + } + .padding(.bottom, 40) + } + } + } +} + +#Preview { + SettingsView() +} diff --git a/timerplus/TimerSettings.swift b/timerplus/TimerSettings.swift new file mode 100644 index 0000000..dfb4f1f --- /dev/null +++ b/timerplus/TimerSettings.swift @@ -0,0 +1,14 @@ +// +// TimerSettings.swift +// final +// +// Created by Jacob Waksmanski on 6/9/25. +// +import SwiftUI + +class TimerSettings: ObservableObject { + @Published var studyTime: Int = 25 + @Published var breakTime: Int = 5 + @Published var autoStart: Bool = false + @Published var playSound: Bool = false +} diff --git a/timerplus/TimerView.swift b/timerplus/TimerView.swift new file mode 100644 index 0000000..9e0c64d --- /dev/null +++ b/timerplus/TimerView.swift @@ -0,0 +1,218 @@ +// +// TimerRunning.swift +// final +// +// Created by Jacob Waksmanski on 6/9/25. +// + +import AudioToolbox +import SwiftUI + +struct TimerView: View { + + var onBreak: Bool = false + + @EnvironmentObject private var timerSettings: TimerSettings + @State private var totalTime: CGFloat = 1500 // default fallback + @State private var timeRemaining: CGFloat = 1500 + @State private var timerActive = true + @State private var showBreakAlert = false + @State private var navigateToBreak = false + @State private var navigateToStudy = false + @State private var navigateToHome = false + + let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + init(onBreak: Bool = false) { + self.onBreak = onBreak + } + + var body: some View { + NavigationStack { + GeometryReader { geo in + ZStack { + Color(.darkGray) + .edgesIgnoringSafeArea(.all) + + // Shrinking area + VStack { + Spacer() + Spacer(minLength: 0) + Color(backgroundColor) + .frame( + width: geo.size.width, + height: blueHeight(for: geo.size.height) + ) + .cornerRadius(0) + .shadow(radius: 16) + } + .edgesIgnoringSafeArea(.all) + + // Timer and buttons + VStack { + Spacer() + Text(timeString) + .font(.system(size: 72, weight: .semibold, design: .monospaced)) + .foregroundColor(.white) + .padding(.bottom, 24) + if timerActive { + Button(action: { withAnimation(nil) { timerActive = false } }) { + Text("Pause") + .font(.system(size: 24, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 32) + .padding(.vertical, 12) + .background(buttonColor) + .cornerRadius(18) + } + .buttonStyle(PlainButtonStyle()) + .padding(.bottom, 32) + } else { + Button(action: { withAnimation(nil) { timerActive = true } }) { + Text("Resume") + .font(.system(size: 24, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 32) + .padding(.vertical, 12) + .background(resumeButtonColor) + .cornerRadius(18) + } + .buttonStyle(PlainButtonStyle()) + .padding(.bottom, 16) + Button(action: { + timeRemaining = totalTime + timerActive = false + navigateToHome = true + }) { + Text("Stop") + .font(.system(size: 24, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 32) + .padding(.vertical, 12) + .background(stopButtonColor) + .cornerRadius(18) + } + .buttonStyle(PlainButtonStyle()) + .padding(.bottom, 32) + } + Spacer() + Button(action: { showBreakAlert = true }) { + Text(onBreak ? "Study again >>" : "Take a break >>") + .font(.system(size: 28)) + .foregroundColor(.white) + .opacity(0.8) + .padding(.bottom, 40) + } + } + .frame(width: geo.size.width, height: geo.size.height) + + .navigationDestination(isPresented: $navigateToBreak) { + if timerSettings.autoStart { + TimerView(onBreak: true) + } else { + BreakView() + } + } + .navigationDestination(isPresented: $navigateToStudy) { + TimerView(onBreak: false) + } + .navigationDestination(isPresented: $navigateToHome) { + ContentView() + } + } + .onAppear { + if onBreak { + totalTime = CGFloat(timerSettings.breakTime * 60) + timeRemaining = CGFloat(timerSettings.breakTime * 60) +// timeRemaining = 5 + } else { + totalTime = CGFloat(timerSettings.studyTime * 60) + timeRemaining = CGFloat(timerSettings.studyTime * 60) + } + } + .onReceive(timer) { _ in + guard timerActive, timeRemaining > 0 else { + if timeRemaining <= 0 { + timerActive = false + if timerSettings.playSound { + AudioServicesPlaySystemSound(1005) + } + if onBreak { + if timerSettings.autoStart { + navigateToStudy = true + } else { + navigateToHome = true + } + } else { + navigateToBreak = true // Go to break after study + } + return + } + return + } + timeRemaining -= 1 + } + .alert(isPresented: $showBreakAlert) { + if onBreak { + return Alert( + title: Text("Back to Studying").fontWeight(.semibold), + message: Text("Finish your break and go back to studying?"), + primaryButton: .default( + Text("Yes"), + action: { + if timerSettings.autoStart { + navigateToStudy = true + } else { + navigateToHome = true + } + }), + secondaryButton: .cancel(Text("No")) + ) + } else { + return Alert( + title: Text("Early Break").fontWeight(.semibold), + message: Text( + "Would you like to end the timer and skip to your break?"), + primaryButton: .default( + Text("Yes"), + action: { + navigateToBreak = true + }), + secondaryButton: .cancel(Text("No")) + ) + } + } + } + .navigationBarBackButtonHidden(true) + } + } + + func blueHeight(for maxHeight: CGFloat) -> CGFloat { + let percent = timeRemaining / totalTime + return maxHeight * percent + } + + var timeString: String { + let minutes = Int(timeRemaining) / 60 + let seconds = Int(timeRemaining) % 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + var backgroundColor: Color { + onBreak + ? Color(red: 1.0, green: 0.28, blue: 0.26) : Color(red: 0.53, green: 0.76, blue: 1.0) + } + var buttonColor: Color { + onBreak ? Color(.darkGray) : Color.red + } + var resumeButtonColor: Color { + onBreak ? Color(red: 0.53, green: 0.76, blue: 1.0) : Color(.darkGray) + } + var stopButtonColor: Color { + onBreak ? Color(.darkGray) : Color.red + } +} + +#Preview { + TimerView(onBreak: false) +} diff --git a/timerplus/timerplus.swift b/timerplus/timerplus.swift new file mode 100644 index 0000000..e949fc7 --- /dev/null +++ b/timerplus/timerplus.swift @@ -0,0 +1,21 @@ +// +// finalApp.swift +// final +// +// Created by Jacob Waksmanski on 6/5/25. +// + +import SwiftUI + +@main +struct timerplus: App { + let persistenceController = PersistenceController.shared + @StateObject private var timerSettings = TimerSettings() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(timerSettings) + } + } +} diff --git a/timerplus/timerplus.xcdatamodeld/.xccurrentversion b/timerplus/timerplus.xcdatamodeld/.xccurrentversion new file mode 100644 index 0000000..6e456ed --- /dev/null +++ b/timerplus/timerplus.xcdatamodeld/.xccurrentversion @@ -0,0 +1,8 @@ + + + + + _XCCurrentVersionName + final.xcdatamodel + + diff --git a/timerplus/timerplus.xcdatamodeld/final.xcdatamodel/Contents b/timerplus/timerplus.xcdatamodeld/final.xcdatamodel/Contents new file mode 100644 index 0000000..9ed2921 --- /dev/null +++ b/timerplus/timerplus.xcdatamodeld/final.xcdatamodel/Contents @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file